设备注册 短信跟邮箱验证

This commit is contained in:
yuhaiming
2026-09-23 16:28:24 +08:00
parent bc1074d194
commit 827b48e954
25 changed files with 645 additions and 67 deletions

View File

@@ -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:";
}

View File

@@ -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 按原型核对。

View File

@@ -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) {

View File

@@ -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));

View File

@@ -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;
}

View File

@@ -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}个字符")

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -47,7 +47,7 @@ public class AppRegisterBo implements Serializable {
private String password;
/**
* 昵称(选填,为空时默认取手机号)。
* 昵称(选填,为空时默认生成 user_ 加当前时间戳)。
*/
@Xss(message = "昵称不能包含脚本字符")
@Size(max = 30, message = "昵称长度不能超过{max}个字符")

View File

@@ -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;

View File

@@ -36,7 +36,7 @@ public class AppUserAdminVo implements Serializable {
private String userName;
/**
* 昵称(手机号注册默认取手机号;邮箱注册默认取邮箱)
* 昵称(注册时未填写则默认生成 user_ 加当前时间戳)
*/
private String nickName;

View File

@@ -45,8 +45,7 @@ public class AppUserProfileVo implements Serializable {
/**
* 是否已绑定手机号。
* <p>
* 供 APP 判断是否需强制引导绑手机号(账号规则:邮箱注册后仍须绑手机号,
* 未绑手机号的账号无法绑定设备)。
* 供 APP 在绑定设备前判断是否需要引导绑手机号;未绑手机号不影响登录和其他个人中心操作。
*/
private Boolean phoneBound;

View File

@@ -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;
}
/**

View File

@@ -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", "验证原邮箱");
/**
* 场景编码。

View File

@@ -40,7 +40,12 @@ public enum SmsSceneEnum {
/**
* 绑定手机(首次绑定:邮箱注册的账号补绑手机号时使用)。
*/
BIND("bind", "绑定手机");
BIND("bind", "绑定手机"),
/**
* 换绑前确认原手机号(仅登录后的原号发码接口使用)。
*/
REBIND_OLD("rebindOld", "确认原手机号");
/**
* 场景编码。

View File

@@ -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 注册入参
*/

View File

@@ -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>

View File

@@ -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());
}
/**

View File

@@ -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 当前用户主键

View File

@@ -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());
}
}

View File

@@ -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) {

View File

@@ -46,6 +46,13 @@ public class AppUserApiServiceImpl implements AppUserApiService {
*/
private static final String USER_NAME_PREFIX = "app_";
/**
* 生成 APP 用户默认昵称,避免直接暴露手机号或邮箱。
*/
private static String defaultNickName() {
return "user_" + System.currentTimeMillis();
}
private final SysUserMapper userMapper;
/**
@@ -138,11 +145,12 @@ public class AppUserApiServiceImpl implements AppUserApiService {
* <li>与后台 {@code sys_user} 的账号名冲突需其恰好命名为 {@code app_<雪花ID>},非自然产生。</li>
* </ul>
* 说明:APP 登录仅依赖 {@code phone_number}(见 {@code selectByPhone}),不依赖 {@code user_name};
* 展示用昵称取 {@code nickName}(默认手机号),故 user_name 采用内部派生值不影响用户展示。
* 展示用昵称取 {@code nickName}(未传时默认生成 {@code user_ + 当前时间戳}),故 user_name
* 采用内部派生值不影响用户展示。
*
* @param phoneNumber 手机号
* @param rawPassword 明文密码
* @param nickName 昵称(为空时默认取手机号)
* @param nickName 昵称(为空时默认生成 {@code user_ + 当前时间戳})
* @return 新建用户主键
*/
@Override
@@ -151,7 +159,7 @@ public class AppUserApiServiceImpl implements AppUserApiService {
SysUser user = new SysUser();
user.setUserId(userId);
user.setUserName(USER_NAME_PREFIX + userId);
user.setNickName(StringUtils.isBlank(nickName) ? phoneNumber : nickName);
user.setNickName(StringUtils.isBlank(nickName) ? defaultNickName() : nickName);
user.setPhoneNumber(phoneNumber);
user.setUserType(UserType.APP_USER.getUserType());
user.setPassword(BCrypt.hashpw(rawPassword));
@@ -210,6 +218,22 @@ public class AppUserApiServiceImpl implements AppUserApiService {
.exists();
}
/**
* 更新 APP 用户邮箱;调用方负责完成验证码和唯一性校验。
* <p>仅写入邮箱字段,不改动昵称及其他用户资料。</p>
*/
@Override
public boolean updateEmail(Long userId, String newEmail) {
if (userId == null || StringUtils.isBlank(newEmail)) {
return false;
}
return userMapper.lambda()
.set(SysUser::getEmail, newEmail)
.eq(SysUser::getUserId, userId)
.eq(SysUser::getUserType, UserType.APP_USER.getUserType())
.updateCount() > 0;
}
/**
* 以邮箱注册一个 APP 用户。
* <p>
@@ -224,7 +248,7 @@ public class AppUserApiServiceImpl implements AppUserApiService {
*
* @param email 邮箱地址(已归一化)
* @param rawPassword 明文密码
* @param nickName 昵称(为空时默认取邮箱)
* @param nickName 昵称(为空时默认生成 {@code user_ + 当前时间戳})
* @return 新建用户主键
*/
@Override
@@ -233,7 +257,7 @@ public class AppUserApiServiceImpl implements AppUserApiService {
SysUser user = new SysUser();
user.setUserId(userId);
user.setUserName(USER_NAME_PREFIX + userId);
user.setNickName(StringUtils.isBlank(nickName) ? email : nickName);
user.setNickName(StringUtils.isBlank(nickName) ? defaultNickName() : nickName);
user.setEmail(email);
user.setUserType(UserType.APP_USER.getUserType());
user.setPassword(BCrypt.hashpw(rawPassword));
@@ -271,7 +295,7 @@ public class AppUserApiServiceImpl implements AppUserApiService {
}
/**
* 换绑手机号。
* 换绑手机号;仅写入手机号字段,不改动昵称及其他用户资料。
*
* @param userId 用户主键
* @param newPhoneNumber 新手机号