Browse Source

实现同设备换号解绑第三方账号及用户注销翻悔期

qmj 2 days ago
parent
commit
484b794eb1
43 changed files with 1267 additions and 213 deletions
  1. 106 91
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  2. 28 7
      ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java
  3. 64 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/UserCancellationService.java
  4. 71 34
      ruoyi-admin/src/main/java/com/ruoyi/app/user/service/DeviceTrustService.java
  5. 4 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  6. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  7. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  8. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  9. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  10. 4 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  11. 162 27
      ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java
  12. 41 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustTestSupport.java
  13. 73 3
      ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java
  14. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackControllerTest.java
  15. 30 5
      ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java
  16. 4 8
      ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSessionAuthAnnotationTest.java
  17. 70 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/UserCancellationServiceTest.java
  18. 91 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/service/DeviceBindingSwitchTest.java
  19. 4 11
      ruoyi-admin/src/test/java/com/ruoyi/app/user/service/DeviceTrustServiceTest.java
  20. 6 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUser.java
  21. 19 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserOauthDevice.java
  22. 12 1
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserDeviceMapper.java
  23. 3 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserMapper.java
  24. 19 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserOauthDeviceMapper.java
  25. 15 1
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserOauthMapper.java
  26. 1 1
      ruoyi-system/src/main/java/com/ruoyi/system/utils/Auth.java
  27. 2 6
      ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java
  28. 17 2
      ruoyi-system/src/main/resources/mapper/infouser/InfoUserMapper.xml
  29. 34 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/UserCancellationMapperTest.java
  30. 7 15
      ruoyi-system/src/test/java/com/ruoyi/system/utils/AuthAspectTest.java
  31. 25 0
      specs/029-device-trust-phone-bind/contracts/api-contract.md
  32. 38 0
      specs/029-device-trust-phone-bind/data-model.md
  33. 16 0
      specs/029-device-trust-phone-bind/frontend-guide.md
  34. 33 0
      specs/029-device-trust-phone-bind/plan.md
  35. 43 0
      specs/029-device-trust-phone-bind/quickstart.md
  36. 9 0
      specs/029-device-trust-phone-bind/research.md
  37. 37 0
      specs/029-device-trust-phone-bind/spec.md
  38. 16 0
      specs/029-device-trust-phone-bind/tasks.md
  39. 42 0
      specs/040-user-cancellation-grace/frontend-app-integration.md
  40. 12 0
      specs/040-user-cancellation-grace/plan.md
  41. 26 0
      specs/040-user-cancellation-grace/spec.md
  42. 39 0
      specs/040-user-cancellation-grace/tasks.md
  43. 27 0
      updatesql/sql.md

+ 106 - 91
ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java

