设备注册 短信跟邮箱验证
This commit is contained in:
@@ -80,6 +80,14 @@ public final class AppConstants {
|
||||
*/
|
||||
public static final String SCENE_BIND = "bind";
|
||||
|
||||
/**
|
||||
* 场景:换绑前确认原手机号。
|
||||
* <p>
|
||||
* 仅登录后的 {@code POST /app/user/phone/old-code} 可发送;公共 {@code /app/auth/sms/code} 拒绝本场景,
|
||||
* 避免客户端指定任意号码冒充原号通道。与 {@link #SCENE_REBIND}(新号)缓存 key 隔离。
|
||||
*/
|
||||
public static final String SCENE_REBIND_OLD = "rebindOld";
|
||||
|
||||
// ---------------- 协议类型 ----------------
|
||||
|
||||
/**
|
||||
@@ -109,6 +117,21 @@ public final class AppConstants {
|
||||
*/
|
||||
public static final String SMS_FAIL_KEY_PREFIX = "app:sms:fail:";
|
||||
|
||||
/**
|
||||
* 换绑原号核验状态 key 前缀,完整 key = 前缀 + userId。
|
||||
* <p>
|
||||
* {@code POST /app/user/phone/verify-old} 写入,{@code PUT /app/user/phone} 换绑成功后删除;
|
||||
* TTL 与短信验证码有效期一致。
|
||||
*/
|
||||
public static final String REBIND_OLD_OK_KEY_PREFIX = "app:rebind:old-ok:";
|
||||
|
||||
/**
|
||||
* 手机号绑定/换绑并发保护锁 key 前缀,完整 key = 前缀 + userId。
|
||||
* <p>
|
||||
* 将原号核验状态检查、手机号更新和状态失效串行化,避免同一账号并发换绑重复消费一次核验状态。
|
||||
*/
|
||||
public static final String PHONE_REBIND_LOCK_KEY_PREFIX = "app:user:phone-rebind:lock:";
|
||||
|
||||
/**
|
||||
* 「错误尝试」限速窗口内允许的最大失败次数(P2 加固后语义)。
|
||||
* <p>
|
||||
@@ -160,4 +183,14 @@ public final class AppConstants {
|
||||
*/
|
||||
public static final String EMAIL_REGISTER_LOCK_KEY_PREFIX = "app:user:register:lock:email:";
|
||||
|
||||
/**
|
||||
* 换绑原邮箱核验状态 key 前缀,完整 key = 前缀 + userId。
|
||||
*/
|
||||
public static final String EMAIL_REBIND_OLD_OK_KEY_PREFIX = "app:rebind:email:old-ok:";
|
||||
|
||||
/**
|
||||
* 邮箱绑定/换绑并发保护锁 key 前缀,完整 key = 前缀 + userId。
|
||||
*/
|
||||
public static final String EMAIL_REBIND_LOCK_KEY_PREFIX = "app:user:email-rebind:lock:";
|
||||
|
||||
}
|
||||
|
||||
@@ -73,16 +73,20 @@ public final class AppMsgConstants {
|
||||
public static final String EMAIL_DOMAIN_NOT_ALLOWED = "该邮箱域名暂不支持注册";
|
||||
|
||||
/**
|
||||
* 邮箱注册成功(提示继续绑定手机号)。
|
||||
* <p>「注册后仍须绑手机号」为本迭代的账号规则,故注册成功文案直接给出下一步引导。
|
||||
* 邮箱注册成功,注册后可以直接使用邮箱登录;绑定手机号仅在绑定设备前要求。
|
||||
*/
|
||||
public static final String EMAIL_REGISTER_SUCCESS = "注册成功,请绑定手机号";
|
||||
public static final String EMAIL_REGISTER_SUCCESS = "注册成功,请登录";
|
||||
|
||||
/**
|
||||
* 该账号尚未绑定手机号(绑定设备前的硬拦截文案)。
|
||||
*/
|
||||
public static final String PHONE_NOT_BOUND = "请先绑定手机号";
|
||||
|
||||
/**
|
||||
* 当前账号尚未绑定邮箱,无法发送原邮箱核验码。
|
||||
*/
|
||||
public static final String EMAIL_NOT_BOUND = "请先绑定邮箱";
|
||||
|
||||
// ---------------- 账号 ----------------
|
||||
|
||||
/**
|
||||
@@ -160,6 +164,36 @@ public final class AppMsgConstants {
|
||||
*/
|
||||
public static final String PHONE_SAME_AS_CURRENT = "新手机号与当前手机号相同";
|
||||
|
||||
/**
|
||||
* 原手机号验证码校验成功(换绑第 1 步)。
|
||||
*/
|
||||
public static final String OLD_PHONE_VERIFY_SUCCESS = "验证成功";
|
||||
|
||||
/**
|
||||
* 换绑第 2 步缺少有效的原号核验状态。
|
||||
*/
|
||||
public static final String OLD_PHONE_NOT_VERIFIED = "请先验证原手机号";
|
||||
|
||||
/**
|
||||
* 邮箱更换成功。
|
||||
*/
|
||||
public static final String EMAIL_UPDATE_SUCCESS = "邮箱更换成功";
|
||||
|
||||
/**
|
||||
* 新邮箱与当前邮箱相同。
|
||||
*/
|
||||
public static final String EMAIL_SAME_AS_CURRENT = "新邮箱与当前邮箱相同";
|
||||
|
||||
/**
|
||||
* 原邮箱验证码校验成功。
|
||||
*/
|
||||
public static final String OLD_EMAIL_VERIFY_SUCCESS = "验证成功";
|
||||
|
||||
/**
|
||||
* 换绑邮箱缺少有效的原邮箱核验状态。
|
||||
*/
|
||||
public static final String OLD_EMAIL_NOT_VERIFIED = "请先验证原邮箱";
|
||||
|
||||
/**
|
||||
* 协议类型不合法。
|
||||
* <p>TODO 阶段 7 按原型核对。
|
||||
|
||||
@@ -107,15 +107,16 @@ public class AppAuthController extends BaseController {
|
||||
*/
|
||||
@PostMapping("/email/code")
|
||||
public R<Void> sendEmailCode(@Validated @RequestBody AppEmailCodeBo bo) {
|
||||
appAuthService.sendEmailCode(bo);
|
||||
return R.ok(AppMsgConstants.EMAIL_CODE_SENT);
|
||||
String code = appAuthService.sendEmailCode(bo);
|
||||
// 开发环境 mock 模式与短信接口保持一致:把验证码放入 msg;真实邮件仍只返回标准成功文案。
|
||||
return R.ok(code == null ? AppMsgConstants.EMAIL_CODE_SENT : code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱注册。
|
||||
* <p>
|
||||
* 成功文案给出「请绑定手机号」引导:按本迭代账号规则,邮箱注册后须继续绑定手机号,
|
||||
* 未绑手机号的账号无法绑定设备(服务端硬拦截,见 {@code IAppDeviceService#bind})。
|
||||
* 邮箱注册完成后可直接使用邮箱登录;未绑定手机号的账号仅在绑定设备时受限
|
||||
* (服务端硬拦截,见 {@code IAppDeviceService#bind})。
|
||||
*/
|
||||
@PostMapping("/email/register")
|
||||
public R<Void> emailRegister(@Validated @RequestBody AppEmailRegisterBo bo) {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package org.dromara.device.controller.app;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.utils.MessageUtils;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.constant.AppMsgConstants;
|
||||
import org.dromara.device.domain.bo.AppOldPhoneCodeBo;
|
||||
import org.dromara.device.domain.bo.AppOldEmailCodeBo;
|
||||
import org.dromara.device.domain.bo.AppPasswordBo;
|
||||
import org.dromara.device.domain.bo.AppEmailBo;
|
||||
import org.dromara.device.domain.bo.AppPhoneBo;
|
||||
import org.dromara.device.domain.vo.AppAgreementVo;
|
||||
import org.dromara.device.domain.vo.AppUserProfileVo;
|
||||
@@ -12,6 +17,7 @@ import org.dromara.device.service.IAppUserService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -21,8 +27,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
/**
|
||||
* APP 个人中心 Controller(前缀 {@code /app/user})。
|
||||
* <p>
|
||||
* 本组接口需登录态;当前用户取自 {@code LoginHelper.getUserId()}(登录标识 {@code app_user:{userId}}),
|
||||
* 仅能操作本人数据,不做后台数据权限。
|
||||
* 除协议查询接口外,本组接口均需登录态;当前用户取自 {@code LoginHelper.getUserId()}
|
||||
* (登录标识 {@code app_user:{userId}}),仅能操作本人数据,不做后台数据权限。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
@@ -53,7 +59,25 @@ public class AppUserController extends BaseController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 换绑手机号(新手机号 + 验证码)。
|
||||
* 向当前账号原手机号发送换绑确认验证码。
|
||||
*/
|
||||
@PostMapping("/phone/old-code")
|
||||
public R<Void> sendOldPhoneCode() {
|
||||
String code = appUserService.sendOldPhoneCode();
|
||||
return R.ok(code == null ? MessageUtils.message("app.sms.code.sent") : code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验原手机号验证码,通过后写入换绑核验状态,APP 再进入绑定新号页。
|
||||
*/
|
||||
@PostMapping("/phone/verify-old")
|
||||
public R<Void> verifyOldPhone(@Validated @RequestBody AppOldPhoneCodeBo bo) {
|
||||
appUserService.verifyOldPhone(bo);
|
||||
return R.ok(AppMsgConstants.OLD_PHONE_VERIFY_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次绑定或更换手机号(新手机号 + 验证码)。换绑须先完成原号核验。
|
||||
*/
|
||||
@PutMapping("/phone")
|
||||
public R<Void> rebindPhone(@Validated @RequestBody AppPhoneBo bo) {
|
||||
@@ -61,6 +85,34 @@ public class AppUserController extends BaseController {
|
||||
return R.ok(AppMsgConstants.PHONE_UPDATE_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向当前账号原邮箱发送换绑确认验证码。
|
||||
*/
|
||||
@PostMapping("/email/old-code")
|
||||
public R<Void> sendOldEmailCode() {
|
||||
String code = appUserService.sendOldEmailCode();
|
||||
// 模拟邮件模式把验证码放入 msg,真实邮件模式只返回通用成功文案。
|
||||
return R.ok(code == null ? AppMsgConstants.EMAIL_CODE_SENT : code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验原邮箱验证码,通过后才允许更换已有邮箱。
|
||||
*/
|
||||
@PostMapping("/email/verify-old")
|
||||
public R<Void> verifyOldEmail(@Validated @RequestBody AppOldEmailCodeBo bo) {
|
||||
appUserService.verifyOldEmail(bo);
|
||||
return R.ok(AppMsgConstants.OLD_EMAIL_VERIFY_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次绑定或更换邮箱(新邮箱 + 验证码)。
|
||||
*/
|
||||
@PutMapping("/email")
|
||||
public R<Void> rebindEmail(@Validated @RequestBody AppEmailBo bo) {
|
||||
appUserService.rebindEmail(bo);
|
||||
return R.ok(AppMsgConstants.EMAIL_UPDATE_SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销账号(先解绑全部设备,再停用账号)。
|
||||
*/
|
||||
@@ -71,10 +123,11 @@ public class AppUserController extends BaseController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询协议内容(URL + 版本 + 更新时间)。
|
||||
* 查询协议内容(URL + 版本 + 更新时间),供登录、注册等未登录页面使用。
|
||||
*
|
||||
* @param type 协议类型:user / privacy
|
||||
*/
|
||||
@SaIgnore
|
||||
@GetMapping("/agreements")
|
||||
public R<AppAgreementVo> agreements(@RequestParam("type") String type) {
|
||||
return R.ok(appUserService.agreements(type));
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 首次绑定或换绑邮箱入参。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-23
|
||||
*/
|
||||
@Data
|
||||
public class AppEmailBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 新邮箱地址。
|
||||
*/
|
||||
@NotBlank(message = "新邮箱不能为空")
|
||||
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
|
||||
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
|
||||
private String newEmail;
|
||||
|
||||
/**
|
||||
* 邮箱验证码(发送至新邮箱)。
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
|
||||
private String code;
|
||||
|
||||
}
|
||||
@@ -14,9 +14,9 @@ import java.io.Serializable;
|
||||
/**
|
||||
* APP 邮箱注册入参。
|
||||
* <p>
|
||||
* <b>不含手机号</b>:按本迭代账号规则「注册后仍须绑手机号」,邮箱注册先建号
|
||||
* ({@code phone_number} 为 {@code NULL}),再由 APP 引导调用 {@code PUT /app/user/phone}
|
||||
* 完成绑定;未绑手机号的账号<b>无法绑定设备</b>(服务端硬拦截)。
|
||||
* <b>不含手机号</b>:邮箱注册先建号({@code phone_number} 为 {@code NULL}),注册后可直接登录;
|
||||
* 用户绑定设备前再按需调用 {@code PUT /app/user/phone} 完成手机号绑定,未绑手机号的账号
|
||||
* <b>无法绑定设备</b>(服务端硬拦截)。
|
||||
* <p>
|
||||
* 协议勾选态由 APP 前端把关,服务端不落库(按《开发计划》§8.1)。
|
||||
*
|
||||
@@ -52,7 +52,7 @@ public class AppEmailRegisterBo implements Serializable {
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 昵称(选填,为空时默认取邮箱)。
|
||||
* 昵称(选填,为空时默认生成 user_ 加当前时间戳)。
|
||||
*/
|
||||
@Xss(message = "昵称不能包含脚本字符")
|
||||
@Size(max = 30, message = "昵称长度不能超过{max}个字符")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 换绑第 1 步:校验原邮箱验证码入参。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-23
|
||||
*/
|
||||
@Data
|
||||
public class AppOldEmailCodeBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 邮箱验证码(6 位数字,发送至账号原邮箱)。
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
|
||||
private String oldCode;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 换绑第 1 步:校验原手机号验证码入参。
|
||||
* <p>
|
||||
* 验证码由 {@code POST /app/user/phone/old-code} 发到账号当前手机号(scene = rebindOld)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-22
|
||||
*/
|
||||
@Data
|
||||
public class AppOldPhoneCodeBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 短信验证码(6 位数字,发送至账号原手机号)。
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
|
||||
private String oldCode;
|
||||
|
||||
}
|
||||
@@ -47,7 +47,7 @@ public class AppRegisterBo implements Serializable {
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 昵称(选填,为空时默认取手机号)。
|
||||
* 昵称(选填,为空时默认生成 user_ 加当前时间戳)。
|
||||
*/
|
||||
@Xss(message = "昵称不能包含脚本字符")
|
||||
@Size(max = 30, message = "昵称长度不能超过{max}个字符")
|
||||
|
||||
@@ -28,7 +28,7 @@ public class AppSmsCodeBo implements Serializable {
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 场景:login / register / resetPwd / rebind。
|
||||
* 场景:login / register / resetPwd / bind / rebind。{@code rebindOld} 仅登录后原号发码接口可用。
|
||||
*/
|
||||
@NotBlank(message = "{app.sms.scene.not.blank}")
|
||||
private String scene;
|
||||
|
||||
@@ -36,7 +36,7 @@ public class AppUserAdminVo implements Serializable {
|
||||
private String userName;
|
||||
|
||||
/**
|
||||
* 昵称(手机号注册默认取手机号;邮箱注册默认取邮箱)
|
||||
* 昵称(注册时未填写则默认生成 user_ 加当前时间戳)
|
||||
*/
|
||||
private String nickName;
|
||||
|
||||
|
||||
@@ -45,8 +45,7 @@ public class AppUserProfileVo implements Serializable {
|
||||
/**
|
||||
* 是否已绑定手机号。
|
||||
* <p>
|
||||
* 供 APP 判断是否需强制引导绑手机号(账号规则:邮箱注册后仍须绑手机号,
|
||||
* 未绑手机号的账号无法绑定设备)。
|
||||
* 供 APP 在绑定设备前判断是否需要引导绑手机号;未绑手机号不影响登录和其他个人中心操作。
|
||||
*/
|
||||
private Boolean phoneBound;
|
||||
|
||||
|
||||
@@ -47,8 +47,9 @@ public class AppEmailService {
|
||||
*
|
||||
* @param email 邮箱地址(大小写不敏感)
|
||||
* @param scene 场景(register / login / resetPwd)
|
||||
* @return mock 模式下返回验证码供接口联调,真实邮件模式返回 {@code null}
|
||||
*/
|
||||
public void sendCode(String email, String scene) {
|
||||
public String sendCode(String email, String scene) {
|
||||
if (!EmailSceneEnum.isValid(scene)) {
|
||||
throw new ServiceException(AppMsgConstants.EMAIL_SCENE_INVALID);
|
||||
}
|
||||
@@ -69,6 +70,8 @@ public class AppEmailService {
|
||||
// 新码对应新计数:清零历史失败计数(错误的限速窗口也随新码重新起算)
|
||||
RedisUtils.deleteObject(AppConstants.EMAIL_FAIL_KEY_PREFIX + scene + ":" + to);
|
||||
send(to, scene, code);
|
||||
// 仅模拟发送时向上返回验证码,避免真实邮件模式通过接口泄露验证码。
|
||||
return cfg.isMock() ? code : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,12 +7,11 @@ import org.dromara.common.core.utils.StringUtils;
|
||||
/**
|
||||
* APP 邮箱验证码场景枚举。
|
||||
* <p>
|
||||
* 场景编码<b>复用</b> {@code AppConstants.SCENE_*} 的取值(register / login / resetPwd),
|
||||
* 场景编码<b>复用</b> {@code AppConstants.SCENE_*} 的取值(register / login / resetPwd / bind / rebind / rebindOld),
|
||||
* 与短信通道保持一致的语义;但由于 Redis key 前缀不同({@code app:email:*} 与 {@code app:sms:*}),
|
||||
* 两个通道的验证码与限流互不影响。
|
||||
* <p>
|
||||
* <b>为什么没有 rebind / bind</b>:手机号的绑定与换绑必须由<b>新手机号收到的短信验证码</b>证明归属,
|
||||
* 邮箱验证码无法证明手机号归属,故邮箱通道不提供这两个场景。
|
||||
* 邮箱换绑同样采用「原邮箱核验 + 新邮箱验证码」两步流程;首次绑定邮箱使用 bind 场景。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
@@ -34,7 +33,22 @@ public enum EmailSceneEnum {
|
||||
/**
|
||||
* 忘记密码。
|
||||
*/
|
||||
RESET_PWD("resetPwd", "忘记密码");
|
||||
RESET_PWD("resetPwd", "忘记密码"),
|
||||
|
||||
/**
|
||||
* 首次绑定邮箱。
|
||||
*/
|
||||
BIND("bind", "绑定邮箱"),
|
||||
|
||||
/**
|
||||
* 更换已有邮箱。
|
||||
*/
|
||||
REBIND("rebind", "换绑邮箱"),
|
||||
|
||||
/**
|
||||
* 换绑前确认原邮箱。
|
||||
*/
|
||||
REBIND_OLD("rebindOld", "验证原邮箱");
|
||||
|
||||
/**
|
||||
* 场景编码。
|
||||
|
||||
@@ -40,7 +40,12 @@ public enum SmsSceneEnum {
|
||||
/**
|
||||
* 绑定手机(首次绑定:邮箱注册的账号补绑手机号时使用)。
|
||||
*/
|
||||
BIND("bind", "绑定手机");
|
||||
BIND("bind", "绑定手机"),
|
||||
|
||||
/**
|
||||
* 换绑前确认原手机号(仅登录后的原号发码接口使用)。
|
||||
*/
|
||||
REBIND_OLD("rebindOld", "确认原手机号");
|
||||
|
||||
/**
|
||||
* 场景编码。
|
||||
|
||||
@@ -68,14 +68,15 @@ public interface IAppAuthService {
|
||||
// ---------------- 邮箱通道(迭代:邮箱注册) ----------------
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码(6 位、场景化 60 秒限流、5 分钟有效)。
|
||||
* 发送邮箱验证码(6 位、场景化 60 秒限流、5 分钟有效);模拟模式返回验证码,真实发送模式返回 {@code null}。
|
||||
*
|
||||
* @param bo 验证码入参(邮箱 + 场景)
|
||||
* @param bo 邮箱验证码请求
|
||||
* @return 模拟验证码或 {@code null}
|
||||
*/
|
||||
void sendEmailCode(AppEmailCodeBo bo);
|
||||
String sendEmailCode(AppEmailCodeBo bo);
|
||||
|
||||
/**
|
||||
* 邮箱注册(注册后账号尚无手机号,须按账号规则继续绑定手机号)。
|
||||
* 邮箱注册(注册后可直接邮箱登录,绑定手机号仅在绑定设备前要求)。
|
||||
*
|
||||
* @param bo 注册入参
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.dromara.device.service;
|
||||
|
||||
import org.dromara.device.domain.bo.AppOldPhoneCodeBo;
|
||||
import org.dromara.device.domain.bo.AppOldEmailCodeBo;
|
||||
import org.dromara.device.domain.bo.AppPasswordBo;
|
||||
import org.dromara.device.domain.bo.AppEmailBo;
|
||||
import org.dromara.device.domain.bo.AppPhoneBo;
|
||||
import org.dromara.device.domain.vo.AppAgreementVo;
|
||||
import org.dromara.device.domain.vo.AppUserProfileVo;
|
||||
@@ -28,12 +31,49 @@ public interface IAppUserService {
|
||||
void updatePassword(AppPasswordBo bo);
|
||||
|
||||
/**
|
||||
* 换绑手机号(新手机号 + 验证码)。
|
||||
* 向当前账号原手机号发送换绑确认验证码(scene = rebindOld)。
|
||||
*
|
||||
* @return mock 模式下返回验证码,真实短信模式返回 {@code null}
|
||||
*/
|
||||
String sendOldPhoneCode();
|
||||
|
||||
/**
|
||||
* 校验原手机号验证码,通过后写入换绑核验状态。
|
||||
*
|
||||
* @param bo 原号验证码
|
||||
*/
|
||||
void verifyOldPhone(AppOldPhoneCodeBo bo);
|
||||
|
||||
/**
|
||||
* 绑定或换绑手机号(新手机号 + 验证码)。
|
||||
* <p>
|
||||
* 已有手机号的换绑必须先通过 {@link #verifyOldPhone} 取得核验状态。
|
||||
*
|
||||
* @param bo 换绑入参
|
||||
*/
|
||||
void rebindPhone(AppPhoneBo bo);
|
||||
|
||||
/**
|
||||
* 向当前账号原邮箱发送换绑确认验证码。
|
||||
*
|
||||
* @return mock 模式下返回验证码,真实邮件模式返回 {@code null}
|
||||
*/
|
||||
String sendOldEmailCode();
|
||||
|
||||
/**
|
||||
* 校验原邮箱验证码,通过后写入换绑核验状态。
|
||||
*
|
||||
* @param bo 原邮箱验证码
|
||||
*/
|
||||
void verifyOldEmail(AppOldEmailCodeBo bo);
|
||||
|
||||
/**
|
||||
* 首次绑定或更换邮箱;已有邮箱的换绑必须先通过原邮箱核验。
|
||||
*
|
||||
* @param bo 邮箱换绑入参
|
||||
*/
|
||||
void rebindEmail(AppEmailBo bo);
|
||||
|
||||
/**
|
||||
* 注销账号:先解绑其下全部设备,再停用账号,最后按 loginId 全端下线。
|
||||
* <p>
|
||||
|
||||
@@ -67,6 +67,9 @@ public class AppAuthServiceImpl implements IAppAuthService {
|
||||
*/
|
||||
@Override
|
||||
public String sendSmsCode(AppSmsCodeBo bo) {
|
||||
if (AppConstants.SCENE_REBIND_OLD.equals(bo.getScene())) {
|
||||
throw new ServiceException(AppMsgConstants.SMS_SCENE_INVALID);
|
||||
}
|
||||
return appSmsService.sendCode(AppPhoneNumbers.canonical(bo.getPhoneNumber()), bo.getScene());
|
||||
}
|
||||
|
||||
@@ -180,8 +183,8 @@ public class AppAuthServiceImpl implements IAppAuthService {
|
||||
* @param bo 验证码入参(邮箱 + 场景)
|
||||
*/
|
||||
@Override
|
||||
public void sendEmailCode(AppEmailCodeBo bo) {
|
||||
appEmailService.sendCode(bo.getEmail(), bo.getScene());
|
||||
public String sendEmailCode(AppEmailCodeBo bo) {
|
||||
return appEmailService.sendCode(bo.getEmail(), bo.getScene());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -117,10 +117,8 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
|
||||
/**
|
||||
* 绑定设备(条件更新防并发双绑;成功才写 bind 流水)。
|
||||
* <p>
|
||||
* <b>前置硬约束(本迭代新增)</b>:账号须已绑定手机号。邮箱注册的账号在建号时
|
||||
* {@code phone_number} 为空,按账号规则「注册后仍须绑手机号」,
|
||||
* 此处<b>服务端拦截</b>而非依赖前端引导——否则「必须绑手机号」只是产品约定,
|
||||
* 绕过 APP 直接调用接口即可跳过。
|
||||
* <b>前置硬约束</b>:账号须已绑定手机号。邮箱注册账号可以先登录和使用其他 APP 功能,
|
||||
* 但在绑定设备时由服务端拦截未绑定手机号的请求,避免绕过前端直接调用接口。
|
||||
*
|
||||
* @param bo 绑定入参(imei + name)
|
||||
* @param userId 当前用户主键
|
||||
|
||||
@@ -7,11 +7,15 @@ import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.DesensitizedUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.device.config.AppProperties;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
import org.dromara.device.constant.AppMsgConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
import org.dromara.device.domain.bo.AppOldPhoneCodeBo;
|
||||
import org.dromara.device.domain.bo.AppOldEmailCodeBo;
|
||||
import org.dromara.device.domain.bo.AppEmailBo;
|
||||
import org.dromara.device.domain.bo.AppPasswordBo;
|
||||
import org.dromara.device.domain.bo.AppPhoneBo;
|
||||
import org.dromara.device.domain.vo.AppAgreementVo;
|
||||
@@ -20,11 +24,15 @@ import org.dromara.device.service.IAppUserService;
|
||||
import org.dromara.device.service.IDeviceBindService;
|
||||
import org.dromara.device.sms.AppPhoneNumbers;
|
||||
import org.dromara.device.sms.AppSmsService;
|
||||
import org.dromara.device.email.AppEmailService;
|
||||
import org.dromara.system.api.AppUserApiService;
|
||||
import org.dromara.system.api.UserService;
|
||||
import org.dromara.system.api.domain.UserDTO;
|
||||
import org.redisson.api.RLock;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* APP 个人中心服务实现。
|
||||
* <p>
|
||||
@@ -52,6 +60,7 @@ public class AppUserServiceImpl implements IAppUserService {
|
||||
private final UserService userService;
|
||||
private final IDeviceBindService deviceBindService;
|
||||
private final AppSmsService appSmsService;
|
||||
private final AppEmailService appEmailService;
|
||||
private final AppProperties appProperties;
|
||||
|
||||
/**
|
||||
@@ -71,8 +80,7 @@ public class AppUserServiceImpl implements IAppUserService {
|
||||
vo.setNickName(user.getNickName());
|
||||
vo.setPhoneNumber(DesensitizedUtils.mobilePhone(user.getPhoneNumber()));
|
||||
vo.setEmail(DesensitizedUtils.email(user.getEmail()));
|
||||
// 账号规则:邮箱注册的账号注册后仍须绑定手机号;未绑定时 APP 需据此强制引导,
|
||||
// 且服务端已禁止未绑手机号的账号绑定设备(见 IAppDeviceService#bind)。
|
||||
// 邮箱注册账号可以先登录和使用个人中心;未绑定手机号时仅禁止绑定设备,绑定设备前再引导补绑。
|
||||
vo.setPhoneBound(StringUtils.isNotBlank(user.getPhoneNumber()));
|
||||
vo.setUserType(user.getUserType());
|
||||
vo.setCreateTime(user.getCreateTime());
|
||||
@@ -96,39 +104,163 @@ public class AppUserServiceImpl implements IAppUserService {
|
||||
appUserApiService.resetPassword(userId, bo.getNewPassword());
|
||||
}
|
||||
|
||||
/**
|
||||
* 向当前账号原手机号发送换绑确认验证码。
|
||||
*
|
||||
* @return mock 模式下返回验证码,真实短信模式返回 {@code null}
|
||||
*/
|
||||
@Override
|
||||
public String sendOldPhoneCode() {
|
||||
UserDTO current = requireCurrentUser();
|
||||
return appSmsService.sendCode(requireBoundPhone(current), AppConstants.SCENE_REBIND_OLD);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验原手机号验证码,通过后写入 Redis 核验状态(TTL 与短信有效期一致)。
|
||||
*
|
||||
* @param bo 原号验证码
|
||||
*/
|
||||
@Override
|
||||
public void verifyOldPhone(AppOldPhoneCodeBo bo) {
|
||||
Long userId = currentUserId();
|
||||
RLock lock = RedisUtils.getClient().getLock(AppConstants.PHONE_REBIND_LOCK_KEY_PREFIX + userId);
|
||||
lock.lock();
|
||||
try {
|
||||
// 与换绑共用用户锁,避免核验状态在换绑成功删除后又被并发写回。
|
||||
UserDTO current = requireCurrentUser();
|
||||
appSmsService.verifyCode(requireBoundPhone(current), AppConstants.SCENE_REBIND_OLD, bo.getOldCode());
|
||||
RedisUtils.setCacheObject(AppConstants.REBIND_OLD_OK_KEY_PREFIX + userId, Boolean.TRUE,
|
||||
Duration.ofMinutes(appProperties.getSms().getExpiryMinutes()));
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定 / 换绑手机号:先拦截「新号==当前号」,再校验新号未被占用,再校验新号收到的验证码,最后落库。
|
||||
* <p>
|
||||
* <b>场景自适应(本迭代新增)</b>:按当前账号是否已有手机号选择验证码场景——
|
||||
* 已有手机号的换绑必须先通过 {@link #verifyOldPhone} 取得核验状态;首次绑定不查该状态。
|
||||
* <p>
|
||||
* <b>场景自适应</b>:按当前账号是否已有手机号选择验证码场景——
|
||||
* <ul>
|
||||
* <li>当前无手机号(邮箱注册的账号)→ 场景 {@link AppConstants#SCENE_BIND}「绑定手机」;</li>
|
||||
* <li>当前已有手机号 → 场景 {@link AppConstants#SCENE_REBIND}「换绑手机」(原有行为不变)。</li>
|
||||
* <li>当前已有手机号 → 场景 {@link AppConstants#SCENE_REBIND}「换绑手机」;</li>
|
||||
* </ul>
|
||||
* 两个场景的验证码缓存 key 互相隔离,故 APP 必须按账号当前状态请求对应场景的验证码,
|
||||
* 用错场景会得到「验证码已过期」。这样划分是为了让「首次绑定」与「更换手机号」在审计与文案上可区分。
|
||||
*
|
||||
* @param bo 换绑入参
|
||||
*/
|
||||
@Override
|
||||
public void rebindPhone(AppPhoneBo bo) {
|
||||
Long userId = currentUserId();
|
||||
UserDTO current = userService.selectById(userId);
|
||||
if (current == null) {
|
||||
throw new ServiceException(AppMsgConstants.NOT_LOGIN);
|
||||
RLock lock = RedisUtils.getClient().getLock(AppConstants.PHONE_REBIND_LOCK_KEY_PREFIX + userId);
|
||||
lock.lock();
|
||||
try {
|
||||
// 锁内重新读取账号,保证核验状态检查与手机号更新针对同一份最新账号数据。
|
||||
UserDTO current = requireCurrentUser();
|
||||
boolean firstBind = StringUtils.isBlank(current.getPhoneNumber());
|
||||
String rebindOldVerifiedKey = AppConstants.REBIND_OLD_OK_KEY_PREFIX + userId;
|
||||
if (!firstBind && RedisUtils.getCacheObject(rebindOldVerifiedKey) == null) {
|
||||
throw new ServiceException(AppMsgConstants.OLD_PHONE_NOT_VERIFIED);
|
||||
}
|
||||
String newPhone = AppPhoneNumbers.canonical(bo.getNewPhoneNumber());
|
||||
// P3-6:新号与当前号相同时给出精确文案(原先 existsByPhone 命中自身 → 误报「手机号已注册」)
|
||||
// 首次绑定时当前号为空,不存在「与当前号相同」的情形,故仅在换绑分支判断。
|
||||
if (!firstBind && StringUtils.equals(current.getPhoneNumber(), newPhone)) {
|
||||
throw new ServiceException(AppMsgConstants.PHONE_SAME_AS_CURRENT);
|
||||
}
|
||||
if (appUserApiService.existsByPhone(newPhone)) {
|
||||
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
|
||||
}
|
||||
appSmsService.verifyCode(newPhone,
|
||||
firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode());
|
||||
// 换绑只更新手机号,昵称保持用户原值,不重新生成默认昵称。
|
||||
appUserApiService.updatePhone(userId, newPhone);
|
||||
if (!firstBind) {
|
||||
// 与上面的状态检查处于同一用户锁内,确保一次核验只允许一次换绑成功。
|
||||
RedisUtils.deleteObject(rebindOldVerifiedKey);
|
||||
}
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
boolean firstBind = StringUtils.isBlank(current.getPhoneNumber());
|
||||
String newPhone = AppPhoneNumbers.canonical(bo.getNewPhoneNumber());
|
||||
// P3-6:新号与当前号相同时给出精确文案(原先 existsByPhone 命中自身 → 误报「手机号已注册」)
|
||||
// 首次绑定时当前号为空,不存在「与当前号相同」的情形,故仅在换绑分支判断。
|
||||
if (!firstBind && StringUtils.equals(current.getPhoneNumber(), newPhone)) {
|
||||
throw new ServiceException(AppMsgConstants.PHONE_SAME_AS_CURRENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向当前账号原邮箱发送换绑确认验证码。
|
||||
*
|
||||
* @return mock 模式下返回验证码,真实邮件模式返回 {@code null}
|
||||
*/
|
||||
@Override
|
||||
public String sendOldEmailCode() {
|
||||
UserDTO current = requireCurrentUser();
|
||||
return appEmailService.sendCode(requireBoundEmail(current), AppConstants.SCENE_REBIND_OLD);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验原邮箱验证码,通过后写入 Redis 核验状态;状态与邮箱换绑共用同一把用户锁。
|
||||
*
|
||||
* @param bo 原邮箱验证码
|
||||
*/
|
||||
@Override
|
||||
public void verifyOldEmail(AppOldEmailCodeBo bo) {
|
||||
Long userId = currentUserId();
|
||||
RLock lock = RedisUtils.getClient().getLock(AppConstants.EMAIL_REBIND_LOCK_KEY_PREFIX + userId);
|
||||
lock.lock();
|
||||
try {
|
||||
UserDTO current = requireCurrentUser();
|
||||
appEmailService.verifyCode(requireBoundEmail(current), AppConstants.SCENE_REBIND_OLD, bo.getOldCode());
|
||||
RedisUtils.setCacheObject(AppConstants.EMAIL_REBIND_OLD_OK_KEY_PREFIX + userId, Boolean.TRUE,
|
||||
Duration.ofMinutes(appProperties.getEmail().getExpiryMinutes()));
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
if (appUserApiService.existsByPhone(newPhone)) {
|
||||
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定 / 换绑邮箱:先校验原邮箱核验状态,再校验新邮箱唯一性与验证码,最后更新账号。
|
||||
* <p>
|
||||
* 当前账号没有邮箱时视为首次绑定,不要求原邮箱核验,验证码场景使用 bind;已有邮箱时使用 rebind。
|
||||
*
|
||||
* @param bo 新邮箱与验证码
|
||||
*/
|
||||
@Override
|
||||
public void rebindEmail(AppEmailBo bo) {
|
||||
Long userId = currentUserId();
|
||||
RLock lock = RedisUtils.getClient().getLock(AppConstants.EMAIL_REBIND_LOCK_KEY_PREFIX + userId);
|
||||
lock.lock();
|
||||
try {
|
||||
UserDTO current = requireCurrentUser();
|
||||
boolean firstBind = StringUtils.isBlank(current.getEmail());
|
||||
String oldVerifiedKey = AppConstants.EMAIL_REBIND_OLD_OK_KEY_PREFIX + userId;
|
||||
if (!firstBind && RedisUtils.getCacheObject(oldVerifiedKey) == null) {
|
||||
throw new ServiceException(AppMsgConstants.OLD_EMAIL_NOT_VERIFIED);
|
||||
}
|
||||
String newEmail = appEmailService.normalize(bo.getNewEmail());
|
||||
if (!firstBind && StringUtils.equals(current.getEmail(), newEmail)) {
|
||||
throw new ServiceException(AppMsgConstants.EMAIL_SAME_AS_CURRENT);
|
||||
}
|
||||
if (appUserApiService.existsByEmail(newEmail)) {
|
||||
throw new ServiceException(DeviceMsgConstants.EMAIL_REGISTERED);
|
||||
}
|
||||
appEmailService.verifyCode(newEmail,
|
||||
firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode());
|
||||
// 换绑只更新邮箱,昵称保持用户原值,不重新生成默认昵称。
|
||||
if (!appUserApiService.updateEmail(userId, newEmail)) {
|
||||
throw new ServiceException(AppMsgConstants.NOT_LOGIN);
|
||||
}
|
||||
if (!firstBind) {
|
||||
RedisUtils.deleteObject(oldVerifiedKey);
|
||||
}
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
appSmsService.verifyCode(newPhone,
|
||||
firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode());
|
||||
appUserApiService.updatePhone(userId, newPhone);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,4 +330,26 @@ public class AppUserServiceImpl implements IAppUserService {
|
||||
return userId;
|
||||
}
|
||||
|
||||
private UserDTO requireCurrentUser() {
|
||||
UserDTO current = userService.selectById(currentUserId());
|
||||
if (current == null) {
|
||||
throw new ServiceException(AppMsgConstants.NOT_LOGIN);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private String requireBoundPhone(UserDTO current) {
|
||||
if (StringUtils.isBlank(current.getPhoneNumber())) {
|
||||
throw new ServiceException(AppMsgConstants.PHONE_NOT_BOUND);
|
||||
}
|
||||
return current.getPhoneNumber();
|
||||
}
|
||||
|
||||
private String requireBoundEmail(UserDTO current) {
|
||||
if (StringUtils.isBlank(current.getEmail())) {
|
||||
throw new ServiceException(AppMsgConstants.EMAIL_NOT_BOUND);
|
||||
}
|
||||
return appEmailService.normalize(current.getEmail());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ public class AppSmsService {
|
||||
* 生成并按场景发送短信验证码(含 60 秒重发限流)。
|
||||
*
|
||||
* @param phoneNumber 手机号
|
||||
* @param scene 场景(login / register / resetPwd / rebind)
|
||||
* @param scene 场景(login / register / resetPwd / bind / rebind / rebindOld)
|
||||
* @return mock 模式下返回验证码供接口联调,真实短信模式返回 {@code null}
|
||||
*/
|
||||
public String sendCode(String phoneNumber, String scene) {
|
||||
|
||||
Reference in New Issue
Block a user