个人项目:社交支付项目(小老板)
作者:三哥(j3code.cn)
文档系统:admire.j3code.cn/note
上篇我们花了 44 块钱实现了一个发送短信的工具类,那么这篇就要让其派上用场了,来编写一个通过短信验证码方式实现的用户注册与登录。
开始之前呢,你们需要先搭建一个基本的 SpringBoot 项目,方便后续的代码编写(不过多赘述)。
1、表字段分析
首先我们来分析一下用户表的相关字段:
用户id
电话:phone
密码:password
而后面可能需要一些扫码登录,所以预留扫码登录的一些字段
openid
session_key
unionid
另外,再适当个性化的加一些字段,标识用户
头像:avatar_url
昵称:nick_name
性别:sex
简介:intro
创建时间:create_time
修改时间:update_time
ok,那么用户表的创建 SQL 如下:
CREATE TABLE `sb_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`phone` varchar(15) COLLATE utf8mb4_german2_ci DEFAULT NULL COMMENT '电话',
`password` varchar(200) COLLATE utf8mb4_german2_ci DEFAULT NULL COMMENT '密码',
`avatar_url` varchar(500) COLLATE utf8mb4_german2_ci DEFAULT 'https://thirdwx.qlogo.cn/mmopen/vi_32/POgEwh4mIHO4nibH0KlMECNjjGxQUq24ZEaGT4poC6icRiccVGKSyXwibcPq4BWmiaIGuG1icwxaQX6grC9VemZoJ8rg/132' COMMENT '头像',
`nick_name` varchar(50) COLLATE utf8mb4_german2_ci DEFAULT '默认用户' COMMENT '昵称',
`openid` varchar(200) COLLATE utf8mb4_german2_ci DEFAULT NULL,
`session_key` varchar(200) COLLATE utf8mb4_german2_ci DEFAULT NULL,
`unionid` varchar(200) COLLATE utf8mb4_german2_ci DEFAULT NULL,
`sex` tinyint(1) DEFAULT '1' COMMENT '性别,0女1男',
`intro` varchar(256) COLLATE utf8mb4_german2_ci DEFAULT NULL COMMENT '简介',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `un_1` (`phone`),
UNIQUE KEY `un_2` (`openid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci
用户表设计好了之后,那么开始我们的短信记录表的设计吧!
为啥要有这张表,我觉得最主要的一个原因还是方便运营人员统计短信的发放数目,方便后续的运营策略。当然这对于我们开发人员来说也是一样,记录就相当于一个日志,如果出现啥问题,开发人员也好回查,所以做一个短信记录的表是很有意义的。
这张表不用记太多字段,有如下这些就行:
id
电话:phone
发送状态:status
备注:remark
创建时间:create_time
对映的表 SQL 如下:
CREATE TABLE `sb_sms_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`phone` varchar(11) COLLATE utf8_unicode_ci NOT NULL COMMENT '号码',
`status` int(11) NOT NULL COMMENT '发送状态',
`remark` varchar(200) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT '备注',
`create_time` datetime DEFAULT NULL COMMENT '发送时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
在数据库中创建表之后,我们通过 IDEA 的插件 MyBatisX
插件来生成对应的 entity、service、mapper 等类或 xml 文件。
1)安装 MyBatisX
插件(我想大家都会)
2)项目连接 MySQL 数据库
-
开始连接你的数据库
3)使用插件,生成代码
-
只能一个表一个表的生成代码,步骤如图:
2、短信验证码注册分析
现在,发挥你们大脑的时刻到了,如果让你来考虑一下用户注册的流程,你会如何做呢!
因为我们这是短信验证码方式注册,所以肯定要考虑如下的问题:
开始分析每一步的流程,这里只是大致的流程,具体的细节要到代码中去考量。
1、注册页面回显一个图形验证码,该验证码可以通过按钮触发更换
2、用户获取短信验证码,必须携带电话 + 图形验证码,才可获取短信验证码
3、注册时需提交如下信息:
- 电话
- 短信验证码
- 密码
4、一系列的校验流程,成功之后才能进入后续步骤
5、生成用户信息,注册成功
大致的流程出来了,那么为了加深一下印象,我们再来画个时序图:
现在清晰多了,那么开始编码。
3、编码实现注册功能
3.1 获取图形验证码
别嫌我啰嗦,获取图形验证码的流程我们还是要再来分析分析。
先搞清楚一点,为啥需要这个。
目的:防止用户刷发送短信接口,只有发送短信接口中携带的图形验证码正确才能触发后续的短信发送功能,否则一律不发送验证码。
现在知道这个图形验证码作用了吧!
那下面我画一个代码实现的基本流程图,确保代码的每一步都清清楚楚:
编码实现:
前提
:先创建好如下几个类:SmsAuthController:短信认证相关控制器 AuthService 和 AuthServiceImpl:认证业务接口和实现类
1)controller 接口方法编写
@Slf4j
@ResponseResult
@AllArgsConstructor
@RestController
@RequestMapping(UrlPrefixConstants.V1 + "/sms/auth")
public class SmsAuthController {
private final AuthService service;
/**
* 用户登录或者注册获取图形验证码
*
* @return 图片 Base64 字符串
*/
@GetMapping("/getCaptcha")
public String getCaptcha() {
return service.getCaptcha();
}
}
2)service 方法实现
public interface AuthService {
String getCaptcha();
}
@Slf4j
@Service
@AllArgsConstructor
public class AuthServiceImpl implements AuthService {
private final RedisTemplate redisTemplate;
@Override
public String getCaptcha() {
// 生成 图形验证码
RandomGenerator randomGenerator = new RandomGenerator("abcdefghjkmnopqrstuvwxyz023456789", 4);
LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(100, 42, 4, 60);
lineCaptcha.setGenerator(randomGenerator);
lineCaptcha.createCode();
// 获取 base64 字符串 和 code 值
String base64 = "data:image/png;base64," + lineCaptcha.getImageBase64();
String code = lineCaptcha.getCode();
log.info("图形验证码内容:{}", code);
log.debug("图形验证码base64:{}", base64);
// 根据 code ,生成 redis 的 key,登录或注册时需要验证
String key = SbUtil.captchaRedisKey(code);
// 存入 redis(key,value,超时时间)
redisTemplate.opsForValue().set(key, code, ServerNameConstants.CAPTCHA_KEY_EXPIRED_TIME);
return base64;
}
}
注:SbUtil 和 ServerNameConstants 类是系统封装的工具类和常量类,代码就不贴了,你们可以根据业务灵活实现。
3.2 获取短信验证码
这节就来实现如何发送短信验证码了。
那还是来分析一下,这个流程需要考虑那些问题:
考虑的问题虽然不多,但是校验这块却是本次的重点,只要校验做的好,升职加薪少不了。
写代码之前,还是来画画流程图:
注意图中的几个校验,其中有一个地方会存在并发安全问题,不知道你们会不会发现?
编码实现:
1)controller 编写
@Slf4j
@ResponseResult
@AllArgsConstructor
@RestController
@RequestMapping(UrlPrefixConstants.V1 + "/sms/auth")
public class SmsAuthController {
private final AuthService service;
/**
* 发送短信验证码
*
* @param phone 电话号码
* @param code 图形验证码 code
*/
@GetMapping("/sendSms")
public void sendSms(@RequestParam("phone") String phone, @RequestParam("code") String code) {
service.sendSms(phone, code);
}
}
2)service 方法实现
public interface AuthService {
void sendSms(String phone, String code);
}
@Slf4j
@Service
@AllArgsConstructor
public class AuthServiceImpl implements AuthService {
private final UserService userService;
private final RedisTemplate redisTemplate;
private final ApplicationContext applicationContext;
private final SendSmsConfig sendSmsConfig;
private final SmsLogService smsLogService;
@Override
public void sendSms(String phone, String code) {
// 校验
checkPhoneAndRegisterAndKey(phone, SbUtil.captchaRedisKey(code));
// 开始发送短信验证码
// 生成 6 位数字验证码
String smsCode = RandomUtil.randomNumbers(6);
// 记录
SmsLog smsLog = new SmsLog()
.setPhone(phone)
.setRemark(String.format("用户注册短信验证码发送:%s", smsCode));
// 封装短信发送参数
final var smsRequest = new SendSmsUtil.SendSmsRequest()
.setSmsSdkAppId(sendSmsConfig.getSmsSdkAppId())
.setSecretId(sendSmsConfig.getSecretId())
.setSecretKey(sendSmsConfig.getSecretKey())
.setSignName(sendSmsConfig.getSignName())
.setTemplateId(sendSmsConfig.getTemplateId())
.setPhone(phone)
.setTemplateParamSet(new String[]{smsCode, "" + ServerNameConstants.SMS_KEY_EXPIRED_TIME.get(ChronoUnit.SECONDS) / 60});
// 发送短信验证码并获取结果
smsLog.setStatus(SendSmsUtil.sendSms(smsRequest) ? SuccessFailStatusEnum.SUCCESS : SuccessFailStatusEnum.FAIL);
try {
// 保存发送日志
smsLogService.save(smsLog);
} catch (Exception e) {
log.error("保存短信发送记录失败:", e);
}
if (SuccessFailStatusEnum.FAIL.equals(smsLog.getStatus())) {
throw new SendSmsAuthException("短信发送失败!");
}
// 存入 redis,校验用户的注册逻辑
redisTemplate.opsForValue().set(SbUtil.smsRedisKey(phone + ":" + smsCode), smsCode, ServerNameConstants.SMS_KEY_EXPIRED_TIME);
}
/**
* 加分布式锁,确保此功能原子
*
* @param key code 再 redis 中对应的 key
*/
@DistributedLock(key = "check-code-{1}", lockFail = true, failMessage = "请求频繁,稍后重试!")
public void checkey(String key) {
// 校验图形验证码是否正确
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
throw new SendSmsAuthException("验证码不正确或已过期!");
}
// 证码成功,删除 key,防止多次提交,多次发送短信
redisTemplate.delete(key);
}
/**
* 校验电话号码是否合格,是否已经注册,key 是否正确
*
* @param phone
* @param key
*/
public void checkPhoneAndRegisterAndKey(String phone, String key) {
// 校验电话号码
if (Boolean.FALSE.equals(SbUtil.checkPhone(phone))) {
throw new SendSmsAuthException("请正确输入电话号码!");
}
// 是否注册
User user = userService.lambdaQuery()
.eq(User::getPhone, phone)
.one();
if (Objects.nonNull(user)) {
throw new RegisterAuthException("电话号码已存在,请直接登录!");
}
// key 是否存在
applicationContext.getBean(AuthServiceImpl.class).checkey(key);
}
}
SendSmsConfig 配置代码:
@Slf4j
@Data
@Configuration
@ConfigurationProperties(prefix = "send.sms")
public class SendSmsConfig {
/**
* 短信签名内容,必须填写已审核通过的签名
*/
private String signName;
/**
* 模板 ID: 必须填写已审核通过的模板 ID
*/
private String templateId;
/**
* 应用id
*/
private String smsSdkAppId;
/**
* api密钥中的secretId
*/
private String secretId;
/**
* api密钥中的应用密钥
*/
private String secretKey;
}
分析一下上面的代码:
先做了三个校验:校验电话号码、校验号码是否已经注册、校验图形验证码是否正确
这里要重点说一下:校验图形验证码是否正确
大家试想一下,如果我们从 Redis 中校验了图形验证码合格,然后不去删除此验证码,是不是在验证码过期之内,用户有可能一直携带这个有效验证码进行刷接口,导致我们的短信不停的发送啊!
所以我们这里的做法肯定是两步:
校验验证码是否合格 合格,移除验证码 但是新的问题又来了,如果用户一次性进行大量的请求(并发)都打到第一步,而第一步同样是符合要求任然会进入到第二步,导致发送多条短信,所以必须让 1、2 两步原子的执行。
解决:
分布式锁
推荐我的手写分布式锁视频:www.bilibili.com/video/BV1d4…
Lua 脚本执行 redis
那么,分析到这里大家就知道我为啥使用了一个分布式锁了。至于 Lua 脚本,后续我也会在优化版本中将代码写出来。
生成 6 位短信验证码,封装发送短信参数,短信发送
保存短信记录
短信验证码存入 Redis,用户注册时进行校验
3.3 短信验证码实现注册
经过了上面两个步骤,终于是要真正的开始注册用户数据了,那流程走到这里,我们要明确下面这几件事:
ok,再来画一画此功能的消息流程图:
好,现在开始编码。
1)controller 编写
@Slf4j
@ResponseResult
@AllArgsConstructor
@RestController
@RequestMapping(UrlPrefixConstants.V1 + "/sms/auth")
public class SmsAuthController {
private final AuthService service;
/**
* 用户注册
*
* @param request 请求数据
*/
@PostMapping("/register")
public void register(@Validated @RequestBody RegisterRequest request) {
service.register(request);
}
}
RegisterRequest 请求体:
@Data
public class RegisterRequest {
/**
* 电话
*/
@NotBlank(message = "电话号码不为空")
private String phone;
/**
* 短信验证码
*/
@NotBlank(message = "短信验证码不为空")
private String smsCode;
/**
* 密码
*/
@NotBlank(message = "密码不为空")
private String password;
}
2)service 编写
public interface AuthService {
void register(RegisterRequest request);
}
@Slf4j
@Service
@AllArgsConstructor
public class AuthServiceImpl implements AuthService {
private final UserService userService;
private final RedisTemplate redisTemplate;
private final ApplicationContext applicationContext;
private final SendSmsConfig sendSmsConfig;
private final SmsLogService smsLogService;
@Override
public void register(RegisterRequest request) {
// 校验
checkPhoneAndRegisterAndKey(request.getPhone(), SbUtil.smsRedisKey(request.getPhone() + ":" + request.getSmsCode()));
// 创建并保存用户对象
boolean isSave = userService.save(new User()
.setPhone(request.getPhone())
.setPassword(request.getPassword()));
if (Boolean.FALSE.equals(isSave)) {
throw new RegisterAuthException("注册失败,请联系管理员!");
}
}
}
注:有些方法在 3.2 节中已经写过了,就不贴出来了
接着,我们对 User 对象内部做了一些调整,提供了下面两个方法:
/** * 设置密码,自动加密 * @param password * @return */ public User setPassword(String password) { if (StringUtils.isBlank(password)) { throw new PasswordException("密码不能为空!"); } this.password = MD5.create().digestHex(password); return this; } /** * 判断当前对象密码与传入密码是否相等 * @param password * @return */ public Boolean passwordEqual(String password) { if (StringUtils.isBlank(password)){ return Boolean.FALSE; } /** * 这里,进行了两次 MD5,因为存入的时候对原值 MD5 了一次, * 然后取出来调用 set 的时候又 MD5 了一次,所以要进行两次 MD5 进行比较 */ String hex = MD5.create().digestHex(password); String hex02 = MD5.create().digestHex(hex); return StringUtils.isBlank(this.password) ? Boolean.FALSE : this.password.equals(hex02); }
这样做的目的是为了高内聚,因为密码是用户的东西,所以关于密码的操作都内聚到用户类中。
4、编码实现登录功能
根据注册功能的实现,我们知道系统中已经有了用户的电话和密码,所以我们应该要提供两种登录的方式:短信登录、电话 + 密码登录。
4.1 短信验证码登录
仔细分析,我们能发现,这个功能的实现流程和注册功能类似,也是有下面几步:
所以,这里为了不重复编写代码,那么就对上面的 3.2 小节的功能实现做一些小小的改动。
3.1 不用动,因为功能一摸一样,只不过 3.2 会有发送记录,和短信发送模板需要改动,所以咱们只改 3.2 节
3.2 调整如下:
1、添加短信发送类型枚举,有了这个,后续就可以灵活的添加短信发送类型了
@Getter
public enum SendSmsTypeEnum {
LOGIN("LOGIN", "登录", "1847057"),
REGISTER("REGISTER", "注册", "1843226"),
;
private String value;
private String description;
/**
* 短信模板id
*/
private String templateId;
SendSmsTypeEnum(String value, String description, String templateId) {
this.value = value;
this.description = description;
this.templateId = templateId;
}
}
2、修改 controller 的发送短信接口定义
/**
* 发送短信验证码
*
* @param request 发送短信请求参数
*/
@PostMapping("/sendSms")
public void sendSms(@Validated @RequestBody SendSmsRequest request) {
service.sendSms(request);
}
3、定义 SendSmsRequest 请求体
@Data
public class SendSmsRequest {
/**
* 电话
*/
@NotBlank(message = "电话不为空")
private String phone;
/**
* 验证码
*/
@NotBlank(message = "验证码不为空")
private String code;
/**
* 类型
*/
@NotNull(message = "类型不为空")
private SendSmsTypeEnum sendSmsType;
}
4、修改 service 中的发送短信方法
@Override
public void sendSms(SendSmsRequest request) {
if (SendSmsTypeEnum.REGISTER.equals(request.getSendSmsType())) {
// 校验注册
checkPhoneAndRegisterAndKey(request.getPhone(), SbUtil.captchaRedisKey(request.getCode()));
} else if (SendSmsTypeEnum.LOGIN.equals(request.getSendSmsType())) {
// 校验登录
checkPhoneAndLoginAndKey(request.getPhone(), SbUtil.captchaRedisKey(request.getCode()));
}
// 开始发送短信验证码
// 生成 6 位数字验证码
String smsCode = RandomUtil.randomNumbers(6);
// 记录
SmsLog smsLog = new SmsLog()
.setPhone(request.getPhone())
.setRemark(String.format("用户" + request.getSendSmsType().getDescription() + "短信验证码发送:%s", smsCode));
// 封装短信发送参数
final var smsRequest = new SendSmsUtil.SendSmsRequest()
.setSmsSdkAppId(sendSmsConfig.getSmsSdkAppId())
.setSecretId(sendSmsConfig.getSecretId())
.setSecretKey(sendSmsConfig.getSecretKey())
.setSignName(sendSmsConfig.getSignName())
.setTemplateId(request.getSendSmsType().getTemplateId())
.setPhone(request.getPhone())
// 设置验证码、过期时间,因为短信模板有2个占位符
.setTemplateParamSet(new String[]{smsCode, "" + ServerNameConstants.SMS_KEY_EXPIRED_TIME.get(ChronoUnit.SECONDS) / 60});
// 发送短信验证码并获取结果
smsLog.setStatus(SendSmsUtil.sendSms(smsRequest) ? SuccessFailStatusEnum.SUCCESS : SuccessFailStatusEnum.FAIL);
try {
// 保存发送日志
smsLogService.save(smsLog);
} catch (Exception e) {
log.error("保存短信发送记录失败:", e);
}
if (SuccessFailStatusEnum.FAIL.equals(smsLog.getStatus())) {
throw new SendSmsAuthException("短信发送失败!");
}
// 存入 redis,校验用户的注册逻辑
redisTemplate.opsForValue().set(SbUtil.smsRedisKey(request.getPhone() + ":" + smsCode), smsCode, ServerNameConstants.SMS_KEY_EXPIRED_TIME);
}
/**
* 校验电话号码是否合格,是否已经注册,key 是否正确
*
* @param phone
* @param key
*/
public void checkPhoneAndRegisterAndKey(String phone, String key) {
// 校验电话号码
if (Boolean.FALSE.equals(SbUtil.checkPhone(phone))) {
throw new SendSmsAuthException("请正确输入电话号码!");
}
// 是否注册
User user = userService.lambdaQuery()
.eq(User::getPhone, phone)
.one();
if (Objects.nonNull(user)) {
throw new RegisterAuthException("电话号码已存在,请直接登录!");
}
// key 是否存在
applicationContext.getBean(AuthServiceImpl.class).checkey(key);
}
/**
* 校验电话号码是否合格,是否未注册,key 是否正确
*
* @param phone
* @param key
*/
public void checkPhoneAndLoginAndKey(String phone, String key) {
// 校验电话号码
if (Boolean.FALSE.equals(SbUtil.checkPhone(phone))) {
throw new SendSmsAuthException("请正确输入电话号码!");
}
// 是否注册
User user = userService.lambdaQuery()
.eq(User::getPhone, phone)
.one();
if (Objects.isNull(user)) {
throw new LoginAuthException("电话号码不存在,请注册!");
}
// key 是否存在
applicationContext.getBean(AuthServiceImpl.class).checkey(key);
}
自此,我们的发送短信功能改造就完成了,现在它适用于 SendSmsTypeEnum 枚举中定义的任何一种短信发送。现在,开始实现我们的短信登录功能。
4.1.1 短信登录
对于短信登录,我们也先确定好一些先前条件:
嗯,就这一个,剩下的就是后端校验验证码是否正确,用户是否存在一类的了。接着还是老样子,画一下短信登录的流程图:
开始编写代码:
1)contoller 实现
/**
* 短信登录
*
* @param phone 电话
* @param smsCode 短信验证码
* @return token
*/
@GetMapping("/loginBySms")
public String loginBySms(@RequestParam("phone") String phone, @RequestParam("smsCode") String smsCode) {
return service.loginBySms(phone, smsCode);
}
2)编写 service
public interface AuthService {
String loginBySms(String phone, String smsCode);
}
@Slf4j
@Service
@AllArgsConstructor
public class AuthServiceImpl implements AuthService {
private final UserService userService;
private final RedisTemplate redisTemplate;
private final ApplicationContext applicationContext;
@Override
public String loginBySms(String phone, String smsCode) {
// 校验电话号码
if (Boolean.FALSE.equals(SbUtil.checkPhone(phone))) {
throw new SendSmsAuthException("请正确输入电话号码!");
}
// 是否注册
User user = userService.lambdaQuery()
.eq(User::getPhone, phone)
.one();
if (Objects.isNull(user)) {
throw new LoginAuthException("电话号码不存在,请注册!");
}
// key 是否存在
applicationContext.getBean(AuthServiceImpl.class).checkey(SbUtil.smsRedisKey(phone + ":" + smsCode));
// 生成 token,并存入 redis(带过期时间)
String token = SbUtil.getTokenStr();
redisTemplate.opsForValue().set(SbUtil.loginRedisKey(token), JSON.toJSONString(user), ServerNameConstants.AUTH_KEY_EXPIRED_TIME);
// 返回 token
return token;
}
}
至此呢,一个短信登录功能就做好了,当然我相信这个都解决了,那么下面的功能就复制粘贴微改一下就 ok 了,看我操作。
4.2 电话 + 密码登录
这个功能的前提:
流程图我就不画了,和短信登录类似,我就直接开始撸代码。
1)controller 编写
/**
* 密码登录
*
* @param phone 电话
* @param password 密码
* @return token
*/
@GetMapping("/loginByPassword")
public String loginByPassword(@RequestParam("phone") String phone, @RequestParam("password") String password) {
return service.loginByPassword(phone, password);
}
2)service 编写
public interface AuthService {
String loginByPassword(String phone, String password);
}
@Slf4j
@Service
@AllArgsConstructor
public class AuthServiceImpl implements AuthService {
private final UserService userService;
private final RedisTemplate redisTemplate;
private final ApplicationContext applicationContext;
@Override
public String loginByPassword(String phone, String password) {
// 校验电话号码
if (Boolean.FALSE.equals(SbUtil.checkPhone(phone))) {
throw new SendSmsAuthException("请正确输入电话号码!");
}
// 是否注册
User user = userService.lambdaQuery()
.eq(User::getPhone, phone)
.one();
if (Objects.isNull(user)) {
throw new LoginAuthException("电话号码不存在,请注册!");
}
// 校验密码
if (Boolean.FALSE.equals(user.passwordEqual(password))){
throw new LoginAuthException("密码不正确,请重新输入!");
}
// 生成 token,并存入 redis(带过期时间)
String token = SbUtil.getTokenStr();
redisTemplate.opsForValue().set(SbUtil.loginRedisKey(token), JSON.toJSONString(user), ServerNameConstants.AUTH_KEY_EXPIRED_TIME);
// 返回 token
return token;
}
}
自此,我们的登录相关功能就已经做完了。可能有人会说咋没有退出登录呢,这个别急,做这个功能之前呢,有一些优先功能要实现,之后才能开发退出登录功能。
这里先卖个关子,咱们把功能留到下篇实现。
好了,今天内容就结束了,关注我,精彩文章马上到来。