@@ -90,6 +90,8 @@ public class InfoUserController extends BaseController {
     @Autowired
     private IInfoUserService infoUserService;
     @Autowired
+    private UserCancellationService userCancellationService;
+    @Autowired
     private ImAccountService imAccountService;
     @Autowired
     private InfoUserMapper infoUserMapper;
@@ -127,6 +129,7 @@ public class InfoUserController extends BaseController {
     private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
     /** 设备确认临时凭证 Redis 前缀:值为 deviceId,与 OAUTH_TEMP_PREFIX 同 TTL 成对写入(029) */
     private static final String OAUTH_TEMP_DEVICE_PREFIX = "oauth:bind:dev:";
+    private static final String OAUTH_TEMP_USER_PREFIX = "oauth:bind:user:";
 
 
     @Anonymous
@@ -276,6 +279,8 @@ public class InfoUserController extends BaseController {
      */
     @Anonymous
     @GetMapping("/deleuser")
+    @Auth
+    @Transactional(rollbackFor = Exception.class)
     public AjaxResult deleuser(@RequestHeader String token) {
         JwtUtil jwtUtil = new JwtUtil();
         String id = jwtUtil.getusid(token);
@@ -289,6 +294,10 @@ public class InfoUserController extends BaseController {
         if (exist) {
             throw new ServiceException("抱歉,您還有未完成的訂單,無法刪除帳號");
         }
+        InfoUser account = infoUserService.getById(userId);
+        if (account != null && "0".equals(account.getUserType())) {
+            return success(userCancellationService.request(userId));
+        }
         return toAjax(infoUserService.deleteInfoUserByUserId(userId));
     }
 
@@ -953,6 +962,7 @@ public class InfoUserController extends BaseController {
     @Anonymous
     @Auth
     @GetMapping("/gettoken")
+    @Transactional(rollbackFor = Exception.class)
     public AjaxResult gettoken(@RequestHeader String token) {
         JwtUtil jwtUtil = new JwtUtil();
         String id = jwtUtil.getusid(token);
@@ -960,6 +970,8 @@ public class InfoUserController extends BaseController {
         wrapper.eq("user_id", id);
         wrapper.eq("status", 0);
         wrapper.eq("del_flag", "0");
+        // 刷新会话也与注销共用账号锁,避免注销撤销后又签发新会话。
+        wrapper.last("FOR UPDATE");
         InfoUser user = infoUserService.getOne(wrapper);
         if (user == null) {
             return error(MessageUtils.message("no.user.stop"));
@@ -989,8 +1001,9 @@ public class InfoUserController extends BaseController {
     private InfoUser findPhoneLoginUser(String phone) {
         List<InfoUser> matches = infoUserService.list(new LambdaQueryWrapper<InfoUser>()
                 .eq(InfoUser::getPhone, phone)
-                .eq(InfoUser::getDelFlag, "0")
-                .eq(InfoUser::getUserType, "0"));
+                .and(w -> w.eq(InfoUser::getDelFlag, "0").or(pending -> pending
+                        .eq(InfoUser::getDelFlag, "1").gt(InfoUser::getCancellationDeadlineAt, new Date())))
+                .eq(InfoUser::getUserType, "0").last("FOR UPDATE"));
         if (matches.size() > 1) {
             log.warn("[Login] 手机号关联多个账号 phone={}", maskPhone(phone));
             throw new ServiceException(MessageUtils.message("no.user.phone.duplicate"));
@@ -1006,6 +1019,7 @@ public class InfoUserController extends BaseController {
      */
     @Anonymous
     @PostMapping("/lodeing")
+    @Transactional(rollbackFor = Exception.class)
     public AjaxResult lodeing(@RequestBody UserDTO userDTO) {
         JwtUtil tokn = new JwtUtil();
         String xcode = redisCache.getCacheObject(userDTO.getPhone().trim().replaceAll("\\+", ""));
@@ -1063,6 +1077,7 @@ public class InfoUserController extends BaseController {
         userDto.setProvider("phone");
         fillLoginUserInfo(userDto);
         String token = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, userDto);
+        if (token == null || token.isBlank()) throw new ServiceException(MessageUtils.message("no.system.error"));
         return success(MessageUtils.message("no.user.login.success"), inus, token);
     }
 
@@ -1073,6 +1088,8 @@ public class InfoUserController extends BaseController {
      * @return
      */
     public AjaxResult userLoginUpdateInfo(UserDTO userDTO, InfoUser infoUser) {
+        // 仅在短信验证成功后恢复;普通用户的行锁由 lodeing 外层事务持有至新会话签发。
+        infoUser = recoverCancelledUser(infoUser);
         DateUtil date = new DateUtil();
         QueryWrapper<InfoUser> query = new QueryWrapper<>();
         query.eq("user_id", infoUser.getUserId());
@@ -1109,6 +1126,7 @@ public class InfoUserController extends BaseController {
         userDto.setProvider("phone");
         fillLoginUserInfo(userDto);
         String token = JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, userDto);
+        if (token == null || token.isBlank()) throw new ServiceException(MessageUtils.message("no.system.error"));
         return success(MessageUtils.message("no.user.login.success"), user, token);
     }
 
@@ -1119,6 +1137,7 @@ public class InfoUserController extends BaseController {
      */
     @Anonymous
     @PostMapping("/oauthLogin")
+    @Transactional(rollbackFor = Exception.class)
     public AjaxResult oauthLogin(@RequestBody OAuthLoginDto dto) {
         if (dto == null || dto.getProvider() == null || dto.getProvider().isEmpty()) {
             return error(MessageUtils.message("no.oauth.provider.blank"));
@@ -1146,8 +1165,17 @@ public class InfoUserController extends BaseController {
             // 已绑定:校验用户正常后直接登录
             log.info("[OAuth] 已绑定 provider={}, userId={}", dto.getProvider(), bind.getUserId());
             QueryWrapper<InfoUser> q = new QueryWrapper<>();
-            q.eq("user_id", bind.getUserId()).eq("status", 0).eq("del_flag", "0");
+            q.eq("user_id", bind.getUserId()).eq("status", 0);
+            if (accountType == null || accountType == OAuthAccountType.USER) {
+                q.eq("user_type", "0").and(w -> w.eq("del_flag", "0").or(pending -> pending
+                        .eq("del_flag", "1").gt("cancellation_deadline_at", new Date())));
+            } else {
+                q.eq("del_flag", "0");
+            }
+            if (accountType == null || accountType == OAuthAccountType.USER) q.last("FOR UPDATE");
             InfoUser u = infoUserService.getOne(q);
+            if (u != null && (accountType == null || accountType == OAuthAccountType.USER))
+                u = recoverCancelledUser(u);
             if (u == null) {
                 log.warn("[OAuth] 已绑定但账号已停用 userId={}", bind.getUserId());
                 return error(MessageUtils.message("no.user.stop"));
@@ -1160,7 +1188,9 @@ public class InfoUserController extends BaseController {
             u.setDeviceToken(dto.getDeviceToken());
             u.setVoIPToken(dto.getVoIPToken());
             infoUserService.saveOrUpdate(u);
-            deviceTrustService.recordLogin(dto.getDeviceId(), u.getPhone(), u.getUserId());
+            if (accountType == null || accountType == OAuthAccountType.USER) {
+                deviceTrustService.recordOauthLogin(dto.getDeviceId(), u.getPhone(), u.getUserId(), bind);
+            }
             refreshOauthDisplayName(bind, displayName);
             return issueOauthToken(u, dto.getProvider(), providerUid);
         }
@@ -1175,10 +1205,13 @@ public class InfoUserController extends BaseController {
                 : dto.getProvider() + "@" + providerUid + "@" + displayName;
         redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey, tempValue, 5, TimeUnit.MINUTES);
         // 029:设备信任免短信确认仅用户端(会员)适用;业务账号绑定必须真实短信,不走免确认
-        String trustedPhone = accountType == null || accountType == OAuthAccountType.USER
-                ? deviceTrustService.getTrustedPhone(dto.getDeviceId()) : null;
+        InfoUserDevice trustedDevice = accountType == null || accountType == OAuthAccountType.USER
+                ? deviceTrustService.getTrust(dto.getDeviceId()) : null;
+        String trustedPhone = trustedDevice == null ? null : trustedDevice.getPhone();
         if (trustedPhone != null) {
             redisCache.setCacheObject(OAUTH_TEMP_DEVICE_PREFIX + tempKey, dto.getDeviceId(), 5, TimeUnit.MINUTES);
+            redisCache.setCacheObject(OAUTH_TEMP_USER_PREFIX + tempKey,
+                    String.valueOf(trustedDevice.getUserId()), 5, TimeUnit.MINUTES);
             log.info("[OAuth] 设备已信任,返回 deviceConfirm provider={}, deviceId={}, phone={}",
                     dto.getProvider(), dto.getDeviceId(), DeviceTrustService.maskPhone(trustedPhone));
             Map<String, Object> data = new HashMap<>();
@@ -1221,22 +1254,20 @@ public class InfoUserController extends BaseController {
         OAuthAccountType accountType = OAuthAccountType.forProvider(provider);
         log.info("[OAuth] 绑定流程 provider={}", provider);
 
-        // 容错:若期间已被绑定,直接登录
-        InfoUserOauth exist = infoUserOauthMapper.selectOne(
-                new LambdaQueryWrapper<InfoUserOauth>()
-                        .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(provider))
-                        .eq(InfoUserOauth::getProviderUid, providerUid));
-        if (exist != null) {
-            log.info("[OAuth] 绑定期间已被绑定,直接登录 userId={}", exist.getUserId());
-            InfoUser u = infoUserService.getById(exist.getUserId());
-            if (accountType != null && !canOauthLogin(accountType, u)) {
-                return error(MessageUtils.message("no.user.stop"));
+        // 期间已有绑定也必须验证本次手机号/业务账号,不能提前签发旧账号 token。
+        boolean businessAccount = accountType != null && !accountType.allowCreate();
+        // 本轮仅改变用户端换号;业务端原有绑定容错及角色校验保持原规则。
+        if (businessAccount) {
+            InfoUserOauth existing = infoUserOauthMapper.selectOne(new LambdaQueryWrapper<InfoUserOauth>()
+                    .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(provider))
+                    .eq(InfoUserOauth::getProviderUid, providerUid));
+            if (existing != null) {
+                InfoUser account = infoUserService.getById(existing.getUserId());
+                if (!canOauthLogin(accountType, account)) return error(MessageUtils.message("no.user.stop"));
+                refreshOauthDisplayName(existing, displayName);
+                return issueOauthToken(account, provider, providerUid);
             }
-            refreshOauthDisplayName(exist, displayName);
-            return issueOauthToken(u, provider, providerUid);
         }
-
-        boolean businessAccount = accountType != null && !accountType.allowCreate();
         InfoUser user;
         if (businessAccount) {
             // 017 2026-10-08 增量:业务端(骑手/商家/子账号)改用账号密码绑定。
@@ -1295,6 +1326,7 @@ public class InfoUserController extends BaseController {
             // getuser 不带 userType 过滤,同手机号命中商家/骑手账号会错绑或多行异常。
             OAuthAccountType bindingChannel = accountType != null ? accountType : OAuthAccountType.USER;
             user = findOauthBindingUser(bindingChannel, phone);
+            if (user != null) user = recoverCancelledUser(user);
             if (user != null && !"0".equals(user.getStatus())) {
                 // 停用账号不允许绑定三方(与 lodeing 一致,防止绕过停用)
                 log.warn("[OAuth] 账号已停用 phone={}", maskPhone(phone));
@@ -1332,24 +1364,12 @@ public class InfoUserController extends BaseController {
             }
         }
 
-        // 写三方绑定
-        InfoUserOauth bind = new InfoUserOauth();
-        bind.setUserId(user.getUserId());
-        bind.setProvider("line".equals(provider) ? "line_user" : provider);
-        bind.setProviderUid(providerUid);
-        bind.setProviderDisplayName(displayName != null && !displayName.isBlank() ? displayName : null);
-        bind.setCreateTime(new Date());
-        if (infoUserOauthMapper.insert(bind) != 1) {
-            throw new ServiceException(MessageUtils.message("no.system.error"));
-        }
-        log.info("[OAuth] 写入绑定 provider={}, userId={}", provider, user.getUserId());
-
-        redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
-        // 029 设备信任仅用户端适用:业务端绑定只验了密码(共享凭证场景)、未验手机号,
-        // 写信任会让共享设备后续经用户端 deviceConfirm 冒用登记手机号,故不记录
-        if (!businessAccount) {
-            deviceTrustService.recordLogin(dto.getDeviceId(), dto.getPhone(), user.getUserId());
-        }
+        // 先解绑旧用户,再写本次新绑定,避免误删新手机号的绑定。
+        if (!businessAccount) deviceTrustService.recordLogin(dto.getDeviceId(), user.getPhone(), user.getUserId());
+        InfoUserOauth bind = bindVerifiedIdentity(user, provider, providerUid, displayName);
+        if (!businessAccount) deviceTrustService.recordOauthLogin(dto.getDeviceId(), user.getPhone(), user.getUserId(), bind);
+        redisCache.deleteObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
+        redisCache.deleteObject(OAUTH_TEMP_USER_PREFIX + dto.getTempKey());
         return issueOauthToken(user, provider, providerUid);
     }
 
@@ -1359,6 +1379,7 @@ public class InfoUserController extends BaseController {
      */
     @Anonymous
     @PostMapping("/oauthDeviceConfirm")
+    @Transactional(rollbackFor = Exception.class)
     public AjaxResult oauthDeviceConfirm(@RequestBody OAuthDeviceConfirmDto dto) {
         if (dto == null || dto.getTempKey() == null || dto.getTempKey().isEmpty()) {
             log.warn("[OAuth] oauthDeviceConfirm 缺 tempKey");
@@ -1376,73 +1397,55 @@ public class InfoUserController extends BaseController {
         // 凭证与设备必须匹配:dev 键由 oauthLogin 的 deviceConfirm 分支写入,换设备重放即拒绝
         String boundDevice = redisCache.getCacheObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
         InfoUserDevice trust = deviceTrustService.getTrust(dto.getDeviceId());
-        if (boundDevice == null || !boundDevice.equals(dto.getDeviceId()) || trust == null) {
-            log.warn("[OAuth] 设备确认凭证与设备不匹配 tempKey={}, deviceId={}", dto.getTempKey(), dto.getDeviceId());
+        Object expectedUser = redisCache.getCacheObject(OAUTH_TEMP_USER_PREFIX + dto.getTempKey());
+        if (boundDevice == null || !boundDevice.equals(dto.getDeviceId()) || trust == null
+                || expectedUser == null || !String.valueOf(trust.getUserId()).equals(String.valueOf(expectedUser)))
             return error(MessageUtils.message("no.oauth.device.mismatch"));
-        }
         String[] cachedParts = cached.split("@", 3);
+        if (cachedParts.length < 2) return error(MessageUtils.message("no.oauth.tempkey.expired"));
         String provider = cachedParts[0];
         String providerUid = cachedParts[1];
         String displayName = cachedParts.length > 2 ? cachedParts[2] : null;
         OAuthAccountType accountType = OAuthAccountType.forProvider(provider);
-        if (accountType != null && accountType != OAuthAccountType.USER) {
-            // 业务账号绑定必须真实短信验证,设备信任免确认不适用
-            log.warn("[OAuth] 设备确认不支持业务渠道 provider={}", provider);
+        if (accountType != null && accountType != OAuthAccountType.USER)
             return error(MessageUtils.message("no.oauth.device.mismatch"));
-        }
-        log.info("[OAuth] 设备确认流程 provider={}, providerUid={}, deviceId={}", provider, providerUid, dto.getDeviceId());
 
-        // 容错:若期间已被绑定,直接登录(与 oauthBindPhone 一致)
-        InfoUserOauth exist = infoUserOauthMapper.selectOne(
-                new LambdaQueryWrapper<InfoUserOauth>()
-                        .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(provider))
-                        .eq(InfoUserOauth::getProviderUid, providerUid));
-        if (exist != null) {
-            log.info("[OAuth] 设备确认期间已被绑定,直接登录 userId={}", exist.getUserId());
-            InfoUser u = infoUserService.getById(exist.getUserId());
-            if (accountType != null && !canOauthLogin(accountType, u)) {
-                return error(MessageUtils.message("no.user.stop"));
-            }
-            refreshOauthDisplayName(exist, displayName);
-            return issueOauthToken(u, provider, providerUid);
-        }
+        // 用户行锁→设备行锁→绑定行锁;旧确认凭证不能绑定到新手机号,也不复建已消失的信任账号。
+        InfoUser user = findOauthBindingUser(OAuthAccountType.USER, trust.getPhone());
+        if (user == null || !java.util.Objects.equals(user.getUserId(), trust.getUserId()))
+            return error(MessageUtils.message("no.oauth.device.mismatch"));
+        if (!"0".equals(user.getStatus())) return error(MessageUtils.message("no.user.stop"));
+        user = recoverCancelledUser(user);
+        if (!canOauthLogin(OAuthAccountType.USER, user)) throw new ServiceException(MessageUtils.message("no.user.stop"));
+        deviceTrustService.requireConfirmation(dto.getDeviceId(), user.getUserId());
+        if (!redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey()))
+            throw new ServiceException(MessageUtils.message("no.oauth.tempkey.expired"));
+        InfoUserOauth bind = bindVerifiedIdentity(user, provider, providerUid, displayName);
+        deviceTrustService.recordOauthLogin(dto.getDeviceId(), user.getPhone(), user.getUserId(), bind);
+        redisCache.deleteObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
+        redisCache.deleteObject(OAUTH_TEMP_USER_PREFIX + dto.getTempKey());
+        return issueOauthToken(user, provider, providerUid);
+    }
 
-        // 信任手机号即账号主键:已注册校验停用、未注册自动创建(与 oauthBindPhone 一致,防绕过停用)
-        String phone = trust.getPhone();
-        InfoUser user = findOauthBindingUser(OAuthAccountType.USER, phone);
-        if (user != null && !"0".equals(user.getStatus())) {
-            log.warn("[OAuth] 设备确认账号已停用 phone={}", DeviceTrustService.maskPhone(phone));
-            return error(MessageUtils.message("no.user.stop"));
-        }
-        if (user == null) {
-            InfoUser info = new InfoUser();
-            info.setPhone(phone);
-            info.setNickName(phone);
-            info.setUserType("0");
-            info.setMycode(new UUIDUtil().get8UUID());
-            infoUserService.saveOrUpdate(info);
-            user = findOauthBindingUser(OAuthAccountType.USER, phone);
-            if (user == null) {
-                return error(MessageUtils.message("no.user.not.exist"));
-            }
-            createUserWallet(user.getUserId());
-            log.info("[OAuth] 设备确认新建账号 userId={}", user.getUserId());
+    /** 身份与手机号已验证后才调用;并发已有绑定只能复用同一用户,不得返回其他账号。 */
+    private InfoUserOauth bindVerifiedIdentity(InfoUser user, String provider, String providerUid, String displayName) {
+        InfoUserOauth bind = infoUserOauthMapper.selectOne(new LambdaQueryWrapper<InfoUserOauth>()
+                .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(provider))
+                .eq(InfoUserOauth::getProviderUid, providerUid).last("FOR UPDATE"));
+        if (bind != null) {
+            if (!java.util.Objects.equals(bind.getUserId(), user.getUserId()))
+                throw new ServiceException(MessageUtils.message("no.oauth.binding.conflict"));
+            refreshOauthDisplayName(bind, displayName);
+            return bind;
         }
-
-        // 写三方绑定(一键确认即免短信绑定)
-        InfoUserOauth bind = new InfoUserOauth();
+        bind = new InfoUserOauth();
         bind.setUserId(user.getUserId());
         bind.setProvider("line".equals(provider) ? "line_user" : provider);
         bind.setProviderUid(providerUid);
         bind.setProviderDisplayName(displayName != null && !displayName.isBlank() ? displayName : null);
         bind.setCreateTime(new Date());
-        infoUserOauthMapper.insert(bind);
-        log.info("[OAuth] 设备确认写入绑定 provider={}, providerUid={}, userId={}", provider, providerUid, user.getUserId());
-
-        redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
-        redisCache.deleteObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
-        deviceTrustService.recordLogin(dto.getDeviceId(), phone, user.getUserId());
-        return issueOauthToken(user, provider, providerUid);
+        if (infoUserOauthMapper.insert(bind) != 1) throw new ServiceException(MessageUtils.message("no.system.error"));
+        return bind;
     }
 
     /** 手机号脱敏:保留前 3 后 4,中间 ****(仅日志用)。 */
@@ -1485,10 +1488,17 @@ public class InfoUserController extends BaseController {
         dto.setProviderUid(providerUid);
         fillLoginUserInfo(dto);
         String token = JwtUtil.setToken(tokenKey, dto);
+        if (token == null || token.isBlank()) throw new ServiceException(MessageUtils.message("no.system.error"));
         Object loginUser = accountType == OAuthAccountType.MERCHANT ? merchantLoginView(user) : user;
         return success(MessageUtils.message("no.user.login.success"), loginUser, token);
     }
 
+    /** 候选查询已锁定账号;只有身份认证成功的用户登录入口调用,刷新token不调用。 */
+    private InfoUser recoverCancelledUser(InfoUser user) {
+        return user != null && "0".equals(user.getUserType()) && "1".equals(user.getDelFlag())
+                ? userCancellationService.authenticatedLogin(user.getUserId()) : user;
+    }
+
     private boolean canOauthLogin(OAuthAccountType channel, InfoUser user) {
         if (user == null || !"0".equals(user.getStatus()) || !"0".equals(user.getDelFlag())
                 || !channel.userTypes().contains(user.getUserType())) {
@@ -1533,8 +1543,13 @@ public class InfoUserController extends BaseController {
         LambdaQueryWrapper<InfoUser> query = new LambdaQueryWrapper<InfoUser>()
                 .eq(channel.allowCreate(), InfoUser::getPhone, phone)
                 .eq(!channel.allowCreate(), InfoUser::getTelPhone, phone)
-                .eq(InfoUser::getDelFlag, "0")
                 .in(InfoUser::getUserType, channel.userTypes());
+        if (channel == OAuthAccountType.USER) {
+            query.and(w -> w.eq(InfoUser::getDelFlag, "0").or(pending -> pending
+                    .eq(InfoUser::getDelFlag, "1").gt(InfoUser::getCancellationDeadlineAt, new Date()))).last("FOR UPDATE");
+        } else {
+            query.eq(InfoUser::getDelFlag, "0");
+        }
         List<InfoUser> matches = infoUserService.list(query);
         if (matches.size() > 1) {
             log.warn("[OAuth] 手机号关联多个账号 channel={}, phone={}", channel, maskPhone(phone));

+ 28 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java

@@ -52,6 +52,7 @@ public class LineCallbackController extends BaseController {
     private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
     /** 与 InfoUserController.OAUTH_TEMP_DEVICE_PREFIX 一致:设备确认临时凭证 Redis 前缀(029) */
     private static final String OAUTH_TEMP_DEVICE_PREFIX = "oauth:bind:dev:";
+    private static final String OAUTH_TEMP_USER_PREFIX = "oauth:bind:user:";
 
     @Autowired
     private OAuthVerifyService oauthVerifyService;
@@ -60,6 +61,8 @@ public class LineCallbackController extends BaseController {
     @Autowired
     private IInfoUserService infoUserService;
     @Autowired
+    private UserCancellationService userCancellationService;
+    @Autowired
     private InfoUserOauthMapper infoUserOauthMapper;
     @Autowired
     private RedisCache redisCache;
@@ -81,6 +84,7 @@ public class LineCallbackController extends BaseController {
 
     @Anonymous
     @GetMapping("/callback")
+    @org.springframework.transaction.annotation.Transactional(rollbackFor = Exception.class)
     public void callback(@RequestParam(value = "provider", required = false) String provider,
                          @RequestParam(value = "code", required = false) String code,
                          @RequestParam(value = "state", required = false) String state,
@@ -118,19 +122,24 @@ public class LineCallbackController extends BaseController {
                 InfoUser user = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>()
                         .eq(InfoUser::getUserId, bind.getUserId())
                         .eq(InfoUser::getStatus, "0")
-                        .eq(InfoUser::getDelFlag, "0"));
+                        .and(w -> w.eq(InfoUser::getDelFlag, "0").or(!businessLine, pending -> pending
+                                .eq(InfoUser::getUserType, "0").eq(InfoUser::getDelFlag, "1")
+                                .gt(InfoUser::getCancellationDeadlineAt, new java.util.Date())))
+                        .last(!businessLine, "FOR UPDATE"));
+                if (!businessLine && user != null && "0".equals(user.getUserType()) && "1".equals(user.getDelFlag()))
+                    user = userCancellationService.authenticatedLogin(user.getUserId());
                 if (!canLogin(channel, user)) {
                     response.sendRedirect(channel.appRedirect() + "?error=user_stopped" + stateSuffix);
                     return;
                 }
-                refreshOauthDisplayName(bind, identity.displayName());
-                String token = buildOauthToken(user, channel, providerUid);
                 // 029:旧用户 App 契约 state 由前端传 deviceId(specs/029 §5),登录即记录设备信任;
                 // 业务线 state 为 LINE 授权状态,不是 deviceId,不记录
                 if (!businessLine) {
-                    deviceTrustService.recordLogin(state, user.getPhone(), user.getUserId());
+                    deviceTrustService.recordOauthLogin(state, user.getPhone(), user.getUserId(), bind);
                 }
+                refreshOauthDisplayName(bind, identity.displayName());
                 log.info("[OAuth][LINE] callback 已绑定登录成功 userId={}", user.getUserId());
+                String token = buildOauthToken(user, channel, providerUid);
                 response.sendRedirect(channel.appRedirect() + "?token=" + enc(token) + stateSuffix);
                 return;
             }
@@ -142,9 +151,12 @@ public class LineCallbackController extends BaseController {
                     : provider + "@" + providerUid + "@" + identity.displayName();
             redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey, tempValue, 5, TimeUnit.MINUTES);
             // 029:旧用户 App 未绑定且设备已信任 → deviceConfirm 免短信分支;业务线不适用
-            String trustedPhone = businessLine ? null : deviceTrustService.getTrustedPhone(state);
+            com.ruoyi.system.domain.InfoUserDevice trustedDevice = businessLine ? null : deviceTrustService.getTrust(state);
+            String trustedPhone = trustedDevice == null ? null : trustedDevice.getPhone();
             if (trustedPhone != null) {
                 redisCache.setCacheObject(OAUTH_TEMP_DEVICE_PREFIX + tempKey, state, 5, TimeUnit.MINUTES);
+                redisCache.setCacheObject(OAUTH_TEMP_USER_PREFIX + tempKey,
+                        String.valueOf(trustedDevice.getUserId()), 5, TimeUnit.MINUTES);
                 log.info("[OAuth][LINE] callback 设备已信任,返回 deviceConfirm providerUid={}, phone={}",
                         providerUid, DeviceTrustService.maskPhone(trustedPhone));
                 response.sendRedirect(channel.appRedirect() + "?deviceConfirm=1&tempKey=" + enc(tempKey)
@@ -154,8 +166,14 @@ public class LineCallbackController extends BaseController {
             log.info("[OAuth][LINE] callback 未绑定,返回 needPhone provider={}", provider);
             response.sendRedirect(channel.appRedirect() + "?needPhone=1&tempKey=" + enc(tempKey) + stateSuffix);
         } catch (Exception exception) {
+            // 回调捕获异常并跳回App时,认证恢复也必须随整个登录事务回滚。
+            if (org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive())
+                org.springframework.transaction.interceptor.TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
             log.error("[OAuth][LINE] callback 异常 provider={}, type={}", provider, exception.getClass().getSimpleName());
-            response.sendRedirect(channel.appRedirect() + "?error=fail" + stateSuffix);
+            String error = !businessLine && exception instanceof ServiceException
+                    && MessageUtils.message("no.oauth.device.phone.changed").equals(exception.getMessage())
+                    ? "phone_login_required" : "fail";
+            response.sendRedirect(channel.appRedirect() + "?error=" + error + stateSuffix);
         }
     }
 
@@ -195,7 +213,10 @@ public class LineCallbackController extends BaseController {
         dto.setProvider(channel.provider());
         dto.setProviderUid(providerUid);
         fillLoginUserInfo(dto);
-        return JwtUtil.setToken(channel.tokenKey(), dto);
+        String token = JwtUtil.setToken(channel.tokenKey(), dto);
+        // 会话写入失败必须回滚本次登录及翻悔恢复,不能返回空token却恢复账号。
+        if (token == null || token.isBlank()) throw new ServiceException(MessageUtils.message("no.system.error"));
+        return token;
     }
 
     private void fillLoginUserInfo(LoginUserDto userDto) {

+ 64 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/UserCancellationService.java

@@ -0,0 +1,64 @@
+package com.ruoyi.app.user;
+
+import com.ruoyi.common.constant.CacheConstants;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+
+/** 普通用户主动注销;调用登录恢复前必须已经完成短信或第三方身份验证。 */
+@Service
+public class UserCancellationService {
+    private final InfoUserMapper users;
+    private final RedisCache redis;
+    private final int graceDays;
+    public UserCancellationService(InfoUserMapper users, RedisCache redis,
+            @Value("${user.cancellation.grace-days:7}") int graceDays) {
+        this.users = users; this.redis = redis;
+        this.graceDays = graceDays > 0 && graceDays <= 365 ? graceDays : 7;
+    }
+    public record CancellationView(
+            @com.fasterxml.jackson.annotation.JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+            Date cancellationDeadlineAt, int graceDays) { }
+
+    @Transactional(rollbackFor = Exception.class)
+    public CancellationView request(Long userId) {
+        // 与认证恢复共用数据库行锁;重复申请不能延长原截止时间。
+        InfoUser user = users.selectCancellationUserForUpdate(userId);
+        requireUser(user);
+        if (!"0".equals(user.getDelFlag())) throw unavailable();
+        Date deadline = Date.from(Instant.now().plus(graceDays, ChronoUnit.DAYS));
+        if (users.requestUserCancellation(userId, deadline) != 1) throw unavailable();
+        redis.deleteKeys(CacheConstants.USER_TOKEN_KEY + userId + ":*");
+        return new CancellationView(deadline, graceDays);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public InfoUser authenticatedLogin(Long userId) {
+        // 登录入口的外层事务持有此锁至签发新会话完成,防止并发注销覆盖恢复。
+        InfoUser user = users.selectCancellationUserForUpdate(userId);
+        requireUser(user);
+        if ("0".equals(user.getDelFlag())) return user;
+        Date now = new Date();
+        if (!canRecover(user, now) || users.restoreCancelledUser(userId, now) != 1) throw unavailable();
+        user.setDelFlag("0"); user.setCancellationDeadlineAt(null);
+        return user;
+    }
+
+    public static boolean canRecover(InfoUser user, Date now) {
+        return user != null && "0".equals(user.getUserType()) && "0".equals(user.getStatus())
+                && "1".equals(user.getDelFlag()) && user.getCancellationDeadlineAt() != null
+                && user.getCancellationDeadlineAt().after(now);
+    }
+    private void requireUser(InfoUser user) {
+        if (user == null || !"0".equals(user.getUserType()) || !"0".equals(user.getStatus())) throw unavailable();
+    }
+    private ServiceException unavailable() { return new ServiceException(MessageUtils.message("no.user.stop")); }
+}

+ 71 - 34
ruoyi-admin/src/main/java/com/ruoyi/app/user/service/DeviceTrustService.java

@@ -1,58 +1,95 @@
 package com.ruoyi.app.user.service;
 
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.domain.InfoUserOauth;
 import com.ruoyi.system.mapper.InfoUserDeviceMapper;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
+import com.ruoyi.system.mapper.InfoUserOauthDeviceMapper;
 import org.springframework.stereotype.Service;
-
+import org.springframework.transaction.annotation.Transactional;
 import java.util.Date;
+import java.util.Objects;
+import java.util.Set;
 
-/**
- * 设备-手机号信任服务(029-device-trust-phone-bind)。
- * <p>规则一句话:手机号验证通过 + 登录成功 + 带了合法 deviceId → upsert 信任记录。</p>
- * <p>调用点:lodeing 短信登录、oauthBindPhone 三方短信绑定、oauthLogin 已绑定直登、
- * oauthDeviceConfirm 一键确认、LINE 回调已绑定路径。同设备以最近一次为准(覆盖),
- * 入参不全时静默跳过,绝不影响登录主流程。</p>
- *
- * @author foodie
- * @date 2026-09-20
- */
+/** 029:手机号验证决定设备账号,第三方快捷登录仅记录实际使用的绑定。 */
 @Service
 public class DeviceTrustService {
-
+    private static final Set<String> USER_PROVIDERS = Set.of("apple", "google", "line", "line_user");
     private final InfoUserDeviceMapper deviceMapper;
+    private final InfoUserOauthMapper oauthMapper;
+    private final InfoUserOauthDeviceMapper linkMapper;
 
-    public DeviceTrustService(InfoUserDeviceMapper deviceMapper) {
+    public DeviceTrustService(InfoUserDeviceMapper deviceMapper, InfoUserOauthMapper oauthMapper,
+                              InfoUserOauthDeviceMapper linkMapper) {
         this.deviceMapper = deviceMapper;
+        this.oauthMapper = oauthMapper;
+        this.linkMapper = linkMapper;
     }
 
-    /** deviceId 合法性:非空且 ≤64(空串/超长按未传处理,老版本 App 兼容) */
     public static boolean isValidDeviceId(String deviceId) {
-        return deviceId != null && !deviceId.isEmpty() && deviceId.length() <= 64;
+        return deviceId != null && !deviceId.isBlank() && deviceId.length() <= 64;
     }
 
-    /** 记录/刷新设备信任(同设备以最近一次为准;入参不全时静默跳过) */
+    /** 仅供手机号验证码登录及验证码绑号调用;必须先完成凭证与账号校验。 */
+    @Transactional(rollbackFor = Exception.class)
     public void recordLogin(String deviceId, String phone, Long userId) {
-        if (!isValidDeviceId(deviceId) || phone == null || phone.isEmpty() || userId == null) {
-            return;
-        }
-        InfoUserDevice exist = deviceMapper.selectById(deviceId);
-        Date now = new Date();
-        if (exist == null) {
-            InfoUserDevice row = new InfoUserDevice();
-            row.setDeviceId(deviceId);
-            row.setPhone(phone);
-            row.setUserId(userId);
-            row.setCreateTime(now);
-            row.setUpdateTime(now);
-            deviceMapper.insert(row);
-        } else {
-            exist.setPhone(phone);
-            exist.setUserId(userId);
-            exist.setUpdateTime(now);
-            deviceMapper.updateById(exist);
+        if (!isValidDeviceId(deviceId) || phone == null || phone.isBlank() || userId == null) return;
+        InfoUserDevice row = lockDevice(deviceId, phone, userId);
+        if (!Objects.equals(row.getUserId(), userId)) {
+            Long previousUser = row.getUserId();
+            // 先锁绑定,再查设备关联;跨设备删除同一绑定时不能先各自锁关联行。
+            for (Long id : oauthMapper.selectUserBindingIdsForUpdate(previousUser)) {
+                if (linkMapper.selectDeviceBindingForUpdate(deviceId, id) == null) continue;
+                linkMapper.deleteByOauthId(id);
+                if (oauthMapper.deleteOwnedUserBinding(id, previousUser) != 1) throw failure("no.oauth.binding.conflict");
+            }
         }
+        updateTrust(row, phone, userId);
+    }
+
+    /** 快捷登录不能静默切回旧手机号;重新锁定绑定以防并发解绑后用旧快照复活关系。 */
+    @Transactional(rollbackFor = Exception.class)
+    public void recordOauthLogin(String deviceId, String phone, Long userId, InfoUserOauth binding) {
+        // 旧客户端未传设备,不推测设备关联。
+        if (!isValidDeviceId(deviceId)) return;
+        if (binding == null || binding.getProvider() == null) throw failure("no.oauth.binding.conflict");
+        if (!USER_PROVIDERS.contains(binding.getProvider())) return;
+        if (phone == null || userId == null) throw failure("no.oauth.binding.conflict");
+        InfoUserDevice row = lockDevice(deviceId, phone, userId);
+        if (!Objects.equals(row.getUserId(), userId)) throw failure("no.oauth.device.phone.changed");
+        InfoUserOauth locked = binding.getId() == null ? null : oauthMapper.selectByIdForUpdate(binding.getId());
+        if (locked == null || !Objects.equals(locked.getUserId(), userId)
+                || !Objects.equals(locked.getProvider(), binding.getProvider())
+                || !Objects.equals(locked.getProviderUid(), binding.getProviderUid()))
+            throw failure("no.oauth.binding.conflict");
+        linkMapper.recordUse(deviceId, locked.getId());
+        updateTrust(row, phone, userId);
+    }
+
+    /** 在用户行锁之后获取设备行锁;凭证绑定签发时用户,不允许旧弹窗确认新手机号。 */
+    @Transactional(rollbackFor = Exception.class)
+    public void requireConfirmation(String deviceId, Long expectedUserId) {
+        InfoUserDevice row = isValidDeviceId(deviceId) ? deviceMapper.selectForUpdate(deviceId) : null;
+        if (row == null || expectedUserId == null || !Objects.equals(row.getUserId(), expectedUserId))
+            throw failure("no.oauth.device.mismatch");
+    }
+
+    private InfoUserDevice lockDevice(String deviceId, String phone, Long userId) {
+        deviceMapper.ensureExists(deviceId, phone, userId);
+        InfoUserDevice row = deviceMapper.selectForUpdate(deviceId);
+        if (row == null) throw failure("no.system.error");
+        return row;
     }
 
+    private void updateTrust(InfoUserDevice row, String phone, Long userId) {
+        row.setPhone(phone); row.setUserId(userId); row.setUpdateTime(new Date());
+        if (deviceMapper.updateById(row) != 1) throw failure("no.system.error");
+    }
+
+    private ServiceException failure(String key) { return new ServiceException(MessageUtils.message(key)); }
+
     /** 查询设备信任记录(deviceId 非法返回 null,不查库) */
     public InfoUserDevice getTrust(String deviceId) {
         if (!isValidDeviceId(deviceId)) {

+ 4 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -504,3 +504,7 @@ native.push.test.accepted=推送服务已接受消息,请检查手机通知
 native.push.test.failed=原生推送发送失败
 order.im.group.invalid=订单号和群聊标识不能为空,群聊标识不能超过200个字符
 merchant.order.print.invalidSnapshot=訂單小票資料不完整,請聯絡平台處理
+
+# 029 device phone switch
+no.oauth.device.phone.changed=請先以對應手機號碼及驗證碼登入,再使用第三方登入
+no.oauth.binding.conflict=第三方帳號綁定已變更,請重新登入

+ 4 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -490,3 +490,7 @@ native.push.test.accepted=The push provider accepted the message; check the phon
 native.push.test.failed=Native push delivery failed
 order.im.group.invalid=Order number and chat group ID are required; the chat group ID must not exceed 200 characters
 merchant.order.print.invalidSnapshot=Order receipt data is incomplete. Please contact support.
+
+# 029 device phone switch
+no.oauth.device.phone.changed=Please sign in with the corresponding phone number and verification code before using third-party sign-in.
+no.oauth.binding.conflict=The third-party account binding has changed. Please sign in again.

+ 4 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -491,3 +491,7 @@ native.push.test.accepted=ผู้ให้บริการรับข้อ
 native.push.test.failed=ส่งการแจ้งเตือนแบบเนทีฟไม่สำเร็จ
 order.im.group.invalid=ต้องระบุหมายเลขคำสั่งซื้อและรหัสกลุ่มแชต โดยรหัสกลุ่มแชตต้องไม่เกิน 200 ตัวอักษร
 merchant.order.print.invalidSnapshot=ข้อมูลใบรายการสั่งซื้อไม่ครบถ้วน กรุณาติดต่อฝ่ายสนับสนุน
+
+# 029 device phone switch
+no.oauth.device.phone.changed=โปรดเข้าสู่ระบบด้วยหมายเลขโทรศัพท์ที่เกี่ยวข้องและรหัสยืนยันก่อนเข้าสู่ระบบผ่านบัญชีภายนอก
+no.oauth.binding.conflict=การเชื่อมโยงบัญชีภายนอกเปลี่ยนแปลงแล้ว โปรดเข้าสู่ระบบอีกครั้ง

+ 4 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -28,3 +28,7 @@ multi.store.checkout.payment.dispatched=Cửa hàng này đã gửi yêu cầu t
 multi.store.checkout.invoice.unavailable=Các cửa hàng tham gia chưa hỗ trợ hóa đơn điện tử
 order.im.group.invalid=Mã đơn hàng và mã nhóm trò chuyện không được để trống; mã nhóm không được vượt quá 200 ký tự
 merchant.order.print.invalidSnapshot=Dữ liệu phiếu đơn hàng không đầy đủ. Vui lòng liên hệ hỗ trợ.
+
+# 029 device phone switch
+no.oauth.device.phone.changed=Vui lòng đăng nhập bằng số điện thoại tương ứng và mã xác minh trước khi đăng nhập bên thứ ba.
+no.oauth.binding.conflict=Liên kết tài khoản bên thứ ba đã thay đổi. Vui lòng đăng nhập lại.

+ 4 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -492,3 +492,7 @@ native.push.test.accepted=推送服务已接受消息,请检查手机通知
 native.push.test.failed=原生推送发送失败
 order.im.group.invalid=订单号和群聊标识不能为空,群聊标识不能超过200个字符
 merchant.order.print.invalidSnapshot=订单小票数据不完整,请联系平台处理
+
+# 029 device phone switch
+no.oauth.device.phone.changed=请先使用对应手机号及验证码登录,再使用第三方登录
+no.oauth.binding.conflict=第三方账号绑定已变更,请重新登录

+ 4 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -491,3 +491,7 @@ native.push.test.accepted=推播服務已接受訊息,請檢查手機通知
 native.push.test.failed=原生推播傳送失敗
 order.im.group.invalid=訂單編號和群聊識別碼不可空白,群聊識別碼不可超過200個字元
 merchant.order.print.invalidSnapshot=訂單小票資料不完整,請聯絡平台處理
+
+# 029 device phone switch
+no.oauth.device.phone.changed=請先以對應手機號碼及驗證碼登入,再使用第三方登入
+no.oauth.binding.conflict=第三方帳號綁定已變更,請重新登入

+ 162 - 27
ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java

@@ -37,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.startsWith;
@@ -52,6 +53,23 @@ import static org.mockito.Mockito.when;
  * 的关键分支;JwtUtil/MessageUtils 通过 SpringUtils 静态 BeanFactory 替换为测试桩。
  */
 class DeviceTrustFlowTest {
+    private UserCancellationService cancellationService;
+    private void pendingRecovery(InfoUser account, boolean pending) {
+        cancellationService = mock(UserCancellationService.class);
+        ReflectionTestUtils.setField(controller, "userCancellationService", cancellationService);
+        if (pending) {
+            account.setDelFlag("1");
+            account.setCancellationDeadlineAt(new java.util.Date(System.currentTimeMillis() + 60000));
+            when(cancellationService.authenticatedLogin(account.getUserId())).thenAnswer(call -> {
+                account.setDelFlag("0"); account.setCancellationDeadlineAt(null); return account;
+            });
+        }
+    }
+    private void verifyRecovery(boolean pending) {
+        if (pending) verify(cancellationService).authenticatedLogin(100L);
+        else verifyNoInteractions(cancellationService);
+    }
+
 
     private static ConfigurableListableBeanFactory originalBeanFactory;
 
@@ -66,6 +84,7 @@ class DeviceTrustFlowTest {
             mock(com.ruoyi.system.service.IVipUserService.class);
     private final InfoUserDeviceMapper deviceMapper = mock(InfoUserDeviceMapper.class);
 
+    private final com.ruoyi.system.mapper.InfoUserOauthDeviceMapper linkMapper = mock(com.ruoyi.system.mapper.InfoUserOauthDeviceMapper.class);
     private InfoUserController controller;
 
     @BeforeAll
@@ -100,7 +119,7 @@ class DeviceTrustFlowTest {
         ReflectionTestUtils.setField(controller, "oauthVerifyService", oauthVerifyService);
         ReflectionTestUtils.setField(controller, "userWalletService", userWalletService);
         ReflectionTestUtils.setField(controller, "vipUserService", vipUserService);
-        ReflectionTestUtils.setField(controller, "deviceTrustService", new DeviceTrustService(deviceMapper));
+        ReflectionTestUtils.setField(controller, "deviceTrustService", DeviceTrustTestSupport.service(deviceMapper, infoUserOauthMapper, linkMapper));
     }
 
     @AfterEach
@@ -200,6 +219,8 @@ class DeviceTrustFlowTest {
     private void stubConfirmRedis(String tempKey, String deviceId) {
         when(redisCache.getCacheObject("oauth:bind:" + tempKey)).thenReturn("google@uid-1");
         when(redisCache.getCacheObject("oauth:bind:dev:" + tempKey)).thenReturn(deviceId);
+        when(redisCache.getCacheObject("oauth:bind:user:" + tempKey)).thenReturn("100");
+        when(redisCache.deleteObject("oauth:bind:" + tempKey)).thenReturn(true);
     }
 
     private OAuthDeviceConfirmDto confirmDto(String tempKey, String deviceId) {
@@ -231,18 +252,13 @@ class DeviceTrustFlowTest {
     }
 
     @Test
-    void confirmCreatesUserWhenTrustedPhoneNotRegistered() {
+    void confirmCannotRecreateMissingTrustedAccount() {
         stubConfirmRedis("tk-2", "dev-1");
-        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0999888777", 0L));
-        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
-        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(), java.util.List.of(user(300L, "0999888777", "0")));
-
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0999888777", 100L));
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of());
         AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-2", "dev-1"));
-
-        assertEquals(200, result.get("code"));
-        assertNotNull(result.get("token"));
-        verify(userWalletService).createUserWallet(300L);
-        verify(infoUserOauthMapper).insert(org.mockito.ArgumentMatchers.<InfoUserOauth>argThat(b -> b.getUserId() == 300L));
+        assertEquals("no.oauth.device.mismatch", result.get("msg"));
+        verify(userWalletService, never()).createUserWallet(anyLong());
     }
 
     @Test
@@ -291,22 +307,27 @@ class DeviceTrustFlowTest {
         verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
     }
 
-    @Test
-    void confirmFallsBackToDirectLoginWhenBoundMeanwhile() {
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
+    void confirmFallsBackToDirectLoginWhenBoundMeanwhile(boolean pending) {
         stubConfirmRedis("tk-8", "dev-1");
         when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
-        InfoUserOauth exist = new InfoUserOauth();
+        InfoUserOauth exist = new InfoUserOauth(); exist.setId(77L);
         exist.setUserId(100L);
         exist.setProvider("google");
         exist.setProviderUid("uid-1");
         when(infoUserOauthMapper.selectOne(any())).thenReturn(exist);
-        when(infoUserService.getById(100L)).thenReturn(user(100L, "0987654321", "0"));
+        InfoUser existing = user(100L, "0987654321", "0");
+        pendingRecovery(existing, pending);
+        when(infoUserService.getOne(any())).thenReturn(existing);
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
 
         AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-8", "dev-1"));
 
         assertEquals(200, result.get("code"));
         assertNotNull(result.get("token"));
         verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+        verifyRecovery(pending);
     }
 
     // ==================== 记录点(US2) ====================
@@ -317,6 +338,7 @@ class DeviceTrustFlowTest {
         InfoUser existing = user(100L, "0987654321", "0");
         when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
         when(infoUserService.getOne(any())).thenReturn(existing);
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
         when(vipUserService.getOne(any())).thenReturn(null);
         when(deviceMapper.selectById("dev-1")).thenReturn(null);
 
@@ -328,16 +350,18 @@ class DeviceTrustFlowTest {
 
         assertEquals(200, result.get("code"));
         assertNotNull(result.get("token"));
-        verify(deviceMapper).insert(org.mockito.ArgumentMatchers.<InfoUserDevice>argThat(row -> "dev-1".equals(row.getDeviceId())
-                && "0987654321".equals(row.getPhone()) && row.getUserId() == 100L));
+        verify(deviceMapper).ensureExists("dev-1", "0987654321", 100L);
     }
 
-    @Test
-    void phoneLoginWithoutDeviceIdSkipsRecording() {
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
+    void phoneLoginWithoutDeviceIdSkipsRecording(boolean pending) {
         when(redisCache.getCacheObject("0987654321")).thenReturn("1234");
         InfoUser existing = user(100L, "0987654321", "0");
+        pendingRecovery(existing, pending);
         when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
         when(infoUserService.getOne(any())).thenReturn(existing);
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
         when(vipUserService.getOne(any())).thenReturn(null);
 
         UserDTO dto = new UserDTO();
@@ -347,16 +371,18 @@ class DeviceTrustFlowTest {
 
         assertEquals(200, result.get("code"));
         verifyNoInteractions(deviceMapper);
+        verifyRecovery(pending);
     }
 
-    @Test
-    void oauthBindPhoneSuccessRecordsDeviceTrust() {
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
+    void oauthBindPhoneSuccessRecordsDeviceTrust(boolean pending) {
         when(redisCache.getCacheObject("oauth:bind:tk-b1")).thenReturn("google@uid-9");
         when(redisCache.deleteObject("oauth:bind:tk-b1")).thenReturn(true);
         when(redisCache.getCacheObject("0987654321")).thenReturn(null);
         when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
-        when(infoUserOauthMapper.insert(any(InfoUserOauth.class))).thenReturn(1);
         InfoUser existing = user(100L, "0987654321", "0");
+        pendingRecovery(existing, pending);
         when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
         when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
         when(deviceMapper.selectById("dev-1")).thenReturn(null);
@@ -370,19 +396,23 @@ class DeviceTrustFlowTest {
 
         assertEquals(200, result.get("code"));
         assertNotNull(result.get("token"));
-        verify(deviceMapper).insert(any(InfoUserDevice.class));
+        verify(deviceMapper, org.mockito.Mockito.atLeastOnce()).ensureExists("dev-1", "0987654321", 100L);
+        verifyRecovery(pending);
     }
 
-    @Test
-    void oauthLoginBoundDirectLoginRecordsDeviceTrust() {
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
+    void oauthLoginBoundDirectLoginRecordsDeviceTrust(boolean pending) {
         when(oauthVerifyService.verifyIdentity("line", "cred-3")).thenReturn(new OAuthIdentity("uid-3", null));
-        InfoUserOauth bind = new InfoUserOauth();
+        InfoUserOauth bind = new InfoUserOauth(); bind.setId(77L);
         bind.setUserId(100L);
         bind.setProvider("line");
         bind.setProviderUid("uid-3");
         when(infoUserOauthMapper.selectOne(any())).thenReturn(bind);
         InfoUser existing = user(100L, "0987654321", "0");
+        pendingRecovery(existing, pending);
         when(infoUserService.getOne(any())).thenReturn(existing);
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(existing));
         when(deviceMapper.selectById("dev-1")).thenReturn(null);
 
         OAuthLoginDto dto = new OAuthLoginDto();
@@ -393,6 +423,111 @@ class DeviceTrustFlowTest {
 
         assertEquals(200, result.get("code"));
         assertNotNull(result.get("token"));
-        verify(deviceMapper).insert(org.mockito.ArgumentMatchers.<InfoUserDevice>argThat(row -> "0987654321".equals(row.getPhone())));
+        verify(deviceMapper).ensureExists("dev-1", "0987654321", 100L);
+        verifyRecovery(pending);
+    }
+
+    private InfoUser prepareSwitch() {
+        InfoUser target = user(200L, "0911111111", "0");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class))).thenReturn(java.util.List.of(target));
+        when(infoUserService.getOne(any())).thenReturn(target);
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+        when(infoUserOauthMapper.selectUserBindingIdsForUpdate(100L)).thenReturn(java.util.List.of(55L));
+        when(linkMapper.selectDeviceBindingForUpdate("dev-1", 55L)).thenReturn(550L);
+        when(infoUserOauthMapper.deleteOwnedUserBinding(55L, 100L)).thenReturn(1);
+        when(redisCache.getCacheObject("0911111111")).thenReturn("1234");
+        return target;
+    }
+
+    @Test
+    void phoneSwitchUnbindsOldDeviceIdentityAndReturnsNewAccount() {
+        InfoUser target = prepareSwitch();
+        UserDTO dto = new UserDTO(); dto.setPhone(target.getPhone()); dto.setCode("1234"); dto.setDeviceId("dev-1");
+        AjaxResult result = controller.lodeing(dto);
+        assertEquals(200, result.get("code"));
+        assertEquals(target, result.get("data"));
+        assertNotNull(result.get("token"));
+        verify(infoUserOauthMapper).deleteOwnedUserBinding(55L, 100L);
+        verify(linkMapper).deleteByOauthId(55L);
+        verify(deviceMapper).updateById(argThat((InfoUserDevice d) -> d.getUserId() == 200L));
+        verify(linkMapper, never()).recordUse(anyString(), anyLong());
+    }
+
+    @Test
+    void phoneSwitchWithWrongCodeDoesNotUnbind() {
+        prepareSwitch();
+        UserDTO dto = new UserDTO(); dto.setPhone("0911111111"); dto.setCode("wrong"); dto.setDeviceId("dev-1");
+        assertEquals(500, controller.lodeing(dto).get("code"));
+        verifyNoInteractions(linkMapper);
+        verify(deviceMapper, never()).updateById(any(InfoUserDevice.class));
+    }
+
+    private OAuthBindDto switchBindDto() {
+        when(redisCache.getCacheObject("oauth:bind:switch")).thenReturn("google@new-identity");
+        when(redisCache.deleteObject("oauth:bind:switch")).thenReturn(true);
+        OAuthBindDto dto = new OAuthBindDto(); dto.setTempKey("switch"); dto.setPhone("0911111111");
+        dto.setCode("1234"); dto.setDeviceId("dev-1"); return dto;
+    }
+
+    @Test
+    void verifiedBindingSwitchRemovesOldBindingBeforeLinkingNewAccount() {
+        InfoUser target = prepareSwitch();
+        AjaxResult result = controller.oauthBindPhone(switchBindDto());
+        assertEquals(200, result.get("code"));
+        assertEquals(target, result.get("data"));
+        assertNotNull(result.get("token"));
+        var order = org.mockito.Mockito.inOrder(infoUserOauthMapper, linkMapper);
+        order.verify(linkMapper).deleteByOauthId(55L);
+        order.verify(infoUserOauthMapper).deleteOwnedUserBinding(55L, 100L);
+        order.verify(infoUserOauthMapper).insert(argThat((InfoUserOauth b) -> b.getUserId() == 200L));
+        order.verify(linkMapper).recordUse("dev-1", 77L);
+    }
+
+    @Test
+    void bindingRaceCannotIssueOtherAccountsToken() {
+        prepareSwitch();
+        InfoUserOauth occupied = new InfoUserOauth(); occupied.setUserId(300L);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(occupied);
+        var ex = org.junit.jupiter.api.Assertions.assertThrows(com.ruoyi.common.exception.ServiceException.class,
+                () -> controller.oauthBindPhone(switchBindDto()));
+        assertEquals("no.oauth.binding.conflict", ex.getMessage());
+        verify(linkMapper, never()).recordUse(anyString(), anyLong());
+        verify(redisCache, never()).deleteKeys(anyString());
+    }
+
+    @Test
+    void invalidBindCodeCannotUseAlreadyBoundShortcut() {
+        prepareSwitch();
+        OAuthBindDto dto = switchBindDto(); dto.setCode("wrong");
+        InfoUserOauth occupied = new InfoUserOauth(); occupied.setUserId(300L);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(occupied);
+        assertEquals(500, controller.oauthBindPhone(dto).get("code"));
+        verifyNoInteractions(linkMapper);
+        verify(redisCache, never()).deleteKeys(anyString());
+    }
+
+    @Test
+    void oldConfirmationCannotBindToNewPhoneOnSameDevice() {
+        stubConfirmRedis("stale", "dev-1");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0911111111", 200L));
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("stale", "dev-1"));
+        assertEquals("no.oauth.device.mismatch", result.get("msg"));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+        verifyNoInteractions(linkMapper);
+    }
+
+    @Test
+    void consumedConfirmationCannotIssueTokenOrCreateBinding() {
+        stubConfirmRedis("used", "dev-1");
+        when(redisCache.deleteObject("oauth:bind:used")).thenReturn(false);
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+        when(infoUserService.list(any(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class)))
+                .thenReturn(java.util.List.of(user(100L, "0987654321", "0")));
+        var ex = org.junit.jupiter.api.Assertions.assertThrows(com.ruoyi.common.exception.ServiceException.class,
+                () -> controller.oauthDeviceConfirm(confirmDto("used", "dev-1")));
+        assertEquals("no.oauth.tempkey.expired", ex.getMessage());
+        verifyNoInteractions(linkMapper);
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
     }
 }

+ 41 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustTestSupport.java

@@ -0,0 +1,41 @@
+package com.ruoyi.app.user;
+
+import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.mapper.*;
+import java.util.HashMap;
+import java.util.Map;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+/** Controller 测试中模拟设备行初始化及数据库生成绑定主键;并发/事务另做数据库集成验证。 */
+public final class DeviceTrustTestSupport {
+    public static DeviceTrustService service(InfoUserDeviceMapper devices, InfoUserOauthMapper oauth) {
+        return service(devices, oauth, mock(InfoUserOauthDeviceMapper.class));
+    }
+
+    public static DeviceTrustService service(InfoUserDeviceMapper devices, InfoUserOauthMapper oauth,
+                                              InfoUserOauthDeviceMapper links) {
+        Map<String, InfoUserDevice> initial = new HashMap<>();
+        Map<Long, InfoUserOauth> inserted = new HashMap<>();
+        when(devices.ensureExists(anyString(), anyString(), anyLong())).thenAnswer(call -> {
+            InfoUserDevice row = new InfoUserDevice(); row.setDeviceId(call.getArgument(0));
+            row.setPhone(call.getArgument(1)); row.setUserId(call.getArgument(2));
+            initial.putIfAbsent(row.getDeviceId(), row); return 1;
+        });
+        when(devices.selectForUpdate(anyString())).thenAnswer(call -> {
+            String id = call.getArgument(0); InfoUserDevice row = devices.selectById(id);
+            return row == null ? initial.get(id) : row;
+        });
+        when(devices.updateById(any(InfoUserDevice.class))).thenReturn(1);
+        when(oauth.insert(any(InfoUserOauth.class))).thenAnswer(call -> {
+            InfoUserOauth bind = call.getArgument(0); bind.setId(77L); inserted.put(77L, bind); return 1;
+        });
+        when(oauth.selectByIdForUpdate(anyLong())).thenAnswer(call -> {
+            InfoUserOauth bind = inserted.get(call.getArgument(0));
+            return bind == null ? oauth.selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>()) : bind;
+        });
+        return new DeviceTrustService(devices, oauth, links);
+    }
+}

+ 73 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java

@@ -156,7 +156,7 @@ class InfoUserControllerTest {
         ReflectionTestUtils.setField(controller, "redisCache", redisCache);
         deviceMapper = mock(InfoUserDeviceMapper.class);
         ReflectionTestUtils.setField(controller, "deviceTrustService",
-                new DeviceTrustService(deviceMapper));
+                DeviceTrustTestSupport.service(deviceMapper, infoUserOauthMapper));
         when(infoUserOauthMapper.insert(any(InfoUserOauth.class))).thenReturn(1);
     }
 
@@ -551,6 +551,8 @@ class InfoUserControllerTest {
     void oauthBindingRaceRejectsDisabledNonLineAccount() {
         OAuthBindDto request = new OAuthBindDto();
         request.setTempKey("google-key");
+        request.setPhone("0912345678"); request.setCode("1234");
+        when(redisCache.getCacheObject("0912345678")).thenReturn("1234");
         when(redisCache.getCacheObject("oauth:bind:google-key"))
                 .thenReturn("google@google-uid");
         when(redisCache.deleteObject("oauth:bind:google-key")).thenReturn(true);
@@ -559,7 +561,7 @@ class InfoUserControllerTest {
         when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
         InfoUser disabled = activeUser(66L, "0");
         disabled.setStatus("1");
-        when(infoUserService.getById(66L)).thenReturn(disabled);
+        when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(disabled));
 
         AjaxResult result = controller.oauthBindPhone(request);
 
@@ -646,7 +648,8 @@ class InfoUserControllerTest {
         InfoUser user = activeUser(72L, "0");
         user.setPhone(request.getPhone());
         when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
-        when(infoUserService.getById(72L)).thenReturn(user);
+        when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(user));
+        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
 
         AjaxResult result = controller.oauthBindPhone(request);
 
@@ -999,6 +1002,73 @@ class InfoUserControllerTest {
         verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
     }
 
+    @ParameterizedTest
+    @CsvSource({"apple", "google", "line", "line_user"})
+    void verifiedBoundUserRestoresGraceAccountBeforeIssuingToken(String provider) {
+        OAuthLoginDto request = new OAuthLoginDto();
+        request.setProvider(provider); request.setCredential("verified-credential");
+        when(oauthVerifyService.verifyIdentity(provider, "verified-credential"))
+                .thenReturn(new OAuthIdentity("provider-uid", null));
+        InfoUserOauth binding = new InfoUserOauth(); binding.setUserId(72L);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
+        InfoUser user = activeUser(72L, "0"); user.setPhone("0912345678"); user.setDelFlag("1");
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
+        UserCancellationService cancellation = mock(UserCancellationService.class);
+        ReflectionTestUtils.setField(controller, "userCancellationService", cancellation);
+        when(cancellation.authenticatedLogin(72L)).thenAnswer(call -> { user.setDelFlag("0"); return user; });
+
+        AjaxResult result = controller.oauthLogin(request);
+
+        assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
+        verify(cancellation).authenticatedLogin(72L);
+        assertEquals("72", JWT.decode((String) result.get("token")).getClaim("id").asString());
+    }
+
+    @Test
+    void incorrectSmsCannotRestoreGraceAccount() {
+        UserCancellationService cancellation = mock(UserCancellationService.class);
+        ReflectionTestUtils.setField(controller, "userCancellationService", cancellation);
+        when(redisCache.getCacheObject("0912345678")).thenReturn("123456");
+        UserDTO request = new UserDTO(); request.setPhone("0912345678"); request.setCode("000000");
+        assertEquals(HttpStatus.ERROR, controller.lodeing(request).get(AjaxResult.CODE_TAG));
+        org.mockito.Mockito.verifyNoInteractions(cancellation);
+        verify(infoUserService, never()).list(any(Wrapper.class));
+    }
+
+    @Test
+    void ordinaryUserCancellationReturnsGraceDeadlineInsteadOfImmediateDeletion() {
+        when(infoUserService.getById(42L)).thenReturn(activeUser(42L, "0"));
+        UserCancellationService cancellation = mock(UserCancellationService.class);
+        ReflectionTestUtils.setField(controller, "userCancellationService", cancellation);
+        var view = new UserCancellationService.CancellationView(new java.util.Date(), 7);
+        when(cancellation.request(42L)).thenReturn(view);
+        assertEquals(view, controller.deleuser(tokenFor(42L)).get(AjaxResult.DATA_TAG));
+        verify(infoUserService, never()).deleteInfoUserByUserId(42L);
+    }
+
+    @Test
+    void refreshDoesNotRestoreDeletedAccount() {
+        UserCancellationService cancellation = mock(UserCancellationService.class);
+        ReflectionTestUtils.setField(controller, "userCancellationService", cancellation);
+        assertEquals(HttpStatus.ERROR, controller.gettoken(tokenFor(72L)).get(AjaxResult.CODE_TAG));
+        org.mockito.Mockito.verifyNoInteractions(cancellation);
+        ArgumentCaptor<Wrapper<InfoUser>> query = ArgumentCaptor.forClass(Wrapper.class);
+        verify(infoUserService).getOne(query.capture());
+        assertTrue(query.getValue().getSqlSegment().contains("FOR UPDATE"));
+        assertTrue(query.getValue().getSqlSegment().contains("del_flag"));
+    }
+
+    @Test
+    void failedSessionCreationThrowsToRollBackGraceRecovery() {
+        OAuthBindDto request = oauthRequest("apple");
+        InfoUserOauth binding = new InfoUserOauth(); binding.setUserId(72L);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(binding);
+        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(activeUser(72L, "0"));
+        try (MockedStatic<JwtUtil> jwt = mockStatic(JwtUtil.class)) {
+            assertThrows(ServiceException.class, () -> controller.oauthBindPhone(request));
+        }
+    }
+
     private OAuthBindDto oauthRequest(String provider) {
         OAuthBindDto request = new OAuthBindDto();
         request.setTempKey("business-key");

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackControllerTest.java

@@ -211,7 +211,7 @@ class LineCallbackControllerTest {
         ReflectionTestUtils.setField(controller, "infoUserOauthMapper", mock(InfoUserOauthMapper.class));
         ReflectionTestUtils.setField(controller, "redisCache", redis);
         ReflectionTestUtils.setField(controller, "deviceTrustService",
-                new DeviceTrustService(mock(InfoUserDeviceMapper.class)));
+                DeviceTrustTestSupport.service(mock(InfoUserDeviceMapper.class), mock(InfoUserOauthMapper.class)));
         MockHttpServletResponse response = new MockHttpServletResponse();
 
         controller.callback(null, "old-code", "line_login", response);

+ 30 - 5
ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java

@@ -97,7 +97,7 @@ class LineCallbackDeviceTrustTest {
         ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
         ReflectionTestUtils.setField(controller, "infoUserOauthMapper", infoUserOauthMapper);
         ReflectionTestUtils.setField(controller, "oauthVerifyService", oauthVerifyService);
-        ReflectionTestUtils.setField(controller, "deviceTrustService", new DeviceTrustService(deviceMapper));
+        ReflectionTestUtils.setField(controller, "deviceTrustService", DeviceTrustTestSupport.service(deviceMapper, infoUserOauthMapper));
         ReflectionTestUtils.setField(controller, "lineOAuthProperties", properties);
 
         response = new MockHttpServletResponse();
@@ -148,10 +148,11 @@ class LineCallbackDeviceTrustTest {
         verify(redisCache, never()).setCacheObject(startsWith("oauth:bind:dev:"), anyString(), anyInt(), any(TimeUnit.class));
     }
 
-    @Test
-    void boundUserWithDeviceStateRedirectsTokenAndRecordsTrust() throws Exception {
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
+    void boundUserWithDeviceStateRedirectsTokenAndRecordsTrust(boolean pending) throws Exception {
         when(oauthVerifyService.verifyIdentity("line", "code-3")).thenReturn(new OAuthIdentity("line-uid-3", null));
-        InfoUserOauth bind = new InfoUserOauth();
+        InfoUserOauth bind = new InfoUserOauth(); bind.setId(77L);
         bind.setUserId(100L);
         bind.setProvider("line");
         bind.setProviderUid("line-uid-3");
@@ -161,6 +162,12 @@ class LineCallbackDeviceTrustTest {
         u.setPhone("0987654321");
         u.setUserType("0");
         u.setStatus("0");
+        u.setDelFlag(pending ? "1" : "0");
+        UserCancellationService cancellation = mock(UserCancellationService.class);
+        ReflectionTestUtils.setField(controller, "userCancellationService", cancellation);
+        if (pending) when(cancellation.authenticatedLogin(100L)).thenAnswer(call -> {
+            u.setDelFlag("0"); return u;
+        });
         when(infoUserService.getOne(any())).thenReturn(u);
         when(deviceMapper.selectById("dev-1")).thenReturn(null);
 
@@ -169,6 +176,24 @@ class LineCallbackDeviceTrustTest {
         String location = response.getRedirectedUrl();
         assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?token="),
                 "已绑定应直接回跳 token,实际: " + location);
-        verify(deviceMapper).insert(any(InfoUserDevice.class));
+        verify(deviceMapper).ensureExists("dev-1", "0987654321", 100L);
+        if (pending) verify(cancellation).authenticatedLogin(100L);
+        else org.mockito.Mockito.verifyNoInteractions(cancellation);
+    }
+
+    @Test
+    void boundIdentityFromOldPhoneRequiresPhoneLoginWithoutIssuingToken() throws Exception {
+        when(oauthVerifyService.verifyIdentity("line", "conflict")).thenReturn(new OAuthIdentity("old-identity", null));
+        InfoUserOauth bind = new InfoUserOauth(); bind.setId(77L); bind.setUserId(100L);
+        bind.setProvider("line"); bind.setProviderUid("old-identity");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(bind);
+        InfoUser user = new InfoUser(); user.setUserId(100L); user.setPhone("0987654321");
+        user.setUserType("0"); user.setStatus("0"); user.setDelFlag("0");
+        when(infoUserService.getOne(any())).thenReturn(user);
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0911111111", 200L));
+        controller.callback("line", "conflict", "dev-1", response);
+        assertEquals("com.test.app://oauthLogin?error=phone_login_required", response.getRedirectedUrl());
+        verify(redisCache, never()).deleteKeys(anyString());
+        verify(deviceMapper, never()).updateById(any(InfoUserDevice.class));
     }
 }

+ 4 - 8
ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSessionAuthAnnotationTest.java

@@ -42,7 +42,7 @@ import static org.mockito.Mockito.when;
 /**
  * 会话校验语义测试(单设备登录,2026-09-22):会话校验已全局化,
  * 本测试守护两件事——商家关键端点仍声明 @Auth;切面对「新格式会话被删(被顶号/登出)」
- * 的 token 返回 401、对历史无冒号 jti 的旧 token 放行。
+ * 的 token 返回 401,历史无会话 token 不再放行。
  */
 class MerchantSessionAuthAnnotationTest {
 
@@ -164,19 +164,15 @@ class MerchantSessionAuthAnnotationTest {
     }
 
     @Test
-    void legacyTokenWithoutColonJtiStillPasses() throws Throwable {
+    void legacyTokenWithoutRedisSessionIsRejected() throws Throwable {
         // 历史无会话重载签发的 token:jti 为纯 UUID,不含冒号
         MockHttpServletRequest request = requestWithToken(JwtUtil.token("7", "user7"));
         RedisCache redisCache = mock(RedisCache.class);
         AuthAspect aspect = new AuthAspect(redisCache);
         ReflectionTestUtils.setField(aspect, "request", request);
         ProceedingJoinPoint joinPoint = joinPointOnDummy();
-        Object expected = new Object();
-        Mockito.when(joinPoint.proceed()).thenReturn(expected);
-
         Object actual = aspect.around(joinPoint);
-
-        assertSame(expected, actual, "历史无冒号 jti 的旧 token 应放行至自然过期");
-        verify(redisCache, never()).hasKey(anyString());
+        assertEquals(401, ((AjaxResult) actual).get("code"));
+        verify(joinPoint, never()).proceed();
     }
 }

+ 70 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/UserCancellationServiceTest.java

@@ -0,0 +1,70 @@
+package com.ruoyi.app.user;
+
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import org.junit.jupiter.api.Test;
+import java.util.Date;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+class UserCancellationServiceTest {
+    private final InfoUserMapper users = mock(InfoUserMapper.class);
+    private final RedisCache redis = mock(RedisCache.class);
+    private final UserCancellationService service = new UserCancellationService(users, redis, 7);
+    private InfoUser user(String deleted, Date deadline) {
+        InfoUser u = new InfoUser(); u.setUserId(1L); u.setUserType("0");
+        u.setStatus("0"); u.setDelFlag(deleted); u.setCancellationDeadlineAt(deadline);
+        when(users.selectCancellationUserForUpdate(1L)).thenReturn(u); return u;
+    }
+    @Test void cancellationRecordsSevenDaysAndRevokesUserSessions() {
+        user("0", null); when(users.requestUserCancellation(eq(1L), any())).thenReturn(1);
+        Date before = new Date(); var result = service.request(1L);
+        assertTrue(result.cancellationDeadlineAt().getTime() >= before.getTime() + 7L*86400000);
+        verify(redis).deleteKeys(anyString());
+    }
+    @Test void verifiedLoginRestoresOriginalUserAndClearsDeadline() {
+        InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
+        when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
+        assertSame(u, service.authenticatedLogin(1L));
+        assertEquals("0", u.getDelFlag()); assertNull(u.getCancellationDeadlineAt());
+    }
+    @Test void expiredHistoricalDisabledAndNonUserAccountsCannotRestore() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            user("1", null); assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
+            user("1", new Date(0)); assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
+            InfoUser u = user("1", new Date(System.currentTimeMillis()+60000)); u.setStatus("1");
+            assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
+            u.setStatus("0"); u.setUserType("2");
+            assertThrows(ServiceException.class, () -> service.request(1L));
+            verify(users, never()).restoreCancelledUser(anyLong(), any());
+        }
+    }
+    @Test void updateLosingDeadlineRaceDoesNotRestoreAccount() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            user("1", new Date(System.currentTimeMillis()+60000));
+            assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
+        }
+    }
+    @Test void exactDeadlineIsNotRecoverable() {
+        Date now = new Date();
+        assertFalse(UserCancellationService.canRecover(user("1", now), now));
+    }
+    @Test void repeatCancellationCannotExtendDeadline() {
+        try (var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            user("1", new Date(System.currentTimeMillis() + 60000));
+            assertThrows(ServiceException.class, () -> service.request(1L));
+            verify(users, never()).requestUserCancellation(anyLong(), any());
+            verifyNoInteractions(redis);
+        }
+    }
+    @Test void activeLoginDoesNotChangeAccountLifecycle() {
+        InfoUser active = user("0", null);
+        assertSame(active, service.authenticatedLogin(1L));
+        verify(users, never()).restoreCancelledUser(anyLong(), any());
+    }
+
+}

+ 91 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/service/DeviceBindingSwitchTest.java

@@ -0,0 +1,91 @@
+package com.ruoyi.app.user.service;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.mapper.InfoUserDeviceMapper;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
+import com.ruoyi.system.mapper.InfoUserOauthDeviceMapper;
+import org.junit.jupiter.api.Test;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class DeviceBindingSwitchTest {
+    final InfoUserDeviceMapper devices = mock(InfoUserDeviceMapper.class);
+    final InfoUserOauthMapper bindings = mock(InfoUserOauthMapper.class);
+    final InfoUserOauthDeviceMapper links = mock(InfoUserOauthDeviceMapper.class);
+    final DeviceTrustService service = new DeviceTrustService(devices, bindings, links);
+
+    InfoUserDevice device(long userId) {
+        InfoUserDevice d = new InfoUserDevice(); d.setDeviceId("D1");
+        d.setUserId(userId); d.setPhone("phone-" + userId);
+        when(devices.selectForUpdate("D1")).thenReturn(d);
+        when(devices.updateById(d)).thenReturn(1);
+        return d;
+    }
+
+    @Test void switchDeletesOnlyDeviceBindingsOwnedByPreviousUserBeforeUpdatingDevice() {
+        InfoUserDevice d = device(10L);
+        when(bindings.selectUserBindingIdsForUpdate(10L)).thenReturn(List.of(7L, 8L, 9L));
+        when(links.selectDeviceBindingForUpdate("D1", 7L)).thenReturn(70L);
+        when(links.selectDeviceBindingForUpdate("D1", 8L)).thenReturn(80L);
+        when(links.selectDeviceBindingForUpdate("D1", 9L)).thenReturn(null);
+        when(bindings.deleteOwnedUserBinding(7L, 10L)).thenReturn(1);
+        when(bindings.deleteOwnedUserBinding(8L, 10L)).thenReturn(1);
+        service.recordLogin("D1", "new-phone", 20L);
+        var order = inOrder(links, bindings, devices);
+        order.verify(bindings).selectUserBindingIdsForUpdate(10L);
+        order.verify(links).selectDeviceBindingForUpdate("D1", 7L);
+        order.verify(links).deleteByOauthId(7L);
+        order.verify(bindings).deleteOwnedUserBinding(7L, 10L);
+        order.verify(links).deleteByOauthId(8L);
+        order.verify(bindings).deleteOwnedUserBinding(8L, 10L);
+        order.verify(devices).updateById(d);
+        assertEquals(20L, d.getUserId());
+        verify(bindings, never()).deleteOwnedUserBinding(9L, 10L);
+        verify(links, never()).deleteByOauthId(9L);
+    }
+
+    @Test void sameAccountAndInvalidDevicesNeverUnbind() {
+        device(10L);
+        service.recordLogin("D1", "same-phone", 10L);
+        service.recordLogin(null, "other-phone", 20L);
+        service.recordLogin(" ", "other-phone", 20L);
+        verifyNoInteractions(links, bindings);
+    }
+
+    @Test void shortcutCannotOverwriteAnotherVerifiedPhone() {
+        device(20L);
+        InfoUserOauth bind = new InfoUserOauth(); bind.setId(7L); bind.setUserId(10L);
+        bind.setProvider("apple"); bind.setProviderUid("X");
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            assertThrows(ServiceException.class, () -> service.recordOauthLogin("D1", "A", 10L, bind));
+        }
+        verifyNoInteractions(links, bindings);
+        verify(devices, never()).updateById(any(InfoUserDevice.class));
+    }
+
+    @Test void removedBindingCannotBeResurrectedByStaleShortcut() {
+        device(10L);
+        InfoUserOauth bind = new InfoUserOauth(); bind.setId(7L); bind.setUserId(10L);
+        bind.setProvider("google"); bind.setProviderUid("X");
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            assertThrows(ServiceException.class, () -> service.recordOauthLogin("D1", "A", 10L, bind));
+        }
+        verify(links, never()).recordUse(anyString(), anyLong());
+    }
+
+    @Test void successfulShortcutRecordsBindingAndStaleConfirmationIsRejected() {
+        device(10L);
+        InfoUserOauth bind = new InfoUserOauth(); bind.setId(7L); bind.setUserId(10L);
+        bind.setProvider("line_user"); bind.setProviderUid("X");
+        when(bindings.selectByIdForUpdate(7L)).thenReturn(bind);
+        service.recordOauthLogin("D1", "A", 10L, bind);
+        verify(links).recordUse("D1", 7L);
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            assertThrows(ServiceException.class, () -> service.requireConfirmation("D1", 20L));
+        }
+    }
+}

+ 4 - 11
ruoyi-admin/src/test/java/com/ruoyi/app/user/service/DeviceTrustServiceTest.java

@@ -23,7 +23,7 @@ import static org.mockito.Mockito.when;
 class DeviceTrustServiceTest {
 
     private final InfoUserDeviceMapper mapper = mock(InfoUserDeviceMapper.class);
-    private final DeviceTrustService service = new DeviceTrustService(mapper);
+    private final DeviceTrustService service = com.ruoyi.app.user.DeviceTrustTestSupport.service(mapper, mock(com.ruoyi.system.mapper.InfoUserOauthMapper.class));
 
     // ---------- isValidDeviceId ----------
 
@@ -44,16 +44,9 @@ class DeviceTrustServiceTest {
 
         service.recordLogin("dev-1", "0987654321", 100L);
 
-        verify(mapper).insert(any(InfoUserDevice.class));
-        verify(mapper, never()).updateById(any(InfoUserDevice.class));
-        org.mockito.ArgumentCaptor<InfoUserDevice> captor =
-                org.mockito.ArgumentCaptor.forClass(InfoUserDevice.class);
-        verify(mapper).insert(captor.capture());
-        InfoUserDevice saved = captor.getValue();
-        assertEquals("dev-1", saved.getDeviceId());
-        assertEquals("0987654321", saved.getPhone());
-        assertEquals(100L, saved.getUserId());
-        assertTrue(saved.getCreateTime() != null && saved.getUpdateTime() != null);
+        verify(mapper).ensureExists("dev-1", "0987654321", 100L);
+        verify(mapper).updateById(any(InfoUserDevice.class));
+
     }
 
     // ---------- recordLogin:同设备换号覆盖 ----------

+ 6 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUser.java

@@ -88,6 +88,12 @@ public class InfoUser
     @Excel(name = "删除标志", readConverterExp = "0=代表存在,1=代表删除")
     private String delFlag;
 
+    /** 用户主动注销的翻悔期截止时间;为空的历史删除账号不可恢复。 */
+    @com.fasterxml.jackson.annotation.JsonIgnore
+    @TableField(insertStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.NEVER,
+            updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.NEVER)
+    private Date cancellationDeadlineAt;
+
     /** 会员标识 */
     @Excel(name = "会员标识")
     private transient String vip;

+ 19 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserOauthDevice.java

@@ -0,0 +1,19 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.util.Date;
+
+/** 设备成功使用的第三方绑定;同一绑定可在多台设备使用,但仍只属于一个用户。 */
+@Data
+@TableName("info_user_oauth_device")
+public class InfoUserOauthDevice {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String deviceId;
+    private Long oauthId;
+    private Date createTime;
+    private Date updateTime;
+}

+ 12 - 1
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserDeviceMapper.java

@@ -4,10 +4,21 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.InfoUserDevice;
 
 /**
- * 设备-手机号信任 Mapper(仅用 MyBatis-Plus CRUD,无自定义查询、无 XML)。
+ * 设备-手机号信任 Mapper(CRUD、幂等初始化与事务行锁)。
  *
  * @author foodie
  * @date 2026-09-20
  */
 public interface InfoUserDeviceMapper extends BaseMapper<InfoUserDevice> {
+    /** 并发首次登录先幂等建行;已有行不得覆盖手机号,随后读取旧值再判断换号。 */
+    @org.apache.ibatis.annotations.Insert("INSERT INTO info_user_device(device_id,phone,user_id,create_time,update_time) "
+            + "VALUES(#{deviceId},#{phone},#{userId},NOW(),NOW()) ON DUPLICATE KEY UPDATE device_id=device_id")
+    int ensureExists(@org.apache.ibatis.annotations.Param("deviceId") String deviceId,
+                     @org.apache.ibatis.annotations.Param("phone") String phone,
+                     @org.apache.ibatis.annotations.Param("userId") Long userId);
+
+    @org.apache.ibatis.annotations.Select("SELECT device_id,phone,user_id,create_time,update_time "
+            + "FROM info_user_device WHERE device_id=#{deviceId} FOR UPDATE")
+    InfoUserDevice selectForUpdate(@org.apache.ibatis.annotations.Param("deviceId") String deviceId);
+
 }

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserMapper.java

@@ -19,6 +19,9 @@ import org.apache.ibatis.annotations.Select;
  */
 public interface InfoUserMapper  extends BaseMapper<InfoUser>
 {
+    InfoUser selectCancellationUserForUpdate(@Param("userId") Long userId);
+    int requestUserCancellation(@Param("userId") Long userId, @Param("deadline") Date deadline);
+    int restoreCancelledUser(@Param("userId") Long userId, @Param("now") Date now);
    public int getUserSum(@Param("userType") String userType, @Param("startDate") String startDate,@Param("endDate") String endDate);  //反回结果
     /**
      * 查询用户信息

+ 19 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserOauthDeviceMapper.java

@@ -0,0 +1,19 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.InfoUserOauthDevice;
+import org.apache.ibatis.annotations.*;
+
+public interface InfoUserOauthDeviceMapper extends BaseMapper<InfoUserOauthDevice> {
+    /** 调用方已锁定 OAuth 绑定后,再以当前读确认本设备是否实际使用过。 */
+    @Select("SELECT id FROM info_user_oauth_device WHERE device_id=#{deviceId} AND oauth_id=#{oauthId} FOR UPDATE")
+    Long selectDeviceBindingForUpdate(@Param("deviceId") String deviceId, @Param("oauthId") Long oauthId);
+
+    /** 绑定是全局记录,删除时同步移除所有设备上的引用。 */
+    @Delete("DELETE FROM info_user_oauth_device WHERE oauth_id=#{oauthId}")
+    int deleteByOauthId(@Param("oauthId") Long oauthId);
+
+    @Insert("INSERT INTO info_user_oauth_device(device_id,oauth_id,create_time,update_time) "
+            + "VALUES(#{deviceId},#{oauthId},NOW(),NOW()) ON DUPLICATE KEY UPDATE update_time=NOW()")
+    int recordUse(@Param("deviceId") String deviceId, @Param("oauthId") Long oauthId);
+}

+ 15 - 1
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserOauthMapper.java

@@ -4,10 +4,24 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.ruoyi.system.domain.InfoUserOauth;
 
 /**
- * 三方账号绑定 Mapper(仅用 MyBatis-Plus CRUD,无自定义查询、无 XML)。
+ * 三方账号绑定 Mapper(CRUD 及用户端设备解绑的事务锁定查询)。
  *
  * @author foodie
  * @date 2026-07-30
  */
 public interface InfoUserOauthMapper extends BaseMapper<InfoUserOauth> {
+    /** 先按主键锁旧用户的用户端绑定,再检查设备关联,避免跨设备解绑时关联行与绑定行锁顺序相反。 */
+    @org.apache.ibatis.annotations.Select("SELECT id FROM info_user_oauth WHERE user_id=#{userId} "
+            + "AND provider IN ('apple','google','line','line_user') ORDER BY id FOR UPDATE")
+    java.util.List<Long> selectUserBindingIdsForUpdate(@org.apache.ibatis.annotations.Param("userId") Long userId);
+
+    @org.apache.ibatis.annotations.Select("SELECT id,user_id,provider,provider_uid,provider_display_name,create_time "
+            + "FROM info_user_oauth WHERE id=#{id} FOR UPDATE")
+    InfoUserOauth selectByIdForUpdate(@org.apache.ibatis.annotations.Param("id") Long id);
+
+    @org.apache.ibatis.annotations.Delete("DELETE FROM info_user_oauth WHERE id=#{id} AND user_id=#{userId} "
+            + "AND provider IN ('apple','google','line','line_user')")
+    int deleteOwnedUserBinding(@org.apache.ibatis.annotations.Param("id") Long id,
+                              @org.apache.ibatis.annotations.Param("userId") Long userId);
+
 }

+ 1 - 1
ruoyi-system/src/main/java/com/ruoyi/system/utils/Auth.java

@@ -17,7 +17,7 @@ public @interface Auth {
 
     /**
      * v2 商家端接口的注解仍在声明;单设备登录(2026-09-22)上线后 Redis 会话
-     * 校验已对全部新格式 token 在 AuthAspect 统一强制,此属性仅作标注保留,不再单独生效。
+     * 校验由 AuthAspect 对所有 token 统一强制,此属性仅作标注保留,不再单独生效。
      */
     boolean session() default false;
 }

+ 2 - 6
ruoyi-system/src/main/java/com/ruoyi/system/utils/AuthAspect.java

@@ -70,12 +70,8 @@ public class AuthAspect {
 
             Object jtiValue = claims.get("jti");
             String jti = jtiValue instanceof String ? (String) jtiValue : null;
-            // 单设备登录(2026-09-22,与 cte_server 同构):登录时按 tokenKey+userId:* 删除旧会话,
-            // 此处强制新格式会话存在,使被顶号/登出/撤销的设备下一个请求即 401 掉线。
-            // 会话 jti 形如 "qtw_tokens:user:1:uuid"(含冒号);历史无冒号 jti 的旧 token 未写会话,
-            // 放行至 15 天自然过期淘汰,避免升级瞬间误伤存量登录。
-            if (jti != null && !jti.isBlank() && jti.contains(":")
-                    && !Boolean.TRUE.equals(redisCache.hasKey(jti))) {
+            // 旧无会话 token 已淘汰:所有受保护接口都必须存在有效 Redis 会话。
+            if (jti == null || jti.isBlank() || !Boolean.TRUE.equals(redisCache.hasKey(jti))) {
                 return sessionExpiredResponse(methodSignature);
             }
 

+ 17 - 2
ruoyi-system/src/main/resources/mapper/infouser/InfoUserMapper.xml

@@ -4,6 +4,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.ruoyi.system.mapper.InfoUserMapper">
 
+    <!-- 普通用户注销与认证恢复共用同一行锁;到期已是删除状态,无需定时任务才能生效。 -->
+    <select id="selectCancellationUserForUpdate" resultMap="InfoUserResult">
+        SELECT * FROM info_user WHERE user_id = #{userId} FOR UPDATE
+    </select>
+    <update id="requestUserCancellation">
+        UPDATE info_user SET del_flag='1', cancellation_deadline_at=#{deadline}
+        WHERE user_id=#{userId} AND user_type='0' AND status='0' AND del_flag='0'
+    </update>
+    <update id="restoreCancelledUser">
+        UPDATE info_user SET del_flag='0', cancellation_deadline_at=NULL
+        WHERE user_id=#{userId} AND user_type='0' AND status='0' AND del_flag='1'
+          AND cancellation_deadline_at &gt; #{now}
+    </update>
+
     <resultMap type="InfoUser" id="InfoUserResult">
         <result property="userId"    column="user_id"    />
         <result property="userName"    column="user_name"    />
@@ -19,6 +33,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="subaccountStatus"    column="subaccount_status"    />
         <result property="lastLoginAt"    column="last_login_at"    />
         <result property="delFlag"    column="del_flag"    />
+        <result property="cancellationDeadlineAt" column="cancellation_deadline_at" />
         <result property="cid"    column="cid"    />
         <result property="cidType"    column="cid_type"    />
         <result property="mycode"    column="mycode"    />
@@ -279,11 +294,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </update>
 
     <update id="deleteInfoUserByUserId" parameterType="Long">
-        update info_user SET del_flag=1 where user_id = #{userId}
+        update info_user SET del_flag=1, cancellation_deadline_at=NULL where user_id = #{userId}
     </update>
 
     <update id="deleteInfoUserByUserIds" parameterType="String">
-        update info_user SET del_flag=1 where user_id in
+        update info_user SET del_flag=1, cancellation_deadline_at=NULL where user_id in
         <foreach item="userId" collection="array" open="(" separator="," close=")">
             #{userId}
         </foreach>

+ 34 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/UserCancellationMapperTest.java

@@ -0,0 +1,34 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.ruoyi.system.domain.InfoUser;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.junit.jupiter.api.Test;
+import java.util.Date;
+import java.util.Map;
+import static org.junit.jupiter.api.Assertions.*;
+
+class UserCancellationMapperTest {
+    @Test void lifecycleSqlKeepsRoleStatusAndDeadlineGuards() throws Exception {
+        MybatisConfiguration config = new MybatisConfiguration();
+        config.getTypeAliasRegistry().registerAlias("InfoUser", InfoUser.class);
+        String resource = "mapper/infouser/InfoUserMapper.xml";
+        try (var input = getClass().getClassLoader().getResourceAsStream(resource)) {
+            new XMLMapperBuilder(input, config, resource, config.getSqlFragments()).parse();
+        }
+        String prefix = "com.ruoyi.system.mapper.InfoUserMapper.";
+        var params = Map.of("userId", 1L, "now", new Date(), "deadline", new Date());
+        String restore = config.getMappedStatement(prefix + "restoreCancelledUser").getBoundSql(params).getSql();
+        assertTrue(restore.contains("user_type='0'"));
+        assertTrue(restore.contains("status='0'"));
+        assertTrue(restore.contains("del_flag='1'"));
+        assertTrue(restore.contains("cancellation_deadline_at >"));
+        assertTrue(restore.contains("cancellation_deadline_at=NULL"));
+        assertTrue(config.getMappedStatement(prefix + "selectCancellationUserForUpdate")
+                .getBoundSql(params).getSql().contains("FOR UPDATE"));
+        assertTrue(config.getMappedStatement(prefix + "deleteInfoUserByUserId")
+                .getBoundSql(params).getSql().contains("cancellation_deadline_at=NULL"));
+        assertTrue(config.getResultMap(prefix + "InfoUserResult").getResultMappings().stream()
+                .anyMatch(m -> "cancellationDeadlineAt".equals(m.getProperty())));
+    }
+}

+ 7 - 15
ruoyi-system/src/test/java/com/ruoyi/system/utils/AuthAspectTest.java

@@ -25,7 +25,7 @@ import static org.mockito.Mockito.when;
 /**
  * 单设备登录(2026-09-22)会话校验切面测试:
  * 新格式会话 token(jti 含冒号)必须存在 Redis 会话,被顶号/登出删除后即 401;
- * 历史无冒号 jti(旧无会话重载签发)放行至自然过期;无 jti 的手工 token 同样按历史处理。
+ * 无 Redis 会话的旧 token 和无 jti token 均拒绝。
  */
 class AuthAspectTest {
 
@@ -83,33 +83,25 @@ class AuthAspectTest {
     }
 
     @Test
-    void legacyNoColonJtiPassesWithoutRedisLookup() throws Throwable {
+    void legacyNoColonJtiWithoutSessionIsRejected() throws Throwable {
         // 历史无会话重载 JwtUtil.token(id, userName) 签发:jti 为纯 UUID,不含冒号
         prepareInvocation("endpoint", JwtUtil.token("936", "user"));
-        Object expected = new Object();
-        when(joinPoint.proceed()).thenReturn(expected);
-
         Object actual = aspect.around(joinPoint);
-
-        assertSame(expected, actual);
-        verify(redisCache, never()).hasKey(anyString());
-        verify(request).setAttribute(AuthContext.USER_ID_ATTRIBUTE, 936L);
+        assertEquals(401, ((AjaxResult) actual).get("code"));
+        verify(joinPoint, never()).proceed();
     }
 
     @Test
-    void tokenWithoutJtiTreatedAsLegacyAndProceeds() throws Throwable {
+    void tokenWithoutJtiIsRejected() throws Throwable {
         String token = JWT.create()
                 .withClaim("id", "936")
                 .withExpiresAt(new Date(System.currentTimeMillis() + 60_000))
                 .sign(Algorithm.HMAC256("TEST-AUTH-TOKEN"));
         prepareInvocation("endpoint", token);
-        Object expected = new Object();
-        when(joinPoint.proceed()).thenReturn(expected);
-
         Object actual = aspect.around(joinPoint);
-
-        assertSame(expected, actual);
+        assertEquals(401, ((AjaxResult) actual).get("code"));
         verify(redisCache, never()).hasKey(anyString());
+        verify(joinPoint, never()).proceed();
     }
 
     @Test

+ 25 - 0
specs/029-device-trust-phone-bind/contracts/api-contract.md

@@ -1,5 +1,7 @@
 # 前端接口契约:设备信任免绑手机号(uni-app 用户端)
 
+> 2026-10-30 增量见文末:同设备换手机号解绑。五字段数据库方案已确认;后端已实现,用户已确认测试库 SQL 执行完成;数据库并发及 App 联调待执行;涉及换号及已有绑定直登的旧描述以增量节为准。
+
 **Date**: 2026-09-20 | **Spec**: [spec.md](../spec.md) | **后端分支**: `029-device-trust-phone-bind`
 
 > 本文档是后端对前端的唯一契约。前端按此实施,无需口头追问。所有接口均为现有接口的**增量**,不传新字段时行为与现网完全一致(老版本 App 无感)。
@@ -88,3 +90,26 @@ https://access.line.me/oauth2/v2.1/authorize?response_type=code&client_id=...&re
 4. 不带 deviceId(模拟老版本)→ 三方新身份登录 → 走 needPhone 原流程。
 5. 等 tempKey 过期(>5 分钟)后点确认 → 收到 `no.oauth.tempkey.expired`。
 6. LINE:authorize state=deviceId → 已信任设备走 deviceConfirm 回跳分支。
+
+## 2026-10-30 增量契约:同设备换号解绑
+
+本节优先于上文的旧换号语义;基础请求字段沿用现有接口,不新增接口。后端代码已实现,用户已确认测试库 SQL 已执行,部署及联调状态待核对。
+
+- 新版本用户 App 的 lodeing、oauthLogin、oauthBindPhone、oauthDeviceConfirm 均提交同一个稳定 deviceId,放在已有 POST body 内;设备 ID 不因退出或换手机号而重建。deviceToken/cid 是推送字段,不能代替 deviceId。LINE 网页回调沿用既有设备传递契约。
+- lodeing:验证成功且手机号从 A 变 B,解除本设备关联的旧用户绑定后,以 B 登录;同号不解绑。
+- oauthBindPhone:验证 B 并绑定登录与手机号登录 B 具有同等效力,允许 B 与设备之前的 A 不同。清理旧绑定后保留本次新绑定。不要求先独立调用 lodeing。
+- oauthLogin:未绑定身份仍返回 needPhone 或 deviceConfirm;已有绑定与本设备当前用户冲突时不直接返回旧用户 token,提示先完成对应手机号验证码登录后重试。不得把旧绑定静默转给其他手机号。
+- oauthDeviceConfirm:确认当前设备手机号,不接受新手机号。用户选择其他手机号时走完整验证码绑定;凭证签发后设备用户已改变则拒绝旧凭证,重新发起 OAuth。
+- 新版本 App 应在换号登录成功后替换本机会话,清理旧账号的本地第三方登录缓存;后端负责解绑服务器记录,不能清除 App 本地状态。
+- 无有效 deviceId 的老版本沿用既有兼容登录,无法保证按设备解绑;不要在前端宣称解绑成功。
+- 成功响应结构保持现有 token/用户信息;新增校验失败沿用项目国际化业务错误包装,错误含义见下表(响应 msg 为国际化文本,key 不作为新增响应字段)。
+- 同账号在设备2登录成功,按现有策略使设备1旧 token 失效;接口401仍按现有方式返回登录页。该行为不等于删除第三方绑定。
+
+| 场景 | 后端消息 key / LINE 回跳 | App 处理 |
+| --- | --- | --- |
+| 第三方已有绑定与设备当前手机号冲突 | `no.oauth.device.phone.changed`;LINE 回跳 `error=phone_login_required` | 引导对应手机号验证码登录,再重新发起 OAuth |
+| 绑定过程中身份已归属于其他用户,或绑定已被删除 | `no.oauth.binding.conflict`;LINE 回跳 `error=fail` | 不接收旧账号会话,提示重新发起登录 |
+| 确认凭证设备或签发时用户不匹配 | `no.oauth.device.mismatch` | 关闭旧确认页,重新发起 OAuth |
+| tempKey 已消费/过期 | `no.oauth.tempkey.expired` | 重新发起 OAuth 获取新 tempKey |
+
+确认缓存新增内部键 `oauth:bind:user:{tempKey}`,值为签发时 userId,TTL 同现有键为5分钟;无此快照的旧确认凭证需重新发起 OAuth。前端不传该 userId。新表未创建前不得部署本次代码。

+ 38 - 0
specs/029-device-trust-phone-bind/data-model.md

@@ -1,5 +1,7 @@
 # Data Model: 设备信任免绑手机号
 
+> 2026-10-30 增量见文末:同设备换手机号解绑。五字段数据库方案已确认;后端已实现,用户已确认测试库 SQL 执行完成;数据库并发及 App 联调待执行;涉及换号及已有绑定直登的旧描述以增量节为准。
+
 **Date**: 2026-09-20 | **Spec**: [spec.md](spec.md)
 
 ## 新增实体:InfoUserDevice(表 `info_user_device`)
@@ -66,3 +68,39 @@ CREATE TABLE info_user_device (
 |---|---|
 | `no.oauth.device.confirm` | deviceConfirm 分支的成功提示语 |
 | `no.oauth.device.mismatch` | 确认接口:tempKey 非设备确认凭证 / dev 键缺失 / deviceId 不匹配 / 设备信任记录不存在 |
+
+## 2026-10-30 新增设备与第三方绑定关联表(五字段结构已确认)
+
+| 表 | 本次变化 | 职责 |
+|---|---|---|
+| info_user_device | 不改字段 | 一台设备最近验证登录的手机号及用户 |
+| info_user_oauth | 不改字段及原有唯一约束 | 一个第三方身份绑定一个用户 |
+| info_user_oauth_device | 新建 | 记录一项现有第三方绑定在哪些设备成功使用过 |
+
+### info_user_oauth_device
+
+| 字段 | 类型 | 约束/用途 |
+|---|---|---|
+| id | BIGINT | 自增主键,沿用项目实体主键风格 |
+| device_id | VARCHAR(64) | 非空,与登录请求和 info_user_device.device_id 使用同一值 |
+| oauth_id | BIGINT | 非空,关联 info_user_oauth.id |
+| create_time | DATETIME | 首次记录成功使用的时间 |
+| update_time | DATETIME | 最近一次成功使用的时间 |
+
+唯一索引 `(device_id, oauth_id)`:同一设备同一绑定只保留一行,后续成功登录更新时间。普通索引 `(oauth_id)`:全局绑定删除后,按绑定 ID 清理全部设备关联。
+
+切换账号以验证成功的 user_id 与设备旧 user_id 比较,手机号使用账号记录中的规范值;不能仅因同一手机号的格式差异误判换号。
+
+不额外保存手机号或 user_id;按 oauth_id 查询现有绑定的 user_id 和 provider,避免绑定归属变化后使用冗余旧数据误删。不新增物理外键,关联完整性由同一事务内的写入/删除维护。表不存第三方 token,也不采集设备型号等信息。
+
+### 事务示例
+
+D1→A,关联行为 (D1, X→A)、(D1, Y→A);B 验证码登录成功后,删除 X→A 与 Y→A 及其全部关联,再更新 D1→B。若本次为 Google Z 绑定 B,则在同一事务内新增 Z→B 和 (D1, Z→B),不能被旧账号清理步骤误删。
+
+同一绑定可有 D1/D2 两行,始终只对应一个 user_id。解绑是删除这一条全局绑定;其他设备以后需重新绑定。设备信任记录不因第三方解绑被全部清空。
+
+### 上线约定
+
+本次仅新增一个表,DDL 见 updatesql/sql.md 的 2026-10-30 / 029 节。开发者计划上线前清理旧绑定,清理范围应区分用户端与商家/骑手渠道;本轮不生成或执行清空数据语句,不回填旧关系。新表从空表开始,之后成功的第三方登录/绑定写入关联。
+
+Redis 确认凭证需要关联签发时 user_id,防止设备换号后旧确认弹窗绑定到新用户;这属于缓存契约调整,不增加 MySQL 字段。

+ 16 - 0
specs/029-device-trust-phone-bind/frontend-guide.md

@@ -1,5 +1,7 @@
 # 前端接口调整说明:同设备三方登录免绑手机号(029)
 
+> 2026-10-30 增量见文末:同设备换手机号解绑。五字段数据库方案已确认;后端已实现,用户已确认测试库 SQL 执行完成;数据库并发及 App 联调待执行;涉及换号及已有绑定直登的旧描述以增量节为准。
+
 **后端分支**: `test`(`cb8721b` 起) | **日期**: 2026-09-20
 
 > 一句话:登录请求多传一个可选字段 `deviceId`;三方登录多一种返回分支 `deviceConfirm`,弹只读确认框后调一个新接口即可,全程免短信。
@@ -101,3 +103,17 @@ https://access.line.me/oauth2/v2.1/authorize?...&state={deviceId}
 4. LINE authorize 的 `state` = `deviceId`,回跳新增 `deviceConfirm=1` 分支
 
 字段与错误码细节见 [contracts/api-contract.md](contracts/api-contract.md)。
+
+## 2026-10-30 调整说明(后端已实现,待部署联调)
+
+以本机最新验证登录的手机号为准,包含手机号验证码登录及第三方验证码绑号。后者输入 B 并验证成功时,即使设备原为 A,也直接按 B 完成登录,不用先单独手机号登录。
+
+新版本每个登录/绑定请求都应携带稳定的 deviceId,字段名和 POST body 结构不变。服务端将解除本设备关联的 A 的旧第三方绑定,保留本次新建的 B 绑定;同号登录不解绑,验证失败不解绑。App 登录成功后更新本机会话及清理旧账号第三方登录缓存。
+
+一键确认仍只能确认当前手机号;可另选“使用其他手机号”进入完整验证码绑定。已有第三方绑定与设备当前账号冲突时,不再直接进入旧账号,应先验证码登录对应手机号;旧确认弹窗在设备换号后失效,需要重新发起登录。具体见 contracts/api-contract.md 最新增量节。
+
+LINE 网页回跳新增 `error=phone_login_required`:提示用户先用对应手机号验证码登录,再重新授权;不要将其当作登录成功。`error=fail` 仍按失败处理。直连接口错误使用现有 code/msg 包装,不新增请求参数。绑定或确认失败后可能需要重新获取 tempKey,不能无限重放旧凭证。
+
+上线前由后端开发者执行五字段新表 SQL;App 保持同设备稳定的 deviceId。后端不会清理 App 本地缓存,需由 App 在换号成功后替换旧会话并清理旧账号授权缓存。
+
+本次 App 最小改动:用户已确认 deviceId 稳定、失败提示及 tempKey 重启流程已有实现;第三方接口已传相同 deviceId 的前提下,仅需在 lodeing 的 body 补传 deviceId,不增加解绑接口调用。

+ 33 - 0
specs/029-device-trust-phone-bind/plan.md

@@ -1,5 +1,7 @@
 # Implementation Plan: 设备信任免绑手机号
 
+> 2026-10-30 增量见文末:同设备换手机号解绑。五字段数据库方案已确认;后端已实现,用户已确认测试库 SQL 执行完成;数据库并发及 App 联调待执行;涉及换号及已有绑定直登的旧描述以增量节为准。
+
 **Branch**: `029-device-trust-phone-bind` | **Date**: 2026-09-20 | **Spec**: [spec.md](spec.md)
 
 **Input**: Feature specification from `/specs/029-device-trust-phone-bind/spec.md`
@@ -96,3 +98,34 @@ updatesql/sql.md                                                      # 建表 D
 > **Fill ONLY if Constitution Check has violations that must be justified**
 
 无违规项。
+
+## 2026-10-30 增量实施计划(五字段数据库方案已确认)
+
+采用 writing-plans 工作流,沿用本 spec-kit 目录。按已确认的 id、device_id、oauth_id、create_time、update_time 五字段实施,保留当前分支;本轮包含后端、测试及文档。
+
+### 数据结构
+
+新增 `info_user_oauth_device`(字段见 data-model.md),已有 `info_user_device`、`info_user_oauth` 不增删字段。以现有 oauth 绑定主键关联,不重复保存手机号、providerUid 或 userId。迁移追加在 updatesql/sql.md 最后,由开发者手动执行。
+
+### 代码调整范围与处理顺序
+
+- `ruoyi-system/.../domain/InfoUserOauthDevice.java` 与 `mapper/InfoUserOauthDeviceMapper.java`:设备使用记录实体、幂等写入、按设备与绑定归属查询、按 oauth_id 批量删除;查询归属时关联 info_user_oauth.user_id,避免冗余快照过期。
+- `ruoyi-system/.../mapper/InfoUserDeviceMapper.java`:提供事务内的设备行锁/并发初始化能力;`InfoUserOauthMapper` 必要时补充按绑定主键与旧用户同时约束的删除。自定义 SQL 放 Mapper 或对应 XML,遵循现有模块边界。
+- `ruoyi-admin/.../user/service/DeviceTrustService.java`:分开“验证手机号后切换账号”与“记录第三方使用”两类调用。不能在所有 recordLogin 调用里无条件删除绑定。先锁设备并读取旧用户,再按主键锁定旧用户的用户端绑定,逐条以当前读核验本设备关联,仅对有关联的绑定清理全局绑定及其关联;最后更新设备状态。
+- `ruoyi-admin/.../user/InfoUserController.java`:手机号新注册/已有账号登录及 oauthBindPhone 均接入切换处理。第三方绑定应完成凭证、验证码及账号校验后,再清理旧绑定并写入新绑定及使用记录。已有绑定并发分支必须核对本次验证用户,不能直接返回旧用户 token。
+- `oauthLogin`、`oauthDeviceConfirm`、`LineCallbackController`:补齐成功登录的设备使用记录;已有绑定与设备当前用户冲突时要求手机号验证;一键确认缓存关联签发时用户,换号后旧凭证失效。首次无设备记录的已有绑定直登可建立信任。
+- 所有 SQL 变更在同一事务内,登录响应只在成功路径签发。统一设备与绑定锁顺序、处理首次设备并发插入;防止并发请求按陈旧归属删除刚建立的新绑定。Redis 临时凭证失效/消费与数据库事务协调,数据库失败不返回成功 token。
+- 新错误使用 MessageUtils 及项目 i18n 资源;补充行为注释;商家/骑手登录渠道排除在本次解绑范围外。
+
+### 验证范围
+
+新增服务/Mapper 定向测试,扩展 DeviceTrustFlowTest、InfoUserControllerTest、LineCallbackDeviceTrustTest;覆盖 spec 增量验收全部场景。JDK21 执行相关测试及 ruoyi-admin 依赖模块打包;事务和锁语义须在开发者建表后做 MySQL 并发集成验证,Mockito 通过不能代替数据库验证。前端只交付契约,App 接入和双设备联调由前端配合。
+
+工作区已有 040 注销翻悔期修改,涉及相同登录文件;实施时基于最新文件合并,不能覆盖其账号恢复/鉴权处理。保留 040 修改,不将其计为 029 实现;用户随后授权两个功能一并提交。
+
+### 实现取舍及最终审查
+
+- 用户行锁 → 设备行锁 → 旧用户的 OAuth 绑定行锁(主键顺序)→ 关联行锁。避免 JOIN 锁定读按设备关联驱动时与跨设备删除形成相反锁顺序;代价是短事务会锁住旧用户其他用户端绑定,但没有本设备关联的一律不删除。
+- 免短信确认的原账号不存在时拒绝并要求重新验证手机号,不复建账号;避免旧设备快照重新建立已失效的身份。
+- Redis 一次性凭证不参与 MySQL 回滚;失败后重新发起 OAuth 获取 tempKey。单元测试验证调用顺序和失败不签发 token,不声称验证了数据库死锁、事务原子性或真实设备会话。
+- 最终独立审查发现上述锁顺序风险并已调整;自动化验证结果见 quickstart.md,数据库并发验证保留人工验收。

+ 43 - 0
specs/029-device-trust-phone-bind/quickstart.md

@@ -1,5 +1,7 @@
 # Quickstart: 设备信任免绑手机号
 
+> 2026-10-30 增量见文末:同设备换手机号解绑。五字段数据库方案已确认;后端已实现,用户已确认测试库 SQL 执行完成;数据库并发及 App 联调待执行;涉及换号及已有绑定直登的旧描述以增量节为准。
+
 **Date**: 2026-09-20 | **Spec**: [spec.md](spec.md) | **契约**: [contracts/api-contract.md](contracts/api-contract.md)
 
 ## 前置条件
@@ -80,3 +82,44 @@ curl -X POST http://localhost:8080/infouser/user/oauthLogin \
 
 - 自动化测试全绿;场景 A/B/C/D 行为与 [contracts/api-contract.md](contracts/api-contract.md) 逐条一致。
 - 老版本兼容:所有不带 deviceId 的请求与改造前行为完全一致。
+
+## 2026-10-30 增量验收清单(数据库及 App 场景待联调)
+
+前置:用户已确认五字段建表方案,待开发者手动创建 info_user_oauth_device;旧绑定清理由开发者另行安排。后端已实现,建表后执行以下真实环境验收。
+
+- 同设备 A→B:分别通过手机号验证码登录、第三方验证码绑号触发,核对旧绑定及全部设备关联删除、新绑定保留、设备用户和返回 token 均为 B。
+- 同号、验证码错误、账号停用、事务回滚:旧绑定保持;无有效 deviceId 的旧版本无设备解绑动作。
+- 多设备同一身份:关联可为多行,第三方身份绑定仍仅一行;全局解绑后其他设备重新绑定。
+- 同账号设备2登录:设备1旧 token 请求返回401,账号及业务数据保留。
+- LINE 回调、直连 OAuth、绑定容错分支、一键确认均覆盖;商家/骑手渠道不参与用户端解绑。
+- MySQL 并发验证设备换号与第三方绑定交错、绑定归属变化、首次设备并发创建;不误删新绑定、不返回旧用户 token;换号前生成的确认凭证必须被拒绝。
+
+本轮已执行后端定向单元测试;数据库迁移、MySQL 并发/回滚、双设备/App 联调尚未执行。最终命令和结果见下方。
+
+### 2026-10-30 实际验证记录
+
+JDK:`C:\Users\99029\.jdks\graalvm-jdk-21.0.12+7.1`,只修改当前命令环境。
+
+```powershell
+mvn -q -pl ruoyi-admin -am '-Dtest=DeviceBindingSwitchTest,DeviceTrustServiceTest,DeviceTrustFlowTest,InfoUserControllerTest,LineCallback*Test,MerchantSessionAuthAnnotationTest' '-Dsurefire.failIfNoSpecifiedTests=false' package
+```
+
+结果:exit 0;130项测试,0失败、0错误、0跳过;ruoyi-admin 及依赖模块打包通过。日志:`ruoyi-admin/target/029-build.log`(本地构建产物,不纳入 Git)。`git diff --check` 通过。
+
+覆盖:手机号/验证码绑定 A→B、只删旧账号本设备关联、同账号不删、错误验证码不解绑、冲突绑定不返回其他账号、过期/换号前确认拒绝、已删除绑定不能复活、LINE 冲突回跳、无设备兼容、商家/骑手回归。加锁顺序回归先在旧流程失败,再随调整通过;这是调用顺序验证,不是真实数据库死锁复现。
+
+未执行:数据库建表、历史绑定清理、MySQL 并发/回滚集成、真实 Apple/Google/LINE 授权及 App 双设备联调、部署。用户随后要求 040 注销翻悔期与 029 一并提交;两个功能按当前工作区组合验证。
+
+后续状态:2026-10-30 用户确认建表 SQL 已在测试环境执行;未由本次工具连接数据库复核。历史绑定清理、MySQL 并发与 App 联调不据此视为完成。
+
+## 2026-10-30 联合提交前验证
+
+用户要求 029 同设备换号解绑与 040 注销翻悔期一并提交。使用 JDK21 执行:
+
+```powershell
+mvn -q -pl ruoyi-admin -am '-Dtest=UserCancellationServiceTest,UserCancellationMapperTest,DeviceBindingSwitchTest,DeviceTrustServiceTest,DeviceTrustFlowTest,InfoUserControllerTest,LineCallback*Test,AuthAspectTest,MerchantSessionAuthAnnotationTest' '-Dsurefire.failIfNoSpecifiedTests=false' package
+```
+
+结果:143 项测试,0 失败、0 错误、0 跳过;package 成功,git diff --cached --check 通过。日志:ruoyi-admin/target/029-040-package.log。
+
+未执行真实 MySQL/Redis 并发集成、App 联调及环境发布。用户已确认 029 关联表 SQL 在测试库执行;040 cancellation_deadline_at 字段迁移是否已执行尚未单独确认。

+ 9 - 0
specs/029-device-trust-phone-bind/research.md

@@ -1,5 +1,7 @@
 # Research: 设备信任免绑手机号
 
+> 2026-10-30 增量见文末:同设备换手机号解绑。五字段数据库方案已确认;后端已实现,用户已确认测试库 SQL 执行完成;数据库并发及 App 联调待执行;涉及换号及已有绑定直登的旧描述以增量节为准。
+
 **Date**: 2026-09-20 | **Status**: 全部决策已与用户确认,无未解决 NEEDS CLARIFICATION
 
 本 feature 的关键决策均在头脑风暴阶段与用户逐条确认,来源为对现有代码(017-oauth-login 实现即 `InfoUserController` / `LineCallbackController` / `OAuthVerifyService`)的实地调查。以下记录决策、理由与被否方案。
@@ -57,3 +59,10 @@
 - **Decision**: `oauthDeviceConfirm` 复用 `oauthBindPhone` 的账号校验:`status != 0` 停用 → `no.user.stop` 错误;get-or-create(`getuser(phone)`,不存在则按现有规则新建 + 建钱包);期间已被绑定的容错直登也保持一致。
 - **Rationale**: 一键确认是绑定流程的"免短信变体",账号语义必须与短信绑定完全一致,防止绕过停用。
 - **Alternatives**: 无(一致性要求)。
+
+## 2026-10-30 追加决策
+
+- 用户确认手机号验证码登录与第三方验证码绑号具有相同的账号选择效力;同设备以最后验证成功的手机号为准,换号直接解绑本设备使用过的旧用户第三方身份。
+- 采用独立关联表而不是 info_user_oauth 增加单个 device_id:同一第三方身份只能绑定一个用户,但同一绑定可以在多台设备使用,单字段会覆盖设备关系。
+- 使用 oauth_id 关联并在清理时读取当前 user_id/provider,不复制手机号或 user_id 到新表;减少归属改变后的陈旧数据。
+- 用户决定上线前自行清除旧绑定,不回填历史设备关系,不增加设备证明流程;数据库 DDL 只登记,等待人工执行。

+ 37 - 0
specs/029-device-trust-phone-bind/spec.md

@@ -1,5 +1,7 @@
 # Feature Specification: 设备信任免绑手机号(同设备三方登录免重复绑定)
 
+> 2026-10-30 增量见文末:同设备换手机号解绑。五字段数据库方案已确认;后端已实现,用户已确认测试库 SQL 执行完成;数据库并发及 App 联调待执行;涉及换号及已有绑定直登的旧描述以增量节为准。
+
 **Feature Branch**: `029-device-trust-phone-bind`
 
 **Created**: 2026-09-20
@@ -108,3 +110,38 @@ LINE 登录走"唤起系统浏览器 → LINE 回调后端 → 302 跳回 App"
 - 范围不含商户端(shanglodeing)与骑手端(syslodeing)登录。
 - 沿用 017-oauth-login 的既有决策:手机号始终是账号主键,InfoUser 上不落三方 ID;万能验证码 8888 等现有测试便利不改动。
 - 设备确认弹窗的 UI 文案与样式由前端实现,后端只提供脱敏手机号数据。
+
+## 2026-10-30 增量:同设备换手机号解除旧第三方绑定
+
+状态:五字段数据库方案已确认,后端已实现;用户已确认测试环境建表 SQL 执行完成,App 联调尚未执行。本节优先于上文涉及“直接覆盖设备信任”或“已有绑定无条件直登”的旧描述。
+
+### 已确认业务规则
+
+- 手机号验证码登录,以及第三方流程输入手机号并通过验证码完成绑定登录,均以本次验证成功的手机号为准。后者不要求先单独使用手机号登录。
+- 同设备从 A 切换到 B:删除该设备确认使用过且当前仍属于旧用户 A 的 Apple、Google、LINE 用户端绑定;更新设备信任到 B。第三方绑定流程同时保留本次新建的“第三方身份 → B”绑定。
+- 同号再次登录不解绑。只输入手机号、验证码错误、账号校验失败或登录事务失败均不得改变旧绑定及设备记录。
+- 第三方绑定仍由 `(provider, provider_uid)` 唯一约束保证一项身份只关联一个用户。设备关联表只记录使用设备,不增加绑定手机号的数量。
+- 删除的是服务器的全局第三方绑定:该身份在其他设备下次也要重新绑定;A 的其他未在本设备使用的绑定保留。删除绑定时清理该绑定在所有设备的关联行。
+- 同一账号新登录使该账号旧 token 失效的现有规则沿用;此规则与设备换号解绑分别处理,不扩大为删除旧账号全部会话。
+- 设备无信任记录且身份未绑定时,输入手机号及验证码完成首次绑定;有信任时可确认当前手机号,也可走完整验证码绑定流程选择其他手机号。一键确认接口本身不接受新手机号。
+- 设备最后一次验证的手机号不能被另一手机号的第三方快捷登录静默覆盖。已有绑定归属不同于设备当前用户时,要求相应手机号验证码登录后重试;不能直接签发旧账号 token。设备无信任记录或归属一致时沿用已绑定快捷登录,并记录使用关联。
+- 用户已决定直接按设备信息处理,不增加设备证明流程。新版本 App 各登录/绑定入口必须提交同一个稳定 `deviceId`;现有接口对缺失/非法 deviceId 的兼容保留,此类请求无法执行按设备解绑。
+- 上线前旧绑定由开发者自行清理;本轮不推断、不回填历史设备使用关系,不提供自动清空旧绑定逻辑。
+
+### 新增需求
+
+- **FR-012**:新增设备与第三方绑定关联表;用户端第三方成功直登、短信绑定、一键确认及 LINE 回调成功路径均应记录对应绑定 ID,重复记录幂等。
+- **FR-013**:手机号登录与第三方短信绑定在覆盖设备信任之前检查旧记录,按“设备 ID + 旧用户 ID + 用户端渠道”精确解绑;绑定当前归属必须在事务内重新校验。
+- **FR-014**:解绑旧绑定、清理关联、建立本次绑定、更新设备记录必须处于同一个数据库事务;失败不得以登录成功响应结束。同设备并发请求应串行处理,已有绑定分支不得绕过手机号验证或签发错误用户 token。
+- **FR-015**:免短信确认凭证应绑定签发时的设备用户;凭证发出后设备已换手机号的,拒绝旧确认凭证并要求重新发起登录,不能用旧弹窗确认新手机号。
+- **FR-016**:仅处理普通用户端 Apple、Google、LINE(兼容旧 line 与 line_user),不清理商家/骑手渠道的绑定。
+
+### 增量验收
+
+1. D1 使用 Apple X 和 Google Y 登录 A,随后用 B 验证码登录:删除 X→A、Y→A,设备变为 B;A 在其他设备单独使用的 LINE Z 绑定保留。
+2. D1 当前为 A,通过新的 Google Y 验证 B 并绑定登录:先清理 D1 对应旧绑定,最后保留 Y→B,设备变为 B。
+3. 同号再次验证、验证码错误、登录失败、缺少 deviceId:分别验证不误删绑定;缺少 deviceId 不可声称已完成设备解绑。
+4. X→A 在 D1、D2 均使用:D1 换号时删除全局 X→A,并清理 X 的全部设备关联,不留下失效关联。
+5. A 已在 D1 登录,D2 首次验证 A 并绑定:不视为 D1 换号;D1 原 token 因现有同账号登录策略失效,账号资料和绑定不因换设备被删除。
+6. 设备当前为 B,第三方身份仍属于 A:不能直接进入 A;成功验证相应手机号后才可切换。
+7. 并发换号、第三方绑定期间归属变化、旧 deviceConfirm 重放:不出现“设备是 B、返回 A token”或误删本次新绑定。

+ 16 - 0
specs/029-device-trust-phone-bind/tasks.md

@@ -1,5 +1,7 @@
 # Tasks: 设备信任免绑手机号
 
+> 2026-10-30 增量见文末:同设备换手机号解绑。五字段数据库方案已确认;后端已实现,用户已确认测试库 SQL 执行完成;数据库并发及 App 联调待执行;涉及换号及已有绑定直登的旧描述以增量节为准。
+
 **Input**: Design documents from `/specs/029-device-trust-phone-bind/`(spec.md / plan.md / research.md / data-model.md / contracts/api-contract.md / quickstart.md)
 
 **Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
@@ -172,3 +174,17 @@ mvn -pl ruoyi-admin -am test -Dtest='XxxTest' -Dsurefire.failIfNoSpecifiedTests=
 - Controller 校验一律 `MessageUtils.message(...)`,禁止硬编码中文错误
 - `oauth:bind:` 现有值格式**不得改动**(`oauthBindPhone` 用 `indexOf('@')` 解析)
 - 不触碰 CLAUDE.md 废弃代码清单中的任何文件
+
+## Phase 7:2026-10-30 同设备换手机号解绑
+
+本节记录本轮交付;真实数据库及 App 验收与后端自动化验证分开记录。
+
+- [x] T024 更新 spec/plan/data-model/research、前端契约及验收清单;将建表 SQL 草案追加到 updatesql/sql.md 末尾。
+- [x] T025 用户确认保留五字段表结构及原索引;后续由开发者安排建表及旧绑定清理,实际执行结果单独记录,本轮未执行 SQL。
+- [x] T026 数据层及服务:新增 InfoUserOauthDevice 实体/Mapper、设备锁和幂等关联、按设备及旧用户归属解绑;编写精确删除、同号不删、失效绑定拒绝和加锁顺序测试;数据库事务回滚另列人工验收。
+- [x] T027 用户验证码登录和 oauthBindPhone 接入同一换号处理,保护本次新绑定;补充新用户、已有用户、失败路径、绑定归属竞争测试,避免已有绑定快捷分支跳过验证。
+- [x] T028 补齐 oauthLogin、oauthDeviceConfirm、LINE 回调的关联记录与手机号冲突校验;确认凭证固定签发时用户,换号后拒绝旧凭证;回归业务端不受影响。
+- [x] T029 使用 JDK21 执行130项定向测试及 ruoyi-admin 依赖模块 package,全部通过;完成独立审查并调整跨设备关联删除的锁顺序。
+- [ ] T030 开发者手动建表及清理旧绑定后,执行 MySQL 并发/事务回滚验证及双设备/App 联调;本轮未连接数据库、未部署。
+
+执行顺序:T025→T026→T027→T028 已实施;T029 自动化已验证,T030 人工验收待执行,不新建分支、不连接数据库。

+ 42 - 0
specs/040-user-cancellation-grace/frontend-app-integration.md

@@ -0,0 +1,42 @@
+# 用户 App:注销与七天翻悔期接入
+
+仅普通用户账号(`userType=0`)适用;商家、骑手不接入此流程。本期未修改 App。
+
+## 申请注销
+
+沿用 `GET /infouser/user/deleuser`,请求头 `token` 必填,无请求体。账号必须有有效 Redis 会话,仍沿用现有未完成订单拦截。
+
+成功响应:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "cancellationDeadlineAt": "2026-11-06 12:00:00",
+    "graceDays": 7
+  }
+}
+```
+
+`cancellationDeadlineAt` 为服务端计算的北京时间(UTC+8),示例日期仅为演示。`graceDays` 默认 7,页面以接口返回值展示,不在客户端另算截止日期。
+
+注销成功后立即清除本地登录态,退出账号。文案说明:在截止时间前重新登录,会撤销注销并恢复原账号;到期后无法恢复原账号。不能把注销成功展示成资料已物理删除。
+
+## 翻悔与登录
+
+无需新增恢复接口或传恢复字段。正常调用原有手机号登录、Apple/Google/LINE 登录、绑定手机号或设备确认接口,身份校验成功后服务端自动恢复,返回原 `userId` 和新 token。App 按原登录成功流程处理即可。
+
+- 必须严格早于截止时间,且账号未被平台停用;到达截止时间即不能恢复。
+- 登录失败、打开页面、刷新 token 和使用原 token 均不会恢复。
+- 注销时原用户会话被撤销,之后使用返回 HTTP 401,业务 `code=401`。恢复后原 token 仍然无效。
+- 历史删除账号没有翻悔截止时间,不能按此流程恢复。
+- 到期账号保留历史业务资料。手机号重新注册沿用现有规则,可能创建新账号;已绑定第三方身份不自动解绑,不能借此恢复过期账号。
+
+## 后端部署与配置
+
+先由开发者手动执行 `updatesql/sql.md` 中 2026-10-30 的 040 迁移,再部署代码。本轮未直接执行数据库迁移。
+
+配置项 `user.cancellation.grace-days` 默认为 7;有效值 1–365,缺失或非法整数范围降级为 7(非数字配置会由 Spring 拒绝启动)。该项仅影响后续注销,已经保存的截止时间不会变化。
+
+账号申请注销时已经设置 `del_flag=1`,到期仅关闭恢复资格,因此无需定时任务再次设置删除状态。平台直接删除会清空翻悔期;平台停用的账号不允许通过登录恢复。

+ 12 - 0
specs/040-user-cancellation-grace/plan.md

@@ -0,0 +1,12 @@
+# 用户注销翻悔期实现计划
+
+1. 梳理注销、手机号、Apple/Google/LINE 已绑定与绑定手机号、设备信任确认等所有用户认证路径及历史 JWT;先编写针对认证成功、失败、截止时间、平台停用和业务端隔离的测试。
+2. 为普通用户增加可空翻悔期截止字段并登记迁移 SQL;限定修改 Entity、Mapper、SQL、注销与认证链路,复用统一的原子状态转换与账号锁。
+3. 注销成功后撤销会话并阻断旧 token;期内只有验证成功的用户账号可原子恢复,严格拒绝到期和历史已注销账号。覆盖注销与登录、恢复与截止时间并发。
+4. 更新 App 接入文档及多语言提示;执行 JDK21 定向测试、受影响模块构建和差异检查。数据库迁移由开发者手动执行。
+
+## 已落实的实现约束
+
+用户确认旧 token 已淘汰,移除 `@Auth` 对无会话旧 JWT 的兼容放行;这项鉴权调整覆盖所有 `@Auth` 接口。`/deleuser` 已加 `@Auth` 会话校验。第三方已绑定查询对用户端开放期内账号,商家/骑手登录与注销业务分支保持原行为。独立 LINE 网页回调同样覆盖恢复;回调捕获异常时回滚登录事务。
+
+前端接入说明见 `frontend-app-integration.md`。错误复用现有国际化 key,本期不新增单一语言提示。

+ 26 - 0
specs/040-user-cancellation-grace/spec.md

@@ -0,0 +1,26 @@
+# 040 用户注销翻悔期
+
+## 用户确认的规则
+
+- 仅用户端普通用户(`user_type=0`):注销时设置 `del_flag=1`,记录截止时间,默认申请时刻起 7 天。
+- 只有截止时间严格大于服务端当前时间,且该账号未被平台停用,身份认证成功后才恢复 `del_flag=0` 并清空截止时间。历史删除账号无截止时间,不能恢复。
+- 期限到达或超过后保持删除状态;刷新 token、旧会话和静默请求不能恢复账号。
+- 其他商家和骑手账号不进入此流程,保留原有行为。
+
+## 必须覆盖的边界
+
+- 注销沿用未完成订单检查;注销须校验有效会话并使现有用户端会话失效。用户确认旧 token 已淘汰,所有 `@Auth` 接口一律要求有效 Redis 会话;不再兼容旧 JWT 放行。
+- 手机号登录、已绑定第三方登录、第三方绑定手机号和设备信任确认各入口需在身份验证成功后统一调用恢复;恢复条件通过数据库原子更新再次验证,避免截止时间竞争、禁用账号被恢复和历史删除账号误恢复。
+- 手机号和第三方身份的查询仅将有未来截止时间的用户纳入候选,不能影响同手机号商家、骑手;多条候选须安全拒绝,避免误恢复或误创建账号。
+- 删除状态与截止时间同时写入同一事务;原会话撤销和新会话签发不得使注销并发登录时绕过注销状态。
+- 现有第三方绑定唯一索引保留;过期后的旧绑定如何解除和重新注册是独立问题,本期不能擅自删除身份绑定或历史业务资料。
+- 数据库调整写入 `updatesql/sql.md`,由开发者手动执行;补充用户 App 接入文档及相关代码注释。
+
+## 实现边界
+
+- 新字段为 `info_user.cancellation_deadline_at`,仅由注销/恢复专用 SQL 写入;通用实体写入不接收此字段。
+- 截止时间默认 7 天,配置 `user.cancellation.grace-days` 支持 1–365 天,超出范围回退 7 天。
+- 数据库行锁覆盖用户登录签发会话与注销;原子恢复 SQL 再校验角色、停用状态、删除状态及截止时间。
+- 平台直接删除会清空截止时间,不能被当成主动注销恢复。
+- 不物理删除账号、历史订单、钱包或第三方绑定;不新增定时清理任务。
+- 未完成订单检查沿用已有逻辑,本期不扩展订单业务;原有短信及设备信任认证策略保持现状。

+ 39 - 0
specs/040-user-cancellation-grace/tasks.md

@@ -0,0 +1,39 @@
+# 040 进度
+
+状态:已验证(本地代码与定向检查)。当前分支 `test-202609v2`;本轮与 029 一并提交;尚未发布。
+
+- [x] 确认主动注销用 `del_flag=1`,平台停用用 `status=1`;仅普通用户获得翻悔期。
+- [x] 按用户确认移除旧 token 无 Redis 会话的兼容放行,所有 `@Auth` 接口要求有效会话;失效返回 HTTP/业务 401。
+- [x] 新增可空 `cancellation_deadline_at`,补齐实体、Mapper 映射与专用 SQL;通用实体更新不能写入截止时间。
+- [x] 注销记录默认 7 天截止时间、撤销用户会话,重复申请不得延长;保留已有未完成订单检查。
+- [x] 手机号、Apple/Google/LINE 已绑定登录、第三方绑定手机号、设备确认及独立 LINE 网页回调覆盖恢复。
+- [x] 数据库行锁覆盖注销、登录与 token 刷新;恢复 SQL 再校验角色、停用/删除状态、截止时间。会话签发失败抛错回滚;LINE 回调捕获异常也回滚。
+- [x] 到期、历史无截止时间及平台停用账号禁止恢复;平台直接删除清空截止时间。
+- [x] 补充业务注释、`spec.md`、`plan.md`、迁移 SQL 及 `frontend-app-integration.md`;未修改 App。
+- [x] JDK21 定向测试 121 项通过,受影响模块 Maven package 成功,`git diff --check` 通过。
+
+## 验证记录(2026-10-30)
+
+使用本机 GraalVM JDK21,执行:
+
+```powershell
+mvn -q -pl ruoyi-admin -am '-Dtest=UserCancellationServiceTest,UserCancellationMapperTest,InfoUserControllerTest,DeviceTrustFlowTest,LineCallbackControllerTest,LineCallbackDeviceTrustTest,AuthAspectTest,MerchantSessionAuthAnnotationTest' '-Dsurefire.failIfNoSpecifiedTests=false' package
+```
+
+8 个测试类共 121 项,失败 0、错误 0、跳过 0。Mapper 测试实际解析 MyBatis XML 并检查账号锁、角色/停用/截止时间条件和平台删除清理。业务测试覆盖七天、截止时刻、历史删除、停用、非用户、重复申请、短信失败、各恢复入口、刷新不恢复与旧会话拒绝。
+
+构建日志:`ruoyi-admin/target/grace-package.log`;产物:`ruoyi-admin/target/ruoyi-admin.jar`。
+
+未执行:真实 MySQL/Redis 并发集成、用户 App 联调及环境发布。迁移 SQL 仅登记在 `updatesql/sql.md`,须由开发者手动执行后再部署。没有依赖定时任务的到期操作。
+
+## 2026-10-30 联合提交前验证
+
+用户要求 029 同设备换号解绑与 040 注销翻悔期一并提交。使用 JDK21 执行:
+
+```powershell
+mvn -q -pl ruoyi-admin -am '-Dtest=UserCancellationServiceTest,UserCancellationMapperTest,DeviceBindingSwitchTest,DeviceTrustServiceTest,DeviceTrustFlowTest,InfoUserControllerTest,LineCallback*Test,AuthAspectTest,MerchantSessionAuthAnnotationTest' '-Dsurefire.failIfNoSpecifiedTests=false' package
+```
+
+结果:143 项测试,0 失败、0 错误、0 跳过;package 成功,git diff --cached --check 通过。日志:ruoyi-admin/target/029-040-package.log。
+
+未执行真实 MySQL/Redis 并发集成、App 联调及环境发布。用户已确认 029 关联表 SQL 在测试库执行;040 cancellation_deadline_at 字段迁移是否已执行尚未单独确认。

+ 27 - 0
updatesql/sql.md

@@ -2473,3 +2473,30 @@ ALTER TABLE order_checkout_refund_intent CONVERT TO CHARACTER SET utf8mb4 COLLAT
 ALTER TABLE pos_order
     ADD COLUMN im_group_id VARCHAR(200) DEFAULT NULL COMMENT '订单IM群聊标识' AFTER dd_id;
 ```
+
+## 2026-10-30 用户注销翻悔期(040)
+
+由开发者在部署相关后端代码前手动执行。历史账号默认为 NULL,不自动赋予翻悔期。
+
+```sql
+ALTER TABLE info_user ADD COLUMN cancellation_deadline_at DATETIME DEFAULT NULL COMMENT '用户主动注销翻悔期截止时间';
+```
+
+## 2026-10-30 同设备换手机号解绑第三方账号(029,五字段建表方案已确认)
+
+状态:用户已确认保留 id、device_id、oauth_id、create_time、update_time 五字段;后端已实现;2026-10-30 用户确认本节建表 SQL 已在测试环境执行,未通过本次工具连接复核;其他环境部署前仍需手动建表。只新增关联表,不修改 info_user_device、info_user_oauth 的字段或原有唯一约束。旧绑定由开发者另行清理,本节不执行清空、不回填历史关系。
+
+```sql
+CREATE TABLE info_user_oauth_device (
+  id          BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+  device_id   VARCHAR(64) NOT NULL COMMENT '设备标识,与登录请求deviceId一致',
+  oauth_id    BIGINT NOT NULL COMMENT '关联info_user_oauth.id',
+  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次成功使用时间',
+  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近成功使用时间,由应用更新',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_device_oauth (device_id, oauth_id),
+  KEY idx_oauth_id (oauth_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户端设备与第三方绑定使用关系(029)';
+```
+
+同设备同绑定仅一条记录;同一绑定可关联多台设备,但始终只绑定 info_user_oauth.user_id 指定的一个用户。按设备与旧用户筛选解绑对象,删除全局绑定时同步删除该绑定的所有设备关联,均在后端业务事务内处理。仅适用用户端 Apple/Google/LINE,商家/骑手绑定不在本次清理范围。不新增物理外键,不存储第三方 token 或重复手机号。