Эх сурвалжийг харах

注销冷静期登录改为二次确认后恢复账号

qmj 2 өдөр өмнө
parent
commit
13dc573f46
20 өөрчлөгдсөн 744 нэмэгдсэн , 78 устгасан
  1. 20 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/CancellationLoginContext.java
  2. 83 11
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  3. 13 2
      ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java
  4. 69 7
      ruoyi-admin/src/main/java/com/ruoyi/app/user/UserCancellationService.java
  5. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/CancellationLoginConfirmDto.java
  6. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  7. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  8. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  9. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  10. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  11. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  12. 217 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/CancellationLoginConfirmationTest.java
  13. 36 4
      ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java
  14. 11 4
      ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java
  15. 13 5
      ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java
  16. 133 24
      ruoyi-admin/src/test/java/com/ruoyi/app/user/UserCancellationServiceTest.java
  17. 99 19
      specs/040-user-cancellation-grace/frontend-app-integration.md
  18. 8 0
      specs/040-user-cancellation-grace/plan.md
  19. 13 2
      specs/040-user-cancellation-grace/spec.md
  20. 13 0
      specs/040-user-cancellation-grace/tasks.md

+ 20 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/CancellationLoginContext.java

@@ -0,0 +1,20 @@
+package com.ruoyi.app.user;
+
+import com.ruoyi.system.domain.vo.UserDTO;
+
+/** 身份校验通过后保存的登录上下文;仅服务端写入,不接受客户端直接提交。 */
+public record CancellationLoginContext(Flow flow, Long userId, String phone, String provider,
+                                       String providerUid, String displayName, Device device) {
+    public enum Flow { PHONE, OAUTH, BIND, DEVICE }
+
+    /** 只保留后续登录所需的设备信息,不缓存短信码、密码或第三方授权凭证。 */
+    public record Device(String deviceId, String cid, String cidType, String deviceToken,
+                         String voIPToken, boolean updatePush) {
+        public UserDTO phoneLogin(String phone) {
+            UserDTO dto = new UserDTO();
+            dto.setPhone(phone); dto.setDeviceId(deviceId); dto.setCid(cid); dto.setCidType(cidType);
+            dto.setDeviceToken(deviceToken); dto.setVoIPToken(voIPToken);
+            return dto;
+        }
+    }
+}

+ 83 - 11
ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java

@@ -43,6 +43,9 @@ import com.ruoyi.app.user.dto.InfoUserResetPwdDto;
 import com.ruoyi.app.user.dto.OAuthBindDto;
 import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
+import com.ruoyi.app.user.dto.CancellationLoginConfirmDto;
+import com.ruoyi.app.user.CancellationLoginContext.Flow;
+import com.ruoyi.app.user.CancellationLoginContext.Device;
 import com.ruoyi.app.utils.oauth.OAuthAccountType;
 import com.ruoyi.app.utils.oauth.OAuthIdentity;
 import com.ruoyi.app.utils.oauth.LineOAuthStateService;
@@ -1088,8 +1091,11 @@ public class InfoUserController extends BaseController {
      * @return
      */
     public AjaxResult userLoginUpdateInfo(UserDTO userDTO, InfoUser infoUser) {
-        // 仅在短信验证成功后恢复;普通用户的行锁由 lodeing 外层事务持有至新会话签发。
-        infoUser = recoverCancelledUser(infoUser);
+        AjaxResult confirmation = cancellationPrompt(infoUser, new CancellationLoginContext(Flow.PHONE,
+                infoUser.getUserId(), infoUser.getPhone(), "phone", null, null,
+                new Device(userDTO.getDeviceId(), userDTO.getCid(), userDTO.getCidType(),
+                        userDTO.getDeviceToken(), userDTO.getVoIPToken(), true)));
+        if (confirmation != null) return confirmation;
         DateUtil date = new DateUtil();
         QueryWrapper<InfoUser> query = new QueryWrapper<>();
         query.eq("user_id", infoUser.getUserId());
@@ -1174,8 +1180,13 @@ public class InfoUserController extends BaseController {
             }
             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 && (accountType == null || accountType == OAuthAccountType.USER)) {
+                AjaxResult confirmation = cancellationPrompt(u, new CancellationLoginContext(Flow.OAUTH,
+                        u.getUserId(), u.getPhone(), dto.getProvider(), providerUid, displayName,
+                        new Device(dto.getDeviceId(), dto.getCid(), dto.getCidType(),
+                                dto.getDeviceToken(), dto.getVoIPToken(), true)));
+                if (confirmation != null) return confirmation;
+            }
             if (u == null) {
                 log.warn("[OAuth] 已绑定但账号已停用 userId={}", bind.getUserId());
                 return error(MessageUtils.message("no.user.stop"));
@@ -1326,7 +1337,17 @@ 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) {
+                AjaxResult confirmation = cancellationPrompt(user, new CancellationLoginContext(Flow.BIND,
+                        user.getUserId(), user.getPhone(), provider, providerUid, displayName,
+                        new Device(dto.getDeviceId(), dto.getCid(), dto.getCidType(),
+                                dto.getDeviceToken(), dto.getVoIPToken(), true)));
+                if (confirmation != null) {
+                    redisCache.deleteObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
+                    redisCache.deleteObject(OAUTH_TEMP_USER_PREFIX + dto.getTempKey());
+                    return confirmation;
+                }
+            }
             if (user != null && !"0".equals(user.getStatus())) {
                 // 停用账号不允许绑定三方(与 lodeing 一致,防止绕过停用)
                 log.warn("[OAuth] 账号已停用 phone={}", maskPhone(phone));
@@ -1415,9 +1436,18 @@ public class InfoUserController extends BaseController {
         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());
+        AjaxResult confirmation = cancellationPrompt(user, new CancellationLoginContext(Flow.DEVICE,
+                user.getUserId(), user.getPhone(), provider, providerUid, displayName,
+                new Device(dto.getDeviceId(), null, null, null, null, false)));
+        if (confirmation != null) {
+            if (!redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey()))
+                throw new ServiceException(MessageUtils.message("no.oauth.tempkey.expired"));
+            redisCache.deleteObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
+            redisCache.deleteObject(OAUTH_TEMP_USER_PREFIX + dto.getTempKey());
+            return confirmation;
+        }
+        if (!canOauthLogin(OAuthAccountType.USER, user)) throw new ServiceException(MessageUtils.message("no.user.stop"));
         if (!redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey()))
             throw new ServiceException(MessageUtils.message("no.oauth.tempkey.expired"));
         InfoUserOauth bind = bindVerifiedIdentity(user, provider, providerUid, displayName);
