Browse Source

改用字典管理用户注销翻悔期天数

qmj 2 days ago
parent
commit
29af5420aa

+ 24 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/user/UserCancellationService.java

@@ -6,7 +6,9 @@ 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 com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import java.util.List;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import java.time.Instant;
@@ -18,11 +20,8 @@ import java.util.Date;
 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) {
+    public UserCancellationService(InfoUserMapper users, RedisCache redis) {
         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")
@@ -34,6 +33,8 @@ public class UserCancellationService {
         InfoUser user = users.selectCancellationUserForUpdate(userId);
         requireUser(user);
         if (!"0".equals(user.getDelFlag())) throw unavailable();
+        // 每次申请读取最新字典;天数只在这里转成截止时间,后续恢复不重新计算。
+        int graceDays = graceDays();
         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 + ":*");
@@ -52,6 +53,24 @@ public class UserCancellationService {
         return user;
     }
 
+    /** 复用平台字典缓存;后台修改并刷新后对新申请生效,读取不到或非法均默认7天。 */
+    private int graceDays() {
+        try {
+            List<SysDictData> rows = DictUtils.getDictCache("user_cancellation_grace_days");
+            if (rows != null) {
+                for (SysDictData row : rows) {
+                    if (row == null || !"0".equals(row.getStatus())) continue;
+                    String value = row.getDictValue();
+                    int days = value == null ? 7 : Integer.parseInt(value.trim());
+                    return days >= 1 && days <= 365 ? days : 7;
+                }
+            }
+        } catch (RuntimeException ignored) {
+            // 字典缺失、值非法或缓存不可用不阻断注销,沿用默认7天。
+        }
+        return 7;
+    }
+
     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

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

@@ -14,7 +14,7 @@ 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 final UserCancellationService service = new UserCancellationService(users, redis);
     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);
@@ -67,4 +67,54 @@ class UserCancellationServiceTest {
         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();
+        row.setDictValue(value); row.setStatus("0"); return row;
+    }
+
+    @Test void dictionaryChangesApplyToSubsequentRequestsWithoutRecreatingService() {
+        user("0", null); when(users.requestUserCancellation(eq(1L), any())).thenReturn(1);
+        try (var dict = mockStatic(com.ruoyi.common.utils.DictUtils.class)) {
+            dict.when(() -> com.ruoyi.common.utils.DictUtils.getDictCache("user_cancellation_grace_days"))
+                    .thenReturn(java.util.List.of(days("14")), java.util.List.of(days("30")));
+            var first = service.request(1L);
+            var second = service.request(1L);
+            assertEquals(14, first.graceDays()); assertEquals(30, second.graceDays());
+            long difference = second.cancellationDeadlineAt().getTime() - first.cancellationDeadlineAt().getTime();
+            assertTrue(difference >= 16L * 86400000 && difference < 16L * 86400000 + 10000);
+        }
+    }
+
+    @org.junit.jupiter.params.ParameterizedTest
+    @org.junit.jupiter.params.provider.NullAndEmptySource
+    @org.junit.jupiter.params.provider.ValueSource(strings = {"abc", "0", "-1", "366", "1.5", "999999999999"})
+    void invalidDictionaryValueDefaultsToSevenDays(String value) {
+        user("0", null); when(users.requestUserCancellation(eq(1L), any())).thenReturn(1);
+        try (var dict = mockStatic(com.ruoyi.common.utils.DictUtils.class)) {
+            dict.when(() -> com.ruoyi.common.utils.DictUtils.getDictCache("user_cancellation_grace_days"))
+                    .thenReturn(java.util.List.of(days(value)));
+            assertEquals(7, service.request(1L).graceDays());
+        }
+    }
+
+    @Test void missingDisabledOrUnavailableDictionaryDefaultsToSevenDays() {
+        user("0", null); when(users.requestUserCancellation(eq(1L), any())).thenReturn(1);
+        var disabled = days("14"); disabled.setStatus("1");
+        try (var dict = mockStatic(com.ruoyi.common.utils.DictUtils.class)) {
+            dict.when(() -> com.ruoyi.common.utils.DictUtils.getDictCache("user_cancellation_grace_days"))
+                    .thenReturn(null, java.util.List.of(), java.util.List.of(disabled))
+                    .thenThrow(new IllegalStateException("cache unavailable"));
+            for (int i=0; i<4; i++) assertEquals(7, service.request(1L).graceDays());
+        }
+    }
+
+    @Test void dictionaryChangeDoesNotChangeExistingRecoveryDeadline() {
+        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));
+            dict.verifyNoInteractions();
+        }
+    }
 }

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

@@ -37,6 +37,6 @@
 
 先由开发者手动执行 `updatesql/sql.md` 中 2026-10-30 的 040 迁移,再部署代码。本轮未直接执行数据库迁移。
 