@@ -1493,10 +1523,52 @@ public class InfoUserController extends BaseController {
         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 AjaxResult cancellationPrompt(InfoUser user, CancellationLoginContext context) {
+        if (user == null || !"0".equals(user.getUserType()) || !"1".equals(user.getDelFlag())) return null;
+        return success(userCancellationService.prepareLogin(context));
+    }
+
+    @Anonymous
+    @PostMapping("/confirmCancellationLogin")
+    @Transactional(rollbackFor = Exception.class)
+    public AjaxResult confirmCancellationLogin(@RequestBody CancellationLoginConfirmDto request) {
+        var confirmed = userCancellationService.confirmLogin(request == null ? null : request.confirmationKey());
+        InfoUser user = confirmed.user();
+        CancellationLoginContext context = confirmed.context();
+        Device device = context.device();
+        if (context.flow() == Flow.PHONE) return userLoginUpdateInfo(device.phoneLogin(user.getPhone()), user);
+
+        if (context.flow() == Flow.DEVICE) {
+            deviceTrustService.requireConfirmation(device.deviceId(), user.getUserId());
+        } else if (context.flow() == Flow.BIND) {
+            deviceTrustService.recordLogin(device.deviceId(), user.getPhone(), user.getUserId());
+        }
+        InfoUserOauth bind;
+        if (context.flow() == Flow.OAUTH) {
+            // 已绑定登录不允许在等待确认期间解绑后自动重建关系。
+            bind = infoUserOauthMapper.selectOne(new LambdaQueryWrapper<InfoUserOauth>()
+                    .in(InfoUserOauth::getProvider, OAuthAccountType.bindingProviders(context.provider()))
+                    .eq(InfoUserOauth::getProviderUid, context.providerUid()));
+            if (bind == null || !java.util.Objects.equals(user.getUserId(), bind.getUserId()))
+                throw new ServiceException(MessageUtils.message("no.oauth.binding.conflict"));
+            deviceTrustService.recordOauthLogin(device.deviceId(), user.getPhone(), user.getUserId(), bind);
+            InfoUserOauth locked = bind.getId() == null ? null : infoUserOauthMapper.selectByIdForUpdate(bind.getId());
+            if (locked == null || !java.util.Objects.equals(user.getUserId(), locked.getUserId())
+                    || !java.util.Objects.equals(bind.getProvider(), locked.getProvider())
+                    || !java.util.Objects.equals(context.providerUid(), locked.getProviderUid()))
+                throw new ServiceException(MessageUtils.message("no.oauth.binding.conflict"));
+            refreshOauthDisplayName(locked, context.displayName());
+        } else {
+            bind = bindVerifiedIdentity(user, context.provider(), context.providerUid(), context.displayName());
+            deviceTrustService.recordOauthLogin(device.deviceId(), user.getPhone(), user.getUserId(), bind);
+        }
+        if (device.updatePush()) {
+            user.setCid(device.cid()); user.setCidType(device.cidType());
+            user.setDeviceToken(device.deviceToken()); user.setVoIPToken(device.voIPToken());
+            if (!infoUserService.saveOrUpdate(user)) throw new ServiceException(MessageUtils.message("no.system.error"));
+        }
+        return issueOauthToken(user, context.provider(), context.providerUid());
     }
 
     private boolean canOauthLogin(OAuthAccountType channel, InfoUser user) {

+ 13 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java

@@ -126,8 +126,19 @@ public class LineCallbackController extends BaseController {
                                 .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 (!businessLine && user != null && "0".equals(user.getUserType()) && "1".equals(user.getDelFlag())) {
+                    var confirmation = userCancellationService.prepareLogin(new CancellationLoginContext(
+                            CancellationLoginContext.Flow.OAUTH, user.getUserId(), user.getPhone(), provider,
+                            providerUid, identity.displayName(),
+                            new CancellationLoginContext.Device(state, null, null, null, null, false)));
+                    String deadline = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+                            .withZone(java.time.ZoneOffset.ofHours(8)).format(confirmation.cancellationDeadlineAt().toInstant());
+                    response.sendRedirect(channel.appRedirect() + "?cancellationConfirm=1&confirmationKey="
+                            + enc(confirmation.confirmationKey()) + "&remainingDays=" + confirmation.remainingDays()
+                            + "&cancellationDeadlineAt=" + enc(deadline)
+                            + "&expiresInSeconds=" + confirmation.expiresInSeconds());
+                    return;
+                }
                 if (!canLogin(channel, user)) {
                     response.sendRedirect(channel.appRedirect() + "?error=user_stopped" + stateSuffix);
                     return;

+ 69 - 7
ruoyi-admin/src/main/java/com/ruoyi/app/user/UserCancellationService.java

@@ -9,6 +9,10 @@ import com.ruoyi.system.mapper.InfoUserMapper;
 import com.ruoyi.common.utils.DictUtils;
 import com.ruoyi.common.core.domain.entity.SysDictData;
 import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import com.alibaba.fastjson2.JSON;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import java.time.Instant;
@@ -37,20 +41,78 @@ public class UserCancellationService {
         int graceDays = graceDays();
         Date deadline = Date.from(Instant.now().plus(graceDays, ChronoUnit.DAYS));
         if (users.requestUserCancellation(userId, deadline) != 1) throw unavailable();
+        redis.deleteObject(CYCLE_PREFIX + userId);
         redis.deleteKeys(CacheConstants.USER_TOKEN_KEY + userId + ":*");
         return new CancellationView(deadline, graceDays);
     }
 
+    static final String CONFIRM_PREFIX = "user:cancellation:confirm:";
+    static final String CYCLE_PREFIX = "user:cancellation:cycle:";
+
+    public record LoginConfirmationView(String status, int remainingDays,
+            @com.fasterxml.jackson.annotation.JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+            Date cancellationDeadlineAt, String confirmationKey, int expiresInSeconds) { }
+    public record PendingLogin(CancellationLoginContext context, long cancellationDeadline, long expiresAt, String cycle) { }
+    public record ConfirmedLogin(InfoUser user, CancellationLoginContext context) { }
+
+    /** 调用方必须先完成身份验证;此方法不恢复账号、不签发会话。 */
     @Transactional(rollbackFor = Exception.class)
-    public InfoUser authenticatedLogin(Long userId) {
-        // 登录入口的外层事务持有此锁至签发新会话完成,防止并发注销覆盖恢复。
-        InfoUser user = users.selectCancellationUserForUpdate(userId);
-        requireUser(user);
-        if ("0".equals(user.getDelFlag())) return user;
+    public LoginConfirmationView prepareLogin(CancellationLoginContext context) {
+        InfoUser user = users.selectCancellationUserForUpdate(context.userId());
+        Date now = new Date();
+        if (!canRecover(user, now) || !Objects.equals(user.getPhone(), context.phone())) throw unavailable();
+        long remaining = user.getCancellationDeadlineAt().getTime() - now.getTime();
+        // 旧记录可按需建立周期标识;账号行锁保证并发提示共用同一周期。
+        String cycle = redis.getCacheObject(CYCLE_PREFIX + user.getUserId());
+        if (cycle == null) {
+            cycle = UUID.randomUUID().toString();
+            redis.setCacheObject(CYCLE_PREFIX + user.getUserId(), cycle,
+                    (int) ((remaining + 999L) / 1000L), TimeUnit.SECONDS);
+        }
+        long validMillis = Math.min(300000L, remaining);
+        String key = UUID.randomUUID().toString().replace("-", "");
+        PendingLogin pending = new PendingLogin(context, user.getCancellationDeadlineAt().getTime(),
+                now.getTime() + validMillis, cycle);
+        int expiresInSeconds = (int) ((validMillis + 999L) / 1000L);
+        redis.setCacheObject(CONFIRM_PREFIX + key, JSON.toJSONString(pending), expiresInSeconds, TimeUnit.SECONDS);
+        return new LoginConfirmationView("cancellationConfirm", (int) ((remaining + 86399999L) / 86400000L),
+                user.getCancellationDeadlineAt(), key, expiresInSeconds);
+    }
+
+    /** 必须由外层登录事务覆盖至签发会话完成;Redis凭证一次性消费,失败须重新认证。 */
+    @Transactional(rollbackFor = Exception.class)
+    public ConfirmedLogin confirmLogin(String key) {
+        if (key == null || !key.matches("[0-9a-f]{32}")) throw confirmationExpired();
+        String cached = redis.getCacheObject(CONFIRM_PREFIX + key);
+        if (cached == null) throw confirmationExpired();
+        PendingLogin pending;
+        try {
+            pending = JSON.parseObject(cached, PendingLogin.class);
+        } catch (RuntimeException exception) {
+            throw confirmationExpired();
+        }
+        if (pending == null || pending.context() == null || pending.context().userId() == null)
+            throw confirmationExpired();
+        CancellationLoginContext context = pending.context();
+        InfoUser user = users.selectCancellationUserForUpdate(context.userId());
         Date now = new Date();
-        if (!canRecover(user, now) || users.restoreCancelledUser(userId, now) != 1) throw unavailable();
+        if (now.getTime() >= pending.expiresAt()) throw confirmationExpired();
+        if (!canRecover(user, now)) throw unavailable();
+        if (pending.cycle() == null || !pending.cycle().equals(redis.getCacheObject(CYCLE_PREFIX + user.getUserId())))
+            throw confirmationExpired();
+        if (user.getCancellationDeadlineAt().getTime() != pending.cancellationDeadline()
+                || !Objects.equals(user.getPhone(), context.phone())) throw confirmationExpired();
+        // 两个并发请求即使读到同一缓存,只有成功删除凭证的请求能恢复及签发会话。
+        if (!redis.deleteObject(CONFIRM_PREFIX + key)) throw confirmationExpired();
+        if (users.restoreCancelledUser(user.getUserId(), now) != 1) throw unavailable();
+        // 恢复后其他等待确认的凭证同时失效,避免秒精度截止时间重复时跨申请恢复。
+        redis.deleteObject(CYCLE_PREFIX + user.getUserId());
         user.setDelFlag("0"); user.setCancellationDeadlineAt(null);
-        return user;
+        return new ConfirmedLogin(user, context);
+    }
+
+    private ServiceException confirmationExpired() {
+        return new ServiceException(MessageUtils.message("no.user.cancellation.confirm.expired"));
     }
 
     /** 复用平台字典缓存;后台修改并刷新后对新申请生效,读取不到或非法均默认7天。 */

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/CancellationLoginConfirmDto.java

@@ -0,0 +1,4 @@
+package com.ruoyi.app.user.dto;
+
+/** 用户明确确认恢复并登录;凭证由身份验证成功后的登录响应提供。 */
+public record CancellationLoginConfirmDto(String confirmationKey) { }

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

@@ -537,5 +537,7 @@ rider.income.query.invalid=查詢參數無效,請檢查起止時間(年月
 rider.income.access.denied=僅啟用的騎手帳號可查看收入帳單
 rider.income.not.found=收入帳單不存在
 
+no.user.cancellation.confirm.expired=確認已失效,請重新登入並確認是否恢復帳號
+
 merchant.subaccount.username.required=分管账号用户名不能为空
 merchant.subaccount.username.exists=该用户名已被使用

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

@@ -523,5 +523,7 @@ rider.income.query.invalid=Invalid query. Check start and end times (yyyy-MM-dd
 rider.income.access.denied=Only active rider accounts can view income statements.
 rider.income.not.found=Income statement not found.
 
+no.user.cancellation.confirm.expired=Confirmation has expired. Please sign in again and confirm account recovery.
+
 merchant.subaccount.username.required=Subaccount username is required
 merchant.subaccount.username.exists=This username is already in use

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

@@ -506,5 +506,7 @@ rider.income.query.invalid=ข้อมูลค้นหาไม่ถูก
 rider.income.access.denied=เฉพาะบัญชีไรเดอร์ที่ใช้งานอยู่เท่านั้นที่ดูรายการรายได้ได้
 rider.income.not.found=ไม่พบรายการรายได้
 
+no.user.cancellation.confirm.expired=การยืนยันหมดอายุแล้ว โปรดเข้าสู่ระบบอีกครั้งและยืนยันการกู้คืนบัญชี
+
 merchant.subaccount.username.required=กรุณาระบุชื่อผู้ใช้บัญชีย่อย
 merchant.subaccount.username.exists=ชื่อผู้ใช้นี้ถูกใช้งานแล้ว

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

@@ -61,5 +61,7 @@ rider.income.query.invalid=Tham số không hợp lệ. Vui lòng kiểm tra th
 rider.income.access.denied=Chỉ tài khoản tài xế đang hoạt động mới được xem thu nhập.
 rider.income.not.found=Không tìm thấy bản ghi thu nhập.
 
+no.user.cancellation.confirm.expired=Xác nhận đã hết hạn. Vui lòng đăng nhập lại và xác nhận khôi phục tài khoản.
+
 merchant.subaccount.username.required=Tên đăng nhập tài khoản phụ là bắt buộc
 merchant.subaccount.username.exists=Tên đăng nhập này đã được sử dụng

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

@@ -525,5 +525,7 @@ rider.income.query.invalid=查询参数无效,请检查起止时间(年月
 rider.income.access.denied=仅启用的骑手账号可查看收入账单
 rider.income.not.found=收入账单不存在
 
+no.user.cancellation.confirm.expired=确认已失效,请重新登录并确认是否恢复账号
+
 merchant.subaccount.username.required=分管账号用户名不能为空
 merchant.subaccount.username.exists=该用户名已被使用

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

@@ -524,5 +524,7 @@ rider.income.query.invalid=查詢參數無效,請檢查起止時間(年月
 rider.income.access.denied=僅啟用的騎手帳號可查看收入帳單
 rider.income.not.found=收入帳單不存在
 
+no.user.cancellation.confirm.expired=確認已失效,請重新登入並確認是否恢復帳號
+
 merchant.subaccount.username.required=分管帳號使用者名稱不能為空
 merchant.subaccount.username.exists=此使用者名稱已被使用

+ 217 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/CancellationLoginConfirmationTest.java

@@ -0,0 +1,217 @@
+package com.ruoyi.app.user;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.user.dto.CancellationLoginConfirmDto;
+import com.ruoyi.app.user.dto.OAuthBindDto;
+import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
+import com.ruoyi.app.user.dto.OAuthLoginDto;
+import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.app.utils.oauth.OAuthIdentity;
+import com.ruoyi.app.utils.oauth.OAuthVerifyService;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.domain.model.LoginUserDto;
+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.domain.InfoUserDevice;
+import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.domain.vo.UserDTO;
+import com.ruoyi.system.mapper.InfoUserMapper;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IVipUserService;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.mockito.MockedStatic;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class CancellationLoginConfirmationTest {
+    private final InfoUserController controller = new InfoUserController();
+    private final InfoUserMapper accounts = mock(InfoUserMapper.class);
+    private final IInfoUserService users = mock(IInfoUserService.class);
+    private final RedisCache redis = mock(RedisCache.class);
+    private final DeviceTrustService devices = mock(DeviceTrustService.class);
+    private final InfoUserOauthMapper bindings = mock(InfoUserOauthMapper.class);
+    private final OAuthVerifyService verifier = mock(OAuthVerifyService.class);
+    private final Map<String, String> cache = new HashMap<>();
+    private final InfoUser user = new InfoUser();
+    private final InfoUserOauth binding = new InfoUserOauth();
+    private MockedStatic<MessageUtils> messages;
+    private MockedStatic<JwtUtil> jwt;
+
+    @BeforeEach void setUp() {
+        messages = mockStatic(MessageUtils.class, c -> c.getArgument(0));
+        jwt = mockStatic(JwtUtil.class);
+        jwt.when(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class))).thenReturn("new-session");
+        var request = new MockHttpServletRequest(); request.setRemoteAddr("127.0.0.1");
+        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+        user.setUserId(7L); user.setUserType("0"); user.setStatus("0"); user.setDelFlag("1");
+        user.setPhone("0912345678"); user.setCancellationDeadlineAt(new Date(System.currentTimeMillis() + 3600000));
+        binding.setId(77L); binding.setUserId(7L); binding.setProvider("google"); binding.setProviderUid("verified-id");
+        when(accounts.selectCancellationUserForUpdate(7L)).thenReturn(user);
+        when(accounts.restoreCancelledUser(eq(7L), any())).thenReturn(1);
+        when(users.getOne(any(Wrapper.class))).thenReturn(user);
+        when(users.list(any(Wrapper.class))).thenReturn(List.of(user));
+        when(users.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+        when(bindings.selectOne(any(Wrapper.class))).thenReturn(binding);
+        when(bindings.selectByIdForUpdate(77L)).thenReturn(binding);
+        when(bindings.insert(any(InfoUserOauth.class))).thenReturn(1);
+        when(verifier.verifyIdentity("google", "one-use-code")).thenReturn(new OAuthIdentity("verified-id", "Name"));
+        var trust = new InfoUserDevice(); trust.setDeviceId("device"); trust.setPhone(user.getPhone()); trust.setUserId(7L);
+        when(devices.getTrust("device")).thenReturn(trust);
+        cache.put(user.getPhone(), "123456");
+        cache.put("oauth:bind:original", "google@verified-id@Name");
+        cache.put("oauth:bind:dev:original", "device"); cache.put("oauth:bind:user:original", "7");
+        when(redis.getCacheObject(anyString())).thenAnswer(call -> cache.get(call.getArgument(0)));
+        when(redis.deleteObject(anyString())).thenAnswer(call -> cache.remove(call.getArgument(0)) != null);
+        doAnswer(call -> { cache.put(call.getArgument(0), call.getArgument(1)); return null; })
+                .when(redis).setCacheObject(anyString(), anyString(), anyInt(), eq(TimeUnit.SECONDS));
+        ReflectionTestUtils.setField(controller, "userCancellationService", new UserCancellationService(accounts, redis));
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "redisCache", redis);
+        ReflectionTestUtils.setField(controller, "deviceTrustService", devices);
+        ReflectionTestUtils.setField(controller, "infoUserOauthMapper", bindings);
+        ReflectionTestUtils.setField(controller, "oauthVerifyService", verifier);
+        ReflectionTestUtils.setField(controller, "vipUserService", mock(IVipUserService.class));
+    }
+    @AfterEach void tearDown() { jwt.close(); messages.close(); RequestContextHolder.resetRequestAttributes(); }
+
+    private UserCancellationService.LoginConfirmationView prompt(CancellationLoginContext.Flow flow) {
+        AjaxResult response;
+        switch (flow) {
+            case PHONE -> {
+                UserDTO dto = new UserDTO(); dto.setPhone(user.getPhone()); dto.setCode("123456");
+                dto.setDeviceId("device"); dto.setCid("new-cid");
+                response = controller.lodeing(dto);
+            }
+            case OAUTH -> {
+                OAuthLoginDto dto = new OAuthLoginDto(); dto.setProvider("google"); dto.setCredential("one-use-code");
+                dto.setDeviceId("device"); dto.setCid("new-cid"); response = controller.oauthLogin(dto);
+            }
+            case BIND -> {
+                OAuthBindDto dto = new OAuthBindDto(); dto.setTempKey("original"); dto.setPhone(user.getPhone());
+                dto.setCode("123456"); dto.setDeviceId("device"); dto.setCid("new-cid");
+                response = controller.oauthBindPhone(dto);
+            }
+            default -> {
+                OAuthDeviceConfirmDto dto = new OAuthDeviceConfirmDto(); dto.setTempKey("original"); dto.setDeviceId("device");
+                response = controller.oauthDeviceConfirm(dto);
+            }
+        }
+        assertEquals(200, response.get("code")); assertNull(response.get("token"));
+        var view = (UserCancellationService.LoginConfirmationView) response.get("data");
+        assertEquals("cancellationConfirm", view.status()); assertEquals(1, view.remainingDays());
+        assertEquals("1", user.getDelFlag()); assertNull(user.getCid());
+        verify(accounts, never()).restoreCancelledUser(anyLong(), any());
+        verify(users, never()).saveOrUpdate(any(InfoUser.class));
+        verify(bindings, never()).insert(any(InfoUserOauth.class));
+        verify(bindings, never()).updateById(any(InfoUserOauth.class));
+        verify(devices, never()).recordLogin(any(), any(), any());
+        verify(devices, never()).recordOauthLogin(any(), any(), any(), any());
+        jwt.verifyNoInteractions();
+        return view;
+    }
+
+    @ParameterizedTest @EnumSource(CancellationLoginContext.Flow.class)
+    void everyFlowWaitsForConfirmationAndResumesWithoutReusingOriginalCredential(CancellationLoginContext.Flow flow) {
+        var view = prompt(flow);
+        cache.remove(user.getPhone()); cache.remove("oauth:bind:original");
+        AjaxResult response = controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey()));
+        assertEquals("new-session", response.get("token")); assertSame(user, response.get("data"));
+        assertEquals("0", user.getDelFlag()); assertNull(user.getCancellationDeadlineAt());
+        if (flow != CancellationLoginContext.Flow.DEVICE) assertEquals("new-cid", user.getCid());
+        verify(accounts, times(1)).restoreCancelledUser(eq(7L), any());
+        if (flow == CancellationLoginContext.Flow.OAUTH) verify(verifier, times(1)).verifyIdentity(any(), any());
+        assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
+        jwt.verify(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class)), times(1));
+    }
+
+    @Test void cancellingPromptLeavesLifecycleAndSessionsUnchanged() { prompt(CancellationLoginContext.Flow.PHONE); }
+
+    @Test void failedAuthenticationNeverReachesCancellationPrompt() {
+        UserDTO dto = new UserDTO(); dto.setPhone(user.getPhone()); dto.setCode("wrong");
+        assertEquals(500, controller.lodeing(dto).get("code"));
+        verifyNoInteractions(accounts); jwt.verifyNoInteractions();
+    }
+
+    @Test void removedOauthBindingCannotBeRecreatedByConfirmation() {
+        var view = prompt(CancellationLoginContext.Flow.OAUTH);
+        when(bindings.selectOne(any(Wrapper.class))).thenReturn(null);
+        assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
+        verify(bindings, never()).insert(any(InfoUserOauth.class)); jwt.verifyNoInteractions();
+    }
+
+    @Test void changedTrustedDeviceCannotCompleteConfirmation() {
+        var view = prompt(CancellationLoginContext.Flow.DEVICE);
+        doThrow(new ServiceException("changed")).when(devices).requireConfirmation("device", 7L);
+        assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
+        jwt.verifyNoInteractions();
+    }
+
+    @Test void tokenFailureThrowsAndConfirmationEndpointHasRollbackTransaction() throws Exception {
+        var view = prompt(CancellationLoginContext.Flow.PHONE);
+        jwt.when(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class))).thenReturn(null);
+        assertThrows(ServiceException.class, () -> controller.confirmCancellationLogin(new CancellationLoginConfirmDto(view.confirmationKey())));
+        var annotation = InfoUserController.class.getMethod("confirmCancellationLogin", CancellationLoginConfirmDto.class)
+                .getAnnotation(Transactional.class);
+        assertNotNull(annotation); assertTrue(List.of(annotation.rollbackFor()).contains(Exception.class));
+    }
+
+    @ParameterizedTest
+    @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
+    void databaseRecoveryCommitsOnlyWhenLoginSessionSucceeds(boolean failSession) {
+        var source = new org.h2.jdbcx.JdbcDataSource();
+        source.setURL("jdbc:h2:mem:" + java.util.UUID.randomUUID() + ";DB_CLOSE_DELAY=-1");
+        var jdbc = new org.springframework.jdbc.core.JdbcTemplate(source);
+        jdbc.execute("CREATE TABLE cancellation_account (id BIGINT PRIMARY KEY, deleted VARCHAR(1), deadline TIMESTAMP)");
+        jdbc.update("INSERT INTO cancellation_account VALUES (7, '1', ?)", user.getCancellationDeadlineAt());
+        when(accounts.selectCancellationUserForUpdate(7L)).thenAnswer(call -> jdbc.queryForObject(
+                "SELECT deleted, deadline FROM cancellation_account WHERE id=7 FOR UPDATE", (row, index) -> {
+                    InfoUser account = new InfoUser(); account.setUserId(7L); account.setPhone(user.getPhone());
+                    account.setUserType("0"); account.setStatus("0"); account.setDelFlag(row.getString(1));
+                    account.setCancellationDeadlineAt(row.getTimestamp(2)); return account;
+                }));
+        when(accounts.restoreCancelledUser(eq(7L), any())).thenAnswer(call -> jdbc.update(
+                "UPDATE cancellation_account SET deleted='0', deadline=NULL WHERE id=7 AND deleted='1' AND deadline>?",
+                call.getArgument(1, Date.class)));
+        var view = prompt(CancellationLoginContext.Flow.PHONE);
+        when(users.getOne(any(Wrapper.class))).thenAnswer(call -> accounts.selectCancellationUserForUpdate(7L));
+        var transactionManager = new org.springframework.jdbc.datasource.DataSourceTransactionManager(source);
+        var interceptor = new org.springframework.transaction.interceptor.TransactionInterceptor(transactionManager,
+                new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource());
+        var factory = new org.springframework.aop.framework.ProxyFactory(controller);
+        factory.setProxyTargetClass(true); factory.addAdvice(interceptor);
+        var transactionalController = (InfoUserController) factory.getProxy();
+        if (failSession) {
+            jwt.when(() -> JwtUtil.setToken(anyString(), any(LoginUserDto.class))).thenReturn(null);
+            assertThrows(ServiceException.class, () -> transactionalController.confirmCancellationLogin(
+                    new CancellationLoginConfirmDto(view.confirmationKey())));
+            assertEquals("1", jdbc.queryForObject("SELECT deleted FROM cancellation_account WHERE id=7", String.class));
+            assertNotNull(jdbc.queryForObject("SELECT deadline FROM cancellation_account WHERE id=7", Date.class));
+        } else {
+            assertEquals("new-session", transactionalController.confirmCancellationLogin(
+                    new CancellationLoginConfirmDto(view.confirmationKey())).get("token"));
+            assertEquals("0", jdbc.queryForObject("SELECT deleted FROM cancellation_account WHERE id=7", String.class));
+            assertNull(jdbc.queryForObject("SELECT deadline FROM cancellation_account WHERE id=7", Date.class));
+        }
+    }
+}

+ 36 - 4
ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java

@@ -60,13 +60,12 @@ class DeviceTrustFlowTest {
         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;
-            });
+            when(cancellationService.prepareLogin(any())).thenReturn(new UserCancellationService.LoginConfirmationView(
+                    "cancellationConfirm", 1, account.getCancellationDeadlineAt(), "a".repeat(32), 60));
         }
     }
     private void verifyRecovery(boolean pending) {
-        if (pending) verify(cancellationService).authenticatedLogin(100L);
+        if (pending) verify(cancellationService).prepareLogin(argThat(context -> context.userId().equals(100L)));
         else verifyNoInteractions(cancellationService);
     }
 
@@ -323,6 +322,17 @@ class DeviceTrustFlowTest {
         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"));
+        if (pending) {
+            assertNull(result.get("token"));
+            assertEquals("cancellationConfirm", ((UserCancellationService.LoginConfirmationView) result.get("data")).status());
+            assertEquals("1", existing.getDelFlag());
+            verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
+            verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+            verify(deviceMapper, never()).updateById(any(InfoUserDevice.class));
+            verifyRecovery(true);
+            return;
+        }
+
 
         assertEquals(200, result.get("code"));
         assertNotNull(result.get("token"));
@@ -393,6 +403,17 @@ class DeviceTrustFlowTest {
         dto.setCode("8888");
         dto.setDeviceId("dev-1");
         AjaxResult result = controller.oauthBindPhone(dto);
+        if (pending) {
+            assertNull(result.get("token"));
+            assertEquals("cancellationConfirm", ((UserCancellationService.LoginConfirmationView) result.get("data")).status());
+            assertEquals("1", existing.getDelFlag());
+            verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
+            verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+            verify(deviceMapper, never()).updateById(any(InfoUserDevice.class));
+            verifyRecovery(true);
+            return;
+        }
+
 
         assertEquals(200, result.get("code"));
         assertNotNull(result.get("token"));
@@ -420,6 +441,17 @@ class DeviceTrustFlowTest {
         dto.setCredential("cred-3");
         dto.setDeviceId("dev-1");
         AjaxResult result = controller.oauthLogin(dto);
+        if (pending) {
+            assertNull(result.get("token"));
+            assertEquals("cancellationConfirm", ((UserCancellationService.LoginConfirmationView) result.get("data")).status());
+            assertEquals("1", existing.getDelFlag());
+            verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
+            verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+            verify(deviceMapper, never()).updateById(any(InfoUserDevice.class));
+            verifyRecovery(true);
+            return;
+        }
+
 
         assertEquals(200, result.get("code"));
         assertNotNull(result.get("token"));

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

@@ -64,6 +64,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockConstruction;
 import static org.mockito.Mockito.mockStatic;
@@ -1004,7 +1005,7 @@ class InfoUserControllerTest {
 
     @ParameterizedTest
     @CsvSource({"apple", "google", "line", "line_user"})
-    void verifiedBoundUserRestoresGraceAccountBeforeIssuingToken(String provider) {
+    void verifiedBoundUserRequiresConfirmationBeforeIssuingToken(String provider) {
         OAuthLoginDto request = new OAuthLoginDto();
         request.setProvider(provider); request.setCredential("verified-credential");
         when(oauthVerifyService.verifyIdentity(provider, "verified-credential"))
@@ -1015,13 +1016,19 @@ class InfoUserControllerTest {
         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; });
+        var view = new UserCancellationService.LoginConfirmationView("cancellationConfirm", 1,
+                new java.util.Date(System.currentTimeMillis() + 60000), "a".repeat(32), 60);
+        when(cancellation.prepareLogin(any())).thenReturn(view);
 
         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());
+        verify(cancellation).prepareLogin(argThat(context -> context.userId().equals(72L)
+                && context.provider().equals(provider) && context.providerUid().equals("provider-uid")));
+        assertEquals(view, result.get("data"));
+        assertEquals("1", user.getDelFlag());
+        verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
+        assertNull(result.get("token"), "Pending cancellation must wait for explicit confirmation");
     }
 
     @Test

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

@@ -165,20 +165,28 @@ class LineCallbackDeviceTrustTest {
         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;
-        });
+        if (pending) when(cancellation.prepareLogin(any())).thenReturn(new UserCancellationService.LoginConfirmationView(
+                "cancellationConfirm", 1, new java.util.Date(System.currentTimeMillis() + 60000), "a".repeat(32), 60));
         when(infoUserService.getOne(any())).thenReturn(u);
         when(deviceMapper.selectById("dev-1")).thenReturn(null);
 
         controller.callback("line", "code-3", "dev-1", response);
+        if (pending) {
+            assertTrue(response.getRedirectedUrl().contains("cancellationConfirm=1"));
+            assertTrue(response.getRedirectedUrl().contains("remainingDays=1"));
+            org.junit.jupiter.api.Assertions.assertFalse(response.getRedirectedUrl().contains("token="));
+            assertEquals("1", u.getDelFlag());
+            verify(cancellation).prepareLogin(any());
+            verify(deviceMapper, never()).updateById(any(InfoUserDevice.class));
+            return;
+        }
+
 
         String location = response.getRedirectedUrl();
         assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?token="),
                 "已绑定应直接回跳 token,实际: " + location);
         verify(deviceMapper).ensureExists("dev-1", "0987654321", 100L);
-        if (pending) verify(cancellation).authenticatedLogin(100L);
-        else org.mockito.Mockito.verifyNoInteractions(cancellation);
+        org.mockito.Mockito.verifyNoInteractions(cancellation);
     }
 
     @Test