-配置项 `user.cancellation.grace-days` 默认为 7;有效值 1–365,缺失或非法整数范围降级为 7(非数字配置会由 Spring 拒绝启动)。该项仅影响后续注销,已经保存的截止时间不会变化。
+后台字典 `user_cancellation_grace_days` 管理天数,默认7;有效整数范围1~365,字典缺失、停用、非法值或读取异常回退7。后台保存刷新字典缓存后影响后续注销,已有截止时间不变,无须重启。App 请求参数不变,文案使用接口返回的 graceDays,不写死7天。
 
 账号申请注销时已经设置 `del_flag=1`,到期仅关闭恢复资格,因此无需定时任务再次设置删除状态。平台直接删除会清空翻悔期;平台停用的账号不允许通过登录恢复。

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

@@ -10,3 +10,9 @@
 用户确认旧 token 已淘汰,移除 `@Auth` 对无会话旧 JWT 的兼容放行;这项鉴权调整覆盖所有 `@Auth` 接口。`/deleuser` 已加 `@Auth` 会话校验。第三方已绑定查询对用户端开放期内账号,商家/骑手登录与注销业务分支保持原行为。独立 LINE 网页回调同样覆盖恢复;回调捕获异常时回滚登录事务。
 
 前端接入说明见 `frontend-app-integration.md`。错误复用现有国际化 key,本期不新增单一语言提示。
+
+## 2026-10-30 字典管理调整
+
+1. 为动态修改、缺失/停用/非法值/异常默认7天及恢复不重算编写定向测试。
+2. UserCancellationService 复用 DictUtils 字典缓存,在申请时读取,不缓存到服务字段;删除 @Value 配置。
+3. 追加幂等字典初始化 SQL,默认7天,仅写入 updatesql/sql.md。更新接入文档,执行服务测试及模块构建。

+ 5 - 1
specs/040-user-cancellation-grace/spec.md

@@ -19,8 +19,12 @@
 ## 实现边界
 
 - 新字段为 `info_user.cancellation_deadline_at`,仅由注销/恢复专用 SQL 写入;通用实体写入不接收此字段。
-- 截止时间默认 7 天,配置 `user.cancellation.grace-days` 支持 1–365 天,超出范围回退 7 天。
+- 每次注销申请读取字典 `user_cancellation_grace_days` 的首条正常状态数据的 dict_value,支持整数 1–365 天;字典缺失、停用、空值、非整数、越界或读取异常均回退 7 天。移除原 user.cancellation.grace-days 启动配置。
 - 数据库行锁覆盖用户登录签发会话与注销;原子恢复 SQL 再校验角色、停用状态、删除状态及截止时间。
 - 平台直接删除会清空截止时间,不能被当成主动注销恢复。
 - 不物理删除账号、历史订单、钱包或第三方绑定;不新增定时清理任务。
 - 未完成订单检查沿用已有逻辑,本期不扩展订单业务;原有短信及设备信任认证策略保持现状。
+
+## 2026-10-30 字典管理增量
+
+管理员通过现有字典管理修改天数,保存刷新字典缓存后对下一次注销申请生效,无须重启。配置只决定新申请的截止时间;已有申请的截止时间不重算,恢复登录不读取新配置。App 继续使用接口返回的 graceDays 与 cancellationDeadlineAt,无请求参数变更。

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

@@ -37,3 +37,12 @@ mvn -q -pl ruoyi-admin -am '-Dtest=UserCancellationServiceTest,UserCancellationM
 结果: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 字段迁移是否已执行尚未单独确认。
+
+## 字典管理增量
+
+- [x] T010 翻悔期改为 user_cancellation_grace_days 字典;读取失败或非法值回退7天,已有截止时间不重算。
+- [x] T011 更新注释、spec/plan、App文档及幂等字典初始化 SQL;执行定向测试及模块构建。
+
+字典增量验证: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联调,本轮字典调整单独提交,发票打印等其他改动不纳入本次提交。

+ 13 - 0
updatesql/sql.md

@@ -2500,3 +2500,16 @@ CREATE TABLE info_user_oauth_device (
 ```
 
 同设备同绑定仅一条记录;同一绑定可关联多台设备,但始终只绑定 info_user_oauth.user_id 指定的一个用户。按设备与旧用户筛选解绑对象,删除全局绑定时同步删除该绑定的所有设备关联,均在后端业务事务内处理。仅适用用户端 Apple/Google/LINE,商家/骑手绑定不在本次清理范围。不新增物理外键,不存储第三方 token 或重复手机号。
+
+## 2026-10-30 用户注销翻悔期天数字典(040)
+
+由开发者手动执行,尚未执行本段 SQL。不新增数据库字段。平台字典管理中修改此类型下的正常状态数据值,仅保留一个有效配置项;支持整数1~365,缺失、停用、非法值或读取异常默认7天。执行 SQL 后通过字典管理刷新缓存;已有申请的截止时间不重算。
+
+```sql
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '用户注销翻悔期天数', 'user_cancellation_grace_days', '0', 'admin', NOW(), '整数1至365天;缺失或非法默认7天,仅影响新注销申请'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='user_cancellation_grace_days');
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '注销翻悔期天数', '7', 'user_cancellation_grace_days', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='user_cancellation_grace_days');
+```