+ 133 - 24
ruoyi-admin/src/test/java/com/ruoyi/app/user/UserCancellationServiceTest.java

@@ -1,5 +1,6 @@
 package com.ruoyi.app.user;
 
+import com.alibaba.fastjson2.JSON;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
@@ -7,6 +8,9 @@ import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.mapper.InfoUserMapper;
 import org.junit.jupiter.api.Test;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.Mockito.*;
 import static org.mockito.ArgumentMatchers.*;
@@ -15,58 +19,163 @@ class UserCancellationServiceTest {
     private final InfoUserMapper users = mock(InfoUserMapper.class);
     private final RedisCache redis = mock(RedisCache.class);
     private final UserCancellationService service = new UserCancellationService(users, redis);
+    private final Map<String, String> cache = new HashMap<>();
+
+    UserCancellationServiceTest() {
+        doAnswer(call -> { cache.put(call.getArgument(0), call.getArgument(1)); return null; })
+                .when(redis).setCacheObject(anyString(), anyString(), anyInt(), eq(TimeUnit.SECONDS));
+        when(redis.getCacheObject(anyString())).thenAnswer(call -> cache.get(call.getArgument(0)));
+        when(redis.deleteObject(anyString())).thenAnswer(call -> cache.remove(call.getArgument(0)) != null);
+    }
     private InfoUser user(String deleted, Date deadline) {
-        InfoUser u = new InfoUser(); u.setUserId(1L); u.setUserType("0");
+        InfoUser u = new InfoUser(); u.setUserId(1L); u.setUserType("0"); u.setPhone("0912345678");
         u.setStatus("0"); u.setDelFlag(deleted); u.setCancellationDeadlineAt(deadline);
         when(users.selectCancellationUserForUpdate(1L)).thenReturn(u); return u;
     }
+    private CancellationLoginContext context() {
+        return new CancellationLoginContext(CancellationLoginContext.Flow.PHONE, 1L, "0912345678", "phone", null, null,
+                new CancellationLoginContext.Device("device", "cid", "ios", "push", null, true));
+    }
     @Test void cancellationRecordsSevenDaysAndRevokesUserSessions() {
         user("0", null); when(users.requestUserCancellation(eq(1L), any())).thenReturn(1);
         Date before = new Date(); var result = service.request(1L);
+        assertEquals(7, result.graceDays());
         assertTrue(result.cancellationDeadlineAt().getTime() >= before.getTime() + 7L*86400000);
         verify(redis).deleteKeys(anyString());
     }
-    @Test void verifiedLoginRestoresOriginalUserAndClearsDeadline() {
+    @Test void verifiedLoginOnlyReturnsConfirmationAndCeilingDaysWithoutRestoring() {
+        InfoUser u = user("1", new Date(System.currentTimeMillis()+86400000L+60000));
+        var result = service.prepareLogin(context());
+        assertEquals("cancellationConfirm", result.status()); assertEquals(2, result.remainingDays());
+        assertEquals(300, result.expiresInSeconds());
+        assertEquals(u.getCancellationDeadlineAt(), result.cancellationDeadlineAt());
+        assertTrue(result.confirmationKey().matches("[0-9a-f]{32}"));
+        assertEquals("1", u.getDelFlag());
+        verify(users, never()).restoreCancelledUser(anyLong(), any());
+        verify(redis, never()).deleteKeys(anyString());
+        var cached = JSON.parseObject(cache.get(UserCancellationService.CONFIRM_PREFIX + result.confirmationKey()), UserCancellationService.PendingLogin.class);
+        assertEquals(context(), cached.context());
+    }
+    @Test void lessThanOneDayShowsOneDayAndTicketCannotOutliveGrace() {
         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());
+        var result = service.prepareLogin(context());
+        assertEquals(1, result.remainingDays());
+        assertTrue(result.expiresInSeconds() > 0 && result.expiresInSeconds() <= 60);
+        var cached = JSON.parseObject(cache.get(UserCancellationService.CONFIRM_PREFIX + result.confirmationKey()), UserCancellationService.PendingLogin.class);
+        assertEquals(u.getCancellationDeadlineAt().getTime(), cached.expiresAt());
     }
-    @Test void expiredHistoricalDisabledAndNonUserAccountsCannotRestore() {
+    @Test void confirmationRestoresOriginalAccountAndConsumesCredentialOnce() {
         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));
+            InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
+            when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
+            String key = service.prepareLogin(context()).confirmationKey();
+            var restored = service.confirmLogin(key);
+            assertSame(u, restored.user()); assertEquals(context(), restored.context());
+            assertEquals("0", u.getDelFlag()); assertNull(u.getCancellationDeadlineAt());
+            assertThrows(ServiceException.class, () -> service.confirmLogin(key));
+            verify(users, times(1)).restoreCancelledUser(eq(1L), any());
+        }
+    }
+    @Test void invalidMissingAndExpiredConfirmationCannotRestore() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            user("1", new Date(System.currentTimeMillis()+60000));
+            for (String key : new String[]{null, "", "abc", "a".repeat(32)})
+                assertThrows(ServiceException.class, () -> service.confirmLogin(key));
+            String key = service.prepareLogin(context()).confirmationKey();
+            String cacheKey = UserCancellationService.CONFIRM_PREFIX + key;
+            var pending = JSON.parseObject(cache.get(cacheKey), UserCancellationService.PendingLogin.class);
+            cache.put(cacheKey, JSON.toJSONString(new UserCancellationService.PendingLogin(context(), pending.cancellationDeadline(), 0, pending.cycle())));
+            assertThrows(ServiceException.class, () -> service.confirmLogin(key));
+            verify(users, never()).restoreCancelledUser(anyLong(), any());
+        }
+    }
+    @Test void staleCancellationCycleChangedPhoneAndDisabledAccountAreRejected() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            for (String mutation : new String[]{"deadline", "phone", "disabled", "expired", "deleted", "active", "role"}) {
+                InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
+                String key = service.prepareLogin(context()).confirmationKey();
+                switch(mutation) {
+                    case "deadline" -> u.setCancellationDeadlineAt(new Date(u.getCancellationDeadlineAt().getTime()+86400000));
+                    case "phone" -> u.setPhone("0999999999");
+                    case "disabled" -> u.setStatus("1");
+                    case "expired" -> u.setCancellationDeadlineAt(new Date(0));
+                    case "deleted" -> u.setCancellationDeadlineAt(null);
+                    case "active" -> u.setDelFlag("0");
+                    case "role" -> u.setUserType("2");
+                }
+                assertThrows(ServiceException.class, () -> service.confirmLogin(key), mutation);
+            }
+            verify(users, never()).restoreCancelledUser(anyLong(), any());
+        }
+    }
+    @Test void siblingConfirmationCannotRecoverNewCancellationEvenWithSameDeadline() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            Date deadline = new Date(System.currentTimeMillis()+60000);
+            InfoUser u = user("1", deadline);
+            String first = service.prepareLogin(context()).confirmationKey();
+            String oldSibling = service.prepareLogin(context()).confirmationKey();
+            when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
+            service.confirmLogin(first);
+            // 模拟数据库秒精度导致新一次申请的截止时间与上次完全相同。
+            u.setDelFlag("1"); u.setCancellationDeadlineAt(deadline);
+            assertThrows(ServiceException.class, () -> service.confirmLogin(oldSibling));
+            verify(users, times(1)).restoreCancelledUser(eq(1L), any());
+        }
+    }
+    @Test void newCancellationInvalidatesExistingCycleBeforeAnotherPrompt() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            Date deadline = new Date(System.currentTimeMillis()+60000);
+            InfoUser u = user("1", deadline);
+            String oldKey = service.prepareLogin(context()).confirmationKey();
+            u.setDelFlag("0");
+            when(users.requestUserCancellation(eq(1L), any())).thenReturn(1);
+            service.request(1L);
+            u.setDelFlag("1"); u.setCancellationDeadlineAt(deadline);
+            service.prepareLogin(context());
+            assertThrows(ServiceException.class, () -> service.confirmLogin(oldKey));
             verify(users, never()).restoreCancelledUser(anyLong(), any());
         }
     }
-    @Test void updateLosingDeadlineRaceDoesNotRestoreAccount() {
+    @Test void lostCredentialConsumptionRaceCannotRestore() {
         try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
             user("1", new Date(System.currentTimeMillis()+60000));
-            assertThrows(ServiceException.class, () -> service.authenticatedLogin(1L));
+            String key = service.prepareLogin(context()).confirmationKey();
+            when(redis.deleteObject(UserCancellationService.CONFIRM_PREFIX+key)).thenReturn(false);
+            assertThrows(ServiceException.class, () -> service.confirmLogin(key));
+            verify(users, never()).restoreCancelledUser(anyLong(), any());
+        }
+    }
+    @Test void updateLosingDeadlineRaceDoesNotReturnRecoveredAccount() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            InfoUser u = user("1", new Date(System.currentTimeMillis()+60000));
+            String key = service.prepareLogin(context()).confirmationKey();
+            assertThrows(ServiceException.class, () -> service.confirmLogin(key));
+            assertEquals("1", u.getDelFlag());
+        }
+    }
+    @Test void expiredHistoricalDisabledAndNonUserAccountsCannotPrepareConfirmation() {
+        try(var messages = mockStatic(MessageUtils.class, c -> c.getArgument(0))) {
+            user("1", null); assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
+            user("1", new Date(0)); assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
+            InfoUser u = user("1", new Date(System.currentTimeMillis()+60000)); u.setStatus("1");
+            assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
+            u.setStatus("0"); u.setUserType("2");
+            assertThrows(ServiceException.class, () -> service.prepareLogin(context()));
+            assertThrows(ServiceException.class, () -> service.request(1L));
+            assertTrue(cache.isEmpty());
         }
     }
     @Test void exactDeadlineIsNotRecoverable() {
-        Date now = new Date();
-        assertFalse(UserCancellationService.canRecover(user("1", now), now));
+        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);
+            verify(redis, never()).deleteKeys(anyString());
         }
     }
-    @Test void activeLoginDoesNotChangeAccountLifecycle() {
-        InfoUser active = user("0", null);
-        assertSame(active, service.authenticatedLogin(1L));
-        verify(users, never()).restoreCancelledUser(anyLong(), any());
-    }
-
 
     private com.ruoyi.common.core.domain.entity.SysDictData days(String value) {
         var row = new com.ruoyi.common.core.domain.entity.SysDictData();
@@ -113,7 +222,7 @@ class UserCancellationServiceTest {
         var deadline = new Date(System.currentTimeMillis() + 60000);
         var u = user("1", deadline); when(users.restoreCancelledUser(eq(1L), any())).thenReturn(1);
         try (var dict = mockStatic(com.ruoyi.common.utils.DictUtils.class)) {
-            assertSame(u, service.authenticatedLogin(1L));
+            assertSame(u, service.confirmLogin(service.prepareLogin(context()).confirmationKey()).user());
             dict.verifyNoInteractions();
         }
     }

+ 99 - 19
specs/040-user-cancellation-grace/frontend-app-integration.md

@@ -1,42 +1,122 @@
-# 用户 App:注销与七天翻悔期接入
+# 用户 App:注销冷静期与登录确认接入
 
-仅普通用户账号(`userType=0`)适用;商家、骑手不接入此流程。本期未修改 App。
+更新:2026-11-05。仅普通用户账号适用,商家和骑手沿用原登录流程。
 
-## 申请注销
+## 注销确认与申请
 
-沿用 `GET /infouser/user/deleuser`,请求头 `token` 必填,无请求体。账号必须有有效 Redis 会话,仍沿用现有未完成订单拦截。
+注销前的确认弹窗由前端控制,按钮为“确认注销”“取消注销”。用户取消时不调用注销接口。
 
-成功响应:
+确认注销调用 `GET /infouser/user/deleuser`,携带 `token` 请求头,无请求体。存在未完成订单等情况时沿用接口错误提示。
+
+成功响应示例:
 
 ```json
 {
   "code": 200,
   "msg": "操作成功",
   "data": {
-    "cancellationDeadlineAt": "2026-11-06 12:00:00",
+    "cancellationDeadlineAt": "2026-11-12 15:00:00",
     "graceDays": 7
   }
 }
 ```
 
-`cancellationDeadlineAt` 为服务端计算的北京时间(UTC+8),示例日期仅为演示。`graceDays` 默认 7,页面以接口返回值展示,不在客户端另算截止日期。
+`graceDays` 是本次申请的冷静期总天数,默认7;`cancellationDeadlineAt` 是准确截止时间,格式 `yyyy-MM-dd HH:mm:ss`,时区 UTC+8。注销成功后以接口实际返回值显示期限,并立即清除本地登录态、退出账号。
+
+## 冷静期内尝试登录
+
+手机号登录、Apple/Google/LINE 已绑定登录、验证手机号绑定和设备确认登录仍使用原请求参数。身份验证通过后,如果账号处于冷静期,返回:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "status": "cancellationConfirm",
+    "remainingDays": 3,
+    "cancellationDeadlineAt": "2026-11-08 15:00:00",
+    "confirmationKey": "0123456789abcdef0123456789abcdef",
+    "expiresInSeconds": 300
+  }
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| status | `cancellationConfirm` 表示等待用户确认恢复,尚未登录 |
+| remainingDays | 剩余冷静期天数,由后端向上取整;不足24小时显示1天 |
+| cancellationDeadlineAt | 本次注销的截止时间,UTC+8 |
+| confirmationKey | 确认恢复所需的临时凭证,仅本次流程使用 |
+| expiresInSeconds | 临时凭证剩余有效秒数,最长300秒;临近注销截止时可能更短 |
+
+前端先判断 `data.status`。进入此分支时没有登录 token,不能仅凭 `code=200` 当作登录成功,也不能保存账号登录态。
+
+弹窗文案:
+
+> 该账号正在注销中,剩余X天。登录将撤销注销申请并恢复正常使用,是否继续?
+
+X 直接使用 `remainingDays`,不要使用申请注销时的 `graceDays`,也不要按手机本地日期重算。
+
+按钮行为:
+
+- **继续登录,恢复账号**:调用下方确认接口,成功后进入原登录成功流程。
+- **继续注销,不登录**:关闭弹窗,丢弃临时凭证,结束本次登录,不调用确认接口。关闭或返回退出弹窗同样不恢复账号。
+
+## 确认恢复并登录
 
-注销成功后立即清除本地登录态,退出账号。文案说明:在截止时间前重新登录,会撤销注销并恢复原账号;到期后无法恢复原账号。不能把注销成功展示成资料已物理删除。
+`POST /infouser/user/confirmCancellationLogin`
 
-## 翻悔与登录
+无需登录 token,请求头 `Content-Type: application/json`,请求体:
 
-无需新增恢复接口或传恢复字段。正常调用原有手机号登录、Apple/Google/LINE 登录、绑定手机号或设备确认接口,身份校验成功后服务端自动恢复,返回原 `userId` 和新 token。App 按原登录成功流程处理即可。
+```json
+{
+  "confirmationKey": "0123456789abcdef0123456789abcdef"
+}
+```
+
+只提交上一步返回的确认凭证,不需要重发短信验证码或第三方授权 code,也不传用户ID、手机号或恢复标志。
+
+成功响应保持原登录成功结构:
+
+```json
+{
+  "code": 200,
+  "msg": "登录成功",
+  "data": {
+    "userId": 1001,
+    "userType": "0",
+    "delFlag": "0"
+  },
+  "token": "新的登录token"
+}
+```
+
+示例只列出部分用户字段。成功后恢复原账号,保存新 token 并按现有流程初始化登录。注销前的旧 token 仍然无效。
+
+按钮提交期间禁用重复点击。确认凭证只能使用一次;超时、账号不可恢复或凭证失效时展示接口 `msg` 并重新发起登录,不能反复提交旧凭证。网络超时导致结果不确定时也重新登录获取当前账号状态。
+
+## LINE 网页回调
+
+通过 `/auth/line/callback` 回到 App 的登录方式,冷静期回跳参数为:
+
+```text
+?cancellationConfirm=1
+&confirmationKey=0123456789abcdef0123456789abcdef
+&remainingDays=3
+&cancellationDeadlineAt=2026-11-08%2015%3A00%3A00
+&expiresInSeconds=300
+```
 
-- 必须严格早于截止时间,且账号未被平台停用;到达截止时间即不能恢复。
-- 登录失败、打开页面、刷新 token 和使用原 token 均不会恢复。
-- 注销时原用户会话被撤销,之后使用返回 HTTP 401,业务 `code=401`。恢复后原 token 仍然无效。
-- 历史删除账号没有翻悔截止时间,不能按此流程恢复。
-- 到期账号保留历史业务资料。手机号重新注册沿用现有规则,可能创建新账号;已绑定第三方身份不自动解绑,不能借此恢复过期账号。
+上例换行仅为阅读,实际为同一个URL的查询参数。解析URL编码后显示同一确认弹窗;此分支没有 `token`,确认时调用同一个 `confirmCancellationLogin` 接口并处理JSON登录响应。
 
-## 后端部署与配置
+正常登录的 `token` 回跳、未绑定的 `needPhone` 和设备确认 `deviceConfirm` 保持原处理方式。
 
-先由开发者手动执行 `updatesql/sql.md` 中 2026-10-30 的 040 迁移,再部署代码。本轮未直接执行数据库迁移。
+## 联调检查
 
-后台字典 `user_cancellation_grace_days` 管理天数,默认7;有效整数范围1~365,字典缺失、停用、非法值或读取异常回退7。后台保存刷新字典缓存后影响后续注销,已有截止时间不变,无须重启。App 请求参数不变,文案使用接口返回的 graceDays,不写死7天。
+- 注销前取消不发请求;注销成功读取实际天数和截止时间并退出账号。
+- 所有用户登录方式遇到 `cancellationConfirm` 均先弹窗,不进入登录态。
+- 选择继续注销或关闭弹窗,账号仍处于注销状态;再次登录仍需身份验证。
+- 确认恢复成功后保持原userId,使用新token登录;重复点击、凭证超时及注销期已到均按错误提示处理。
+- 无冷静期的正常用户登录体验保持不变。
 
-账号申请注销时已经设置 `del_flag=1`,到期仅关闭恢复资格,因此无需定时任务再次设置删除状态。平台直接删除会清空翻悔期;平台停用的账号不允许通过登录恢复。
+本次提供后端接口及接入约定,App弹窗和按钮由前端实现,文案按现有客户端国际化机制处理。

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

@@ -16,3 +16,11 @@
 1. 为动态修改、缺失/停用/非法值/异常默认7天及恢复不重算编写定向测试。
 2. UserCancellationService 复用 DictUtils 字典缓存,在申请时读取,不缓存到服务字段;删除 @Value 配置。
 3. 追加幂等字典初始化 SQL,默认7天,仅写入 updatesql/sql.md。更新接入文档,执行服务测试及模块构建。
+
+## 2026-11-05 二次确认恢复实施计划
+
+1. 先复现冷静期认证直接签token;新增经过身份验证的短期确认上下文,Redis保存5分钟一次性凭证及注销周期快照,账号行锁下创建随机周期标识,恢复或重新注销时失效旧标识。
+2. UserCancellationService统一生成提示与消费确认:数据库行锁检查角色、停用、截止时间,确认后原子恢复;剩余天数向上取整。
+3. 在所有用户认证入口的账号恢复/绑定/设备写入之前返回确认分支;新确认接口恢复原认证流程,重新校验身份绑定、手机号和设备,签发失败回滚。
+4. LINE网页回调返回确认参数;商家/骑手、正常用户登录不变。注销响应维持原契约。
+5. 更新Service/Controller/设备及LINE回调测试、六份语言提示和App文档,运行相关认证定向回归及JDK21打包,检查差异与安全边界。

+ 13 - 2
specs/040-user-cancellation-grace/spec.md

@@ -3,14 +3,14 @@
 ## 用户确认的规则
 
 - 仅用户端普通用户(`user_type=0`):注销时设置 `del_flag=1`,记录截止时间,默认申请时刻起 7 天。
-- 只有截止时间严格大于服务端当前时间,且该账号未被平台停用,身份认证成功后才恢复 `del_flag=0` 并清空截止时间。历史删除账号无截止时间,不能恢复。
+- 只有截止时间严格大于服务端当前时间,且该账号未被平台停用,身份认证成功且用户明确确认恢复后才恢复 `del_flag=0` 并清空截止时间。历史删除账号无截止时间,不能恢复。
 - 期限到达或超过后保持删除状态;刷新 token、旧会话和静默请求不能恢复账号。
 - 其他商家和骑手账号不进入此流程,保留原有行为。
 
 ## 必须覆盖的边界
 
 - 注销沿用未完成订单检查;注销须校验有效会话并使现有用户端会话失效。用户确认旧 token 已淘汰,所有 `@Auth` 接口一律要求有效 Redis 会话;不再兼容旧 JWT 放行。
-- 手机号登录、已绑定第三方登录、第三方绑定手机号和设备信任确认各入口需在身份验证成功后统一调用恢复;恢复条件通过数据库原子更新再次验证,避免截止时间竞争、禁用账号被恢复和历史删除账号误恢复。
+- 手机号登录、已绑定第三方登录、第三方绑定手机号和设备信任确认各入口需在身份验证成功后统一返回确认提示,仅确认接口可恢复;恢复条件通过数据库原子更新再次验证,避免截止时间竞争、禁用账号被恢复和历史删除账号误恢复。
 - 手机号和第三方身份的查询仅将有未来截止时间的用户纳入候选,不能影响同手机号商家、骑手;多条候选须安全拒绝,避免误恢复或误创建账号。
 - 删除状态与截止时间同时写入同一事务;原会话撤销和新会话签发不得使注销并发登录时绕过注销状态。
 - 现有第三方绑定唯一索引保留;过期后的旧绑定如何解除和重新注册是独立问题,本期不能擅自删除身份绑定或历史业务资料。
@@ -28,3 +28,14 @@
 ## 2026-10-30 字典管理增量
 
 管理员通过现有字典管理修改天数,保存刷新字典缓存后对下一次注销申请生效,无须重启。配置只决定新申请的截止时间;已有申请的截止时间不重算,恢复登录不读取新配置。App 继续使用接口返回的 graceDays 与 cancellationDeadlineAt,无请求参数变更。
+
+## 2026-11-05 登录前确认恢复(当前约定)
+
+- 本节替代身份验证成功即自动恢复的行为。注销前弹窗由前端控制;注销接口仍返回 graceDays 和 cancellationDeadlineAt,保留字典及默认7天规则。
+- 所有普通用户认证入口验证身份后,若处于冷静期,只返回 status=cancellationConfirm、remainingDays、cancellationDeadlineAt、confirmationKey、expiresInSeconds,不恢复账号、不签发登录token、不记录设备信任或写入第三方绑定/推送资料。
+- 剩余天数按服务端距截止时间的剩余毫秒除以24小时向上取整,未到期至少1天。等于或超过截止时间不能恢复。
+- 用户明确点击“继续登录,恢复账号”后,POST /infouser/user/confirmCancellationLogin,仅提交 confirmationKey。凭证有效期最长5分钟、一次性使用,绑定已验证账号/身份/设备上下文、本次注销截止时间和服务端随机周期标识,不接收客户端指定的userId或恢复标志。
+- 确认时重新校验账号、截止时间、手机号和第三方绑定/设备信任;拒绝过期、重放、停用、平台删除及旧注销周期的凭证。恢复和会话签发在同一数据库事务,失败回滚。重新注销或恢复会使旧周期凭证全部失效,防止数据库秒精度导致截止时间碰撞。
+- “继续注销,不登录”不调用确认接口,前端丢弃凭证并结束登录,保留注销状态;再次登录重新验证身份。
+- 手机号、Apple/Google/LINE、短信绑号、设备确认及LINE网页回调均覆盖;网页回调返回确认参数而非token,App再调同一确认接口。
+- 无新增数据库结构;本轮仅后端、六份国际化提示与前端接入文档,不创建分支,不自动提交/推送。

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

@@ -46,3 +46,16 @@ mvn -q -pl ruoyi-admin -am '-Dtest=UserCancellationServiceTest,UserCancellationM
 字典增量验证:JDK21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=UserCancellationServiceTest,UserCancellationMapperTest,InfoUserControllerTest -Dsurefire.failIfNoSpecifiedTests=false package`,94项测试通过,模块打包成功,git diff --check 通过。新测试先复现字典设为14仍返回7,再随实现通过。日志:ruoyi-admin/target/040-dict-package.log。
 
 本次新增字典 INSERT 尚未执行;此前测试库关联表执行确认不包括本次字典 SQL。未执行真实数据库/Redis及App联调,本轮字典调整单独提交,发票打印等其他改动不纳入本次提交。
+
+## 2026-11-05 登录前确认恢复
+
+状态:已验证(本次改动独立源码副本)。
+- [x] T012 先返回冷静期确认信息,统一剩余天数与一次性短期凭证;确认时重新检查注销周期并原子恢复。
+- [x] T013 覆盖手机号、已绑定OAuth、短信绑号、设备确认与LINE回调;确认之前不签token、不写绑定/信任。
+- [x] T014 补齐重放、到期、停用、身份变更、登录失败回滚及原渠道回归,更新国际化和前端文档并构建验证。
+
+本轮验证(2026-11-05):原登录直接签发token的4个OAuth渠道断言先失败;复审发现数据库秒精度下旧兄弟凭证可能跨注销周期,新增用例先复现,再加入Redis周期标识解决。H2配合真实DataSourceTransactionManager和Controller事务代理验证恢复提交及签会话失败回滚。
+
+执行 JDK21 `mvn -q -pl ruoyi-admin -am -Dtest=UserCancellationServiceTest,CancellationLoginConfirmationTest,UserCancellationMapperTest,DeviceBindingSwitchTest,DeviceTrustServiceTest,DeviceTrustFlowTest,InfoUserControllerTest,LineCallback*Test,AuthAspectTest,MerchantSessionAuthAnnotationTest -Dsurefire.failIfNoSpecifiedTests=false package`,独立副本共171项测试通过,失败/错误/跳过均0,package退出码0。副本为当前HEAD加本次注销相关代码、测试与语言key,与工作区交付文件逐字节核对一致;JSON示例及git diff --check通过,独立复审无剩余重要问题。
+
+工作区首次回归另有MerchantSessionAuthAnnotationTest失败:其他任务删除了PosOrderShOprateController.orderDetail,非本次改动;保留原工作区内容,独立副本未纳入该删除。真实MySQL/Redis并发集成、App与第三方实机联调、全量回归及部署未执行。无新SQL、无分支、未提交或推送。日志:ruoyi-admin/target/cancellation-confirm-red.log、cancellation-confirm-cycle-red.log、cancellation-confirm-green.log(含上述无关失败)、cancellation-confirm-isolated.log(最终通过)。