Просмотр исходного кода

chore: auto bump version and changelog [skip ci]

iwt 2 недель назад
Родитель
Сommit
7c1f9c1434

+ 60 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ReportAnalyzeQuotaDTO.java

@@ -0,0 +1,60 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 报告快速分析配额状态。
+ *
+ * <p>同一 DTO 承载两种语义,按入口区分:
+ * <ul>
+ *   <li>只读查询({@code POST /api/ai/report/analyze/quota}):仅
+ *       {@code total/used/remaining/unlimited/exhausted} 有含义,{@code allowed} 恒为 true;</li>
+ *   <li>扣减结果({@code POST /api/ai/report/analyze}):额外携带
+ *       {@code allowed/message/actionUrl}。放行时作为 data 内 {@code quota} 字段回给前端;
+ *       超限时 {@code allowed=false},控制器以 {@code message} 作为 Result.message 返回
+ *       (仍带 code=500),但 data 依然保留本对象供前端渲染剩余次数与升级引导。</li>
+ * </ul>
+ *
+ * <p>口径见 {@code docs/superpowers/specs/2026-09-10-report-quick-analyze-design.md} §3.3:
+ * 免费版每日 1 次;付费版按 membership_levels.max_daily_analyze 配置,0/NULL = 不限次。
+ */
+@Data
+public class ReportAnalyzeQuotaDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 配额类型(固定值,便于前端按类型复用进度条组件) */
+    private String quotaType = "REPORT_ANALYZE";
+
+    /** 当前用户有效会员等级编码:FREE/BASIC/FAMILY/PREMIUM/LIFETIME */
+    private String levelCode;
+
+    /** 每日上限次数;0 表示不限次 */
+    private int total;
+
+    /** 今日已用次数 */
+    private int used;
+
+    /** 今日剩余次数;-1 表示不限次 */
+    private int remaining;
+
+    /** 是否不限次(付费等级未配置上限,或上限配置为 0) */
+    private boolean unlimited;
+
+    /** 是否已用完(unlimited=false 且 remaining<=0) */
+    private boolean exhausted;
+
+    /** 本次调用是否放行;只读查询入口恒为 true */
+    private boolean allowed = true;
+
+    /** allowed=false 时的提示文案(免费版与付费版文案不同) */
+    private String message;
+
+    /** 升级引导路径(exhausted 时有值) */
+    private String actionUrl;
+
+    /** 计数周期键(yyyyMMdd),与 Redis 计数器 key 后缀一致 */
+    private String periodKey;
+}

+ 269 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportAnalyzeQuotaService.java

@@ -0,0 +1,269 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.ReportAnalyzeQuotaDTO;
+import com.etotem.cfc.entity.MembershipLevel;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 报告快速分析配额服务(Redis 日计数)。
+ *
+ * <p>规格:{@code docs/superpowers/specs/2026-09-10-report-quick-analyze-design.md} §3.3、
+ * {@code 2026-09-13-report-quick-analyze-extend-design.md} §5。
+ *
+ * <p>口径:
+ * <ul>
+ *   <li>免费版(FREE,含 PROVIDER 归一化后的 FREE):每日 {@value #FREE_DAILY_LIMIT} 次,服务端硬编码,
+ *       不随 membership_levels 配置变化;</li>
+ *   <li>付费版:上限取 {@code membership_levels.max_daily_analyze};0 或 NULL = 不限次
+ *       (保留既有「付费版多轮对话不限制次数」口径);</li>
+ *   <li>日计数器 key:{@code report:analyze:{userId}:{yyyyMMdd}},INCR + 24h EXPIRE,按天自然滚动,
+ *       无定时任务;TTL 到期即等价于额度重置;</li>
+ *   <li>付费多轮:同一 {@code conversationId} 仅计 1 次({@code SADD} 原子判重 + 同日去重 set),
+ *       后续追问不重复扣次数;免费用户不做会话判重(后端已剥离 conversationId,天然单次);</li>
+ *   <li>Redis 异常 <strong>fail-open</strong> 放行(避免免费用户因 Redis 故障完全不可用),记 warn。</li>
+ * </ul>
+ *
+ * <p>范围说明:报告反馈(report_feedback)沿用其独立的 {@code report:feedback:free:} 计数器,
+ * 不由本服务管理(见 {@link ReportFeedbackService})。
+ */
+@Slf4j
+@Service
+public class ReportAnalyzeQuotaService {
+
+    /** 配额类型 */
+    public static final String QUOTA_TYPE = "REPORT_ANALYZE";
+
+    /** 免费版每日次数上限(硬编码,不读配置表) */
+    public static final int FREE_DAILY_LIMIT = 1;
+
+    /** 免费版等级编码(MembershipService 已将 PROVIDER 归一化为 FREE) */
+    private static final String LEVEL_FREE = "FREE";
+    private static final String LEVEL_PROVIDER = "PROVIDER";
+
+    /** 日计数器 key 前缀:report:analyze:{userId}:{yyyyMMdd} */
+    private static final String COUNTER_KEY_PREFIX = "report:analyze:";
+
+    /** 会话去重 set key 前缀:report:analyze:session:{yyyyMMdd}:{conversationId} */
+    private static final String SESSION_KEY_PREFIX = "report:analyze:session:";
+
+    private static final DateTimeFormatter PERIOD_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+    /** TTL:略大于 1 天,保证当日末尾读到的仍是当日计数 */
+    private static final Duration TTL = Duration.ofDays(1);
+
+    /** 升级引导路径(与前端 pages/membership 分包约定一致) */
+    public static final String UPGRADE_URL = "/pages/membership/upgrade";
+
+    /** 免费版超限文案:含「今日已用完」,与付费版文案可区分 */
+    public static final String MSG_FREE_EXHAUSTED =
+            "免费版今日已用完(" + FREE_DAILY_LIMIT + " 次/天),升级会员解锁更多";
+
+    /** 付费版超限文案:含「请升级会员」,与免费版文案可区分 */
+    public static final String MSG_PAID_EXHAUSTED =
+            "请升级会员以获取更多快速分析次数";
+
+    @Resource
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Resource
+    private MembershipService membershipService;
+
+    @Resource
+    private MembershipLevelMapper membershipLevelMapper;
+
+    /**
+     * 查询当前用户的报告快速分析配额状态(只读,不扣减)。
+     * 用于前端在发起分析前展示剩余次数。
+     */
+    public ReportAnalyzeQuotaDTO getStatus(Long userId) {
+        ReportAnalyzeQuotaDTO dto = buildBase(userId);
+        if (!dto.isUnlimited()) {
+            dto.setUsed(readUsed(userId));
+        }
+        applyRemaining(dto);
+        return dto;
+    }
+
+    /**
+     * 尝试扣减一次配额。
+     *
+     * @param userId         当前登录用户 ID
+     * @param conversationId 会话 ID(付费版多轮去重用);为空时按单次计费
+     * @return 配额结果。{@code allowed=true} 表示放行;{@code allowed=false} 时
+     *         {@code message} 为超限文案(免费版 / 付费版文案不同),{@code actionUrl} 为升级路径
+     */
+    public ReportAnalyzeQuotaDTO consume(Long userId, String conversationId) {
+        ReportAnalyzeQuotaDTO dto = buildBase(userId);
+        boolean free = isFreeLevel(dto.getLevelCode());
+
+        if (dto.isUnlimited()) {
+            // 付费等级未配置上限:放行且不落计数器(避免 key 无限增长)
+            dto.setAllowed(true);
+            return dto;
+        }
+
+        String counterKey = counterKey(userId);
+        String sessionKey = free ? null : sessionKey(conversationId);
+
+        try {
+            // 付费多轮:同一会话内只计 1 次(SADD 返回 0 = 该会话已计过费)
+            if (sessionKey != null) {
+                Long added = redisTemplate.opsForSet().add(sessionKey, String.valueOf(userId));
+                if (added != null) {
+                    redisTemplate.expire(sessionKey, TTL);
+                }
+                if (added != null && added == 0L) {
+                    dto.setUsed(readUsed(userId));
+                    applyRemaining(dto);
+                    dto.setAllowed(true);
+                    return dto;
+                }
+            }
+
+            Long count = redisTemplate.opsForValue().increment(counterKey);
+            if (count != null && count == 1L) {
+                redisTemplate.expire(counterKey, TTL);
+            }
+            int used = (count != null) ? count.intValue() : readUsed(userId);
+            dto.setUsed(used);
+
+            if (used > dto.getTotal()) {
+                // 本次未扣成功:回滚会话标记,使同一会话仍可再次作为新一轮计费
+                if (sessionKey != null) {
+                    redisTemplate.opsForSet().remove(sessionKey, String.valueOf(userId));
+                }
+                applyRemaining(dto);
+                dto.setAllowed(false);
+                dto.setMessage(free ? MSG_FREE_EXHAUSTED : MSG_PAID_EXHAUSTED);
+                dto.setActionUrl(UPGRADE_URL);
+                log.info("报告快速分析配额超限: userId={}, level={}, used={}, total={}, free={}",
+                        userId, dto.getLevelCode(), used, dto.getTotal(), free);
+                return dto;
+            }
+
+            applyRemaining(dto);
+            dto.setAllowed(true);
+            return dto;
+        } catch (Exception e) {
+            // fail-open:Redis 异常时放行,避免免费用户因 Redis 故障完全不可用
+            log.warn("报告快速分析配额计数失败,放行: userId={}, err={}", userId, e.getMessage());
+            dto.setUsed(readUsed(userId));
+            applyRemaining(dto);
+            dto.setAllowed(true);
+            return dto;
+        }
+    }
+
+    /**
+     * 是否为免费版(含 PROVIDER 归一化结果)。免费版锁死多轮,后端需剥离 conversationId。
+     */
+    public boolean isFreeLevel(String levelCode) {
+        return LEVEL_FREE.equals(levelCode) || LEVEL_PROVIDER.equals(levelCode);
+    }
+
+    // ==================== 内部方法 ====================
+
+    /**
+     * 构建配额 DTO 基础信息(等级 + 每日上限 + 周期键)。
+     */
+    private ReportAnalyzeQuotaDTO buildBase(Long userId) {
+        ReportAnalyzeQuotaDTO dto = new ReportAnalyzeQuotaDTO();
+        dto.setQuotaType(QUOTA_TYPE);
+        dto.setPeriodKey(LocalDate.now().format(PERIOD_FMT));
+        String level = resolveLevel(userId);
+        dto.setLevelCode(level);
+        int limit = isFreeLevel(level) ? FREE_DAILY_LIMIT : resolveConfigLimit(level);
+        dto.setTotal(limit);
+        dto.setUnlimited(limit <= 0);
+        if (limit <= 0) {
+            dto.setRemaining(-1);
+            dto.setExhausted(false);
+        }
+        return dto;
+    }
+
+    /**
+     * 取用户当前有效会员等级;异常/空用户按免费版处理(最保守)。
+     */
+    private String resolveLevel(Long userId) {
+        if (userId == null) {
+            return LEVEL_FREE;
+        }
+        try {
+            String level = membershipService.getMemberLevel(userId);
+            return (level == null || level.isEmpty()) ? LEVEL_FREE : level;
+        } catch (Exception e) {
+            log.warn("获取会员等级失败,按免费版处理: userId={}, err={}", userId, e.getMessage());
+            return LEVEL_FREE;
+        }
+    }
+
+    /**
+     * 读取会员等级配置的每日上限;0 或 NULL 表示不限次;读取失败按不限次兜底。
+     */
+    private int resolveConfigLimit(String levelCode) {
+        try {
+            MembershipLevel lv = membershipLevelMapper.selectOne(
+                    new LambdaQueryWrapper<MembershipLevel>()
+                            .eq(MembershipLevel::getLevelCode, levelCode)
+                            .last("LIMIT 1"));
+            if (lv == null || lv.getMaxDailyAnalyze() == null) {
+                return 0;
+            }
+            return lv.getMaxDailyAnalyze();
+        } catch (Exception e) {
+            log.warn("读取 max_daily_analyze 失败,按不限次处理: level={}, err={}",
+                    levelCode, e.getMessage());
+            return 0;
+        }
+    }
+
+    /**
+     * 读取今日已用次数;Redis 异常时按 0 处理。
+     */
+    private int readUsed(Long userId) {
+        try {
+            Object v = redisTemplate.opsForValue().get(counterKey(userId));
+            if (v == null) {
+                return 0;
+            }
+            return Integer.parseInt(String.valueOf(v));
+        } catch (Exception e) {
+            return 0;
+        }
+    }
+
+    private void applyRemaining(ReportAnalyzeQuotaDTO dto) {
+        if (dto.isUnlimited()) {
+            dto.setRemaining(-1);
+            dto.setExhausted(false);
+            return;
+        }
+        int remaining = Math.max(0, dto.getTotal() - dto.getUsed());
+        dto.setRemaining(remaining);
+        dto.setExhausted(remaining <= 0);
+        if (dto.isExhausted()) {
+            dto.setActionUrl(UPGRADE_URL);
+        }
+    }
+
+    private String counterKey(Long userId) {
+        return COUNTER_KEY_PREFIX + userId + ":" + LocalDate.now().format(PERIOD_FMT);
+    }
+
+    private String sessionKey(String conversationId) {
+        if (conversationId == null || conversationId.trim().isEmpty()) {
+            return null;
+        }
+        return SESSION_KEY_PREFIX + LocalDate.now().format(PERIOD_FMT) + ":" + conversationId.trim();
+    }
+}

+ 197 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ReportAnalyzeQuotaServiceTest.java

@@ -0,0 +1,197 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ReportAnalyzeQuotaDTO;
+import com.etotem.cfc.entity.MembershipLevel;
+import com.etotem.cfc.mapper.MembershipLevelMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.SetOperations;
+import org.springframework.data.redis.core.ValueOperations;
+
+import java.lang.reflect.Field;
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * ReportAnalyzeQuotaService 单元测试(纯 Mockito,无 SpringBootTest、无真实 Redis)。
+ *
+ * 覆盖验收口径:
+ * <ul>
+ *   <li>免费版当日第 2 次被拒(文案「今日已用完」,与付费版「请升级会员」可区分);</li>
+ *   <li>计数 key 到期(次日新 key)后免费额度自动恢复;key 格式 report:analyze:{userId}:{yyyyMMdd};</li>
+ *   <li>付费版多轮:同一 conversationId 只计 1 次;</li>
+ *   <li>付费版超限被拒(文案「请升级会员」)且回滚会话标记;</li>
+ *   <li>付费版上限 0 = 不限次:放行且不落计数器;</li>
+ *   <li>只读查询不扣减次数。</li>
+ * </ul>
+ */
+public class ReportAnalyzeQuotaServiceTest {
+
+    @Mock
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Mock
+    private ValueOperations<String, Object> valueOps;
+
+    @Mock
+    private SetOperations<String, Object> setOps;
+
+    @Mock
+    private MembershipService membershipService;
+
+    @Mock
+    private MembershipLevelMapper membershipLevelMapper;
+
+    private ReportAnalyzeQuotaService service;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        ReportAnalyzeQuotaService raw = new ReportAnalyzeQuotaService();
+        setField(raw, "redisTemplate", redisTemplate);
+        setField(raw, "membershipService", membershipService);
+        setField(raw, "membershipLevelMapper", membershipLevelMapper);
+        service = raw;
+
+        when(redisTemplate.opsForValue()).thenReturn(valueOps);
+        when(redisTemplate.opsForSet()).thenReturn(setOps);
+        when(redisTemplate.expire(anyString(), any(Duration.class))).thenReturn(Boolean.TRUE);
+    }
+
+    private void setField(Object target, String fieldName, Object value) {
+        try {
+            Field f = target.getClass().getDeclaredField(fieldName);
+            f.setAccessible(true);
+            f.set(target, value);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private MembershipLevel level(String code, Integer maxDailyAnalyze) {
+        MembershipLevel lv = new MembershipLevel();
+        lv.setLevelCode(code);
+        lv.setMaxDailyAnalyze(maxDailyAnalyze);
+        return lv;
+    }
+
+    // ── 免费版 ─────────────────────────────────────────────────────────
+
+    @Test
+    void freeUser_secondCallSameDay_rejectedWithTodayExhaustedMessage() {
+        when(membershipService.getMemberLevel(1L)).thenReturn("FREE");
+        when(valueOps.increment(anyString())).thenReturn(1L, 2L);
+
+        ReportAnalyzeQuotaDTO first = service.consume(1L, null);
+        assertTrue(first.isAllowed());
+        assertEquals(1, first.getTotal());
+        assertEquals(0, first.getRemaining());
+
+        ReportAnalyzeQuotaDTO second = service.consume(1L, null);
+        assertFalse(second.isAllowed());
+        assertTrue(second.getMessage().contains("今日已用完"));
+        assertTrue(second.getMessage().contains("免费版"));
+        assertFalse(second.getMessage().contains("请升级会员"));
+        assertEquals(ReportAnalyzeQuotaService.UPGRADE_URL, second.getActionUrl());
+        assertTrue(second.isExhausted());
+        // 免费版读等级配置为硬编码 1,不查配置表
+        verify(membershipLevelMapper, never()).selectOne(any());
+    }
+
+    @Test
+    void freeUser_counterExpired_nextDay_allowedAgain_withDatedKey() {
+        when(membershipService.getMemberLevel(1L)).thenReturn("FREE");
+        // key 到期失效后重新 INCR 从 1 开始(次日为新 key)
+        when(valueOps.increment(anyString())).thenReturn(1L, 1L);
+
+        assertTrue(service.consume(1L, null).isAllowed());
+        assertTrue(service.consume(1L, null).isAllowed());
+
+        ArgumentCaptor<String> keyCaptor = ArgumentCaptor.forClass(String.class);
+        verify(valueOps, times(2)).increment(keyCaptor.capture());
+        assertTrue(keyCaptor.getAllValues().get(0).matches("report:analyze:1:\\d{8}"),
+                "计数器 key 应为 report:analyze:{userId}:{yyyyMMdd},实际=" + keyCaptor.getAllValues().get(0));
+    }
+
+    // ── 付费版 ─────────────────────────────────────────────────────────
+
+    @Test
+    void paidBasic_sameConversation_multiRound_chargedOnlyOnce() {
+        when(membershipService.getMemberLevel(2L)).thenReturn("BASIC");
+        when(membershipLevelMapper.selectOne(any())).thenReturn(level("BASIC", 3));
+        // SADD:首次加入返回 1(已计费),同一会话再次加入返回 0(已计过费)
+        when(setOps.add(anyString(), any())).thenReturn(1L, 0L);
+        when(valueOps.increment(anyString())).thenReturn(1L);
+        when(valueOps.get(anyString())).thenReturn(1);
+
+        ReportAnalyzeQuotaDTO first = service.consume(2L, "conv-1");
+        assertTrue(first.isAllowed());
+        assertEquals(2, first.getRemaining());
+
+        // 同一会话第二轮:放行但不重复扣次数
+        ReportAnalyzeQuotaDTO second = service.consume(2L, "conv-1");
+        assertTrue(second.isAllowed());
+        assertEquals(2, second.getRemaining());
+        verify(valueOps, times(1)).increment(anyString());
+    }
+
+    @Test
+    void paidBasic_quotaExhausted_rejectedWithUpgradeMessage_andSessionRolledBack() {
+        when(membershipService.getMemberLevel(3L)).thenReturn("BASIC");
+        when(membershipLevelMapper.selectOne(any())).thenReturn(level("BASIC", 3));
+        when(setOps.add(anyString(), any())).thenReturn(1L);
+        when(valueOps.increment(anyString())).thenReturn(4L);
+
+        ReportAnalyzeQuotaDTO result = service.consume(3L, "conv-x");
+        assertFalse(result.isAllowed());
+        assertTrue(result.getMessage().contains("请升级会员"));
+        assertFalse(result.getMessage().contains("今日已用完"));
+        assertEquals(ReportAnalyzeQuotaService.UPGRADE_URL, result.getActionUrl());
+        // 本次未扣成功 → 回滚会话标记,同一会话仍可再次作为新一轮计费
+        verify(setOps).remove(anyString(), any());
+    }
+
+    @Test
+    void paidFamily_limitZero_unlimited_allowedWithoutCounting() {
+        when(membershipService.getMemberLevel(4L)).thenReturn("FAMILY");
+        when(membershipLevelMapper.selectOne(any())).thenReturn(level("FAMILY", 0));
+
+        ReportAnalyzeQuotaDTO result = service.consume(4L, "conv-1");
+        assertTrue(result.isAllowed());
+        assertTrue(result.isUnlimited());
+        assertEquals(-1, result.getRemaining());
+        assertFalse(result.isExhausted());
+        verify(valueOps, never()).increment(anyString());
+        verifyNoInteractions(setOps);
+    }
+
+    // ── 只读查询 ───────────────────────────────────────────────────────
+
+    @Test
+    void getStatus_reportsRemaining_withoutConsuming() {
+        when(membershipService.getMemberLevel(5L)).thenReturn("FREE");
+        when(valueOps.get(anyString())).thenReturn(0);
+
+        ReportAnalyzeQuotaDTO status = service.getStatus(5L);
+        assertEquals("FREE", status.getLevelCode());
+        assertEquals(1, status.getTotal());
+        assertEquals(1, status.getRemaining());
+        assertFalse(status.isExhausted());
+        assertTrue(status.isAllowed());
+        verify(valueOps, never()).increment(anyString());
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-c760c15e973c1f45fd8c875561a8a8c2d62c4aa2
+fcdec6fbc5ef2ef6381792699e5bc22a35d60e69

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1782",
+  "version": "1.0.1783",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.1782",
+      "version": "1.0.1783",
       "dependencies": {
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1783",
+  "version": "1.0.1784",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "dev": "vue-cli-service serve",
     "dev": "vue-cli-service serve",

+ 115 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,121 @@
 
 
 ---
 ---
 
 
+## v1.0.1784 (2026-10-24)
+
+### 重构
+- F-6 存量迁移 三账本合一(迁移417)
+- W0.3 审计日志统一(audit_log 表 + AOP 切面 + 查询接口,迁移 414)
+- F-2 CF值统一账本服务(CfBalanceService + 统一发放入口 + 迁移416)
+- W0.2 双配置中心合并,service_config 收编 product_config
+
+### 其他
+- 
+- 并提供统一发放入口 earnByRule(memberId, ruleCode, refId, ctx):读规则(启用+生效期+params_json)
+- → 会员等级日限/累计上限/冷却三道闸 → 公式/固定/逐人数量计算 → 幂等入账。
+- 要点:
+- - 并发安全:所有变更先 SELECT ... FOR UPDATE 锁余额行,事务内更新余额并写流水;
+- - 幂等:(member_id, type, ref_type, ref_id) 唯一键 uk_ref_type 兜底,命中返回原结果;
+-   拦截类跳过写 type=skip、amount=0 流水可追溯(今日已发放次数仅统计 type=earn);
+- - 分→CF 换算由 params.amt_unit=cent + cf.rate.cf_per_yuan 驱动(F02/F06/F18,1元=100CF),
+-   公式经白名单求值器 CfFormulaEvaluator(floor/ceil/round/min/max/abs/cfg),业务数值不落代码;
+- - 管理员调整 ref_type=admin_adjust 并留痕 operator_user_id。
+- 数据层:迁移416 补 operator_user_id、幂等键升级为 (member_id,type,ref_type,ref_id)、
+- 种子 cf.withdraw_enabled=false / cf.rate.cf_per_yuan=100;schema.sql 同步。
+- 迁移编号说明(主理人 2026-10-25 编排):407-412(F-1)、413(W0.2 product_config 收编)、
+- 414(W0.3 审计)、415(F-6 存量迁移 lan-204)均已占用,本 F-2 使用 416。
+- 测试:CfBalanceServiceTest 20 例 + CfFormulaEvaluatorTest 8 例全部通过;mvn clean compile 通过。
+- 
+-   若目标环境存在 product_config 表则按 product.{productKey}.{key} 规则并入(product_key→product_code→product_id→group_key→default 标识规则),表缺失时幂等空跑;同步 schema.sql
+- - ServiceConfigItem 新增 productKey 字段;ConfigService 支持按 productKey 过滤列表明细 + product 侧配置 get/set(读 service_config WHERE product_key=?)
+- - AdminServiceConfigController /list、/save 支持 productKey 维度
+- - 新增 AdminProductConfigController:product_config 写接口 410 Gone(30 天只读回滚窗口,2026-11-23 可删表)
+- - cfc-web 配置中心统一入口(ConfigCenter.vue + 路由 + 菜单),product_key 可筛选;out/ 备份脚本
+- 注:本基线(origin/cfclub)与测试库不存在 product_config 表/实体,迁移数据并入为空跑(0→0),
+- ProductConfigServiceImpl 亦不存在,step4 以 ConfigService 同等语义实现。
+- 
+- - 新建 8 表: cf_balance / cf_balance_log / cf_earn_rule(+24行F02-F24种子) / cf_earn_rule_history / cf_direct_sales_tier / cf_indirect_sales_tier / cf_earn_daily_limit(+N11 20行) / service_config
+- - coupon / user_coupon 补 family_id(优惠券家庭化 N3)
+- - DatabaseInitializer 迁移 407-412,全部 try-catch + 存在性判断,可重复执行
+- - schema.sql 同步建表定义
+- - 实体 + Mapper(@TableName/@TableId AUTO,MapperScan 覆盖)
+- 配置治理(W0.1):
+- - service_config 10 项 CF 通用运营参数 + cf.version
+- - sys_config/products 散落参数迁入 service_config,旧位置标记(不删值,过渡兼容)
+- - ConfigService.getConfig(本地缓存 TTL 60s → DB → 兜底常量)+ CfConfigCache.invalidate
+- - 新增 /api/admin/service-config 与 /api/admin/cf-earn-rule 配置接口,save/toggle 挂缓存失效 + 规则变更留痕
+- - L1 常量类 OrderConstants / AdminConstants(仅 CF 无关技术参数)
+- 不含任何发放/扣减业务逻辑(F-2/F-3 批)
+- 
+- 
+- - FamilyPlatformPointsService.exchangeCouponByCf: 返回券实例ID(family_coupon.id);FamilyPlatformBalanceController 透传到 Result.data
+- - checkout.vue doExchangeCoupon: 兑换成功后按实例ID在 /coupon/list 结果中精确匹配并自动选中,交服务端 applyCouponDiscount 计算折扣
+- 
+- 
+- 统一家庭成员口径:memberType 赋值 + family_members userId 去重兜底
+-   同一 userId 多条记录只保留一条(优先 status=active + updated_at 最新)
+-   userId 为 null 的孤儿记录保持原样(local 成员)
+- - 补全 memberType 赋值:userId 非空且关联 User 有 openid → 'login';否则 → 'local'
+-   parent 段维持 'parent' 不变
+- - 保持 isSelf 唯一标记逻辑不变
+- - UserService.getVisibleMembers() 两段去重逻辑保持正确(复用 listMembers)
+- - mvn clean compile 验证通过
+- - 不改 schema、不新增接口
+- 
+- 2) 海报模板 layoutConfig 扩展(迁移404):entity+service JSON 校验+schema.sql;ActivityPoster.vue 全量读取 layout 配置(9 元素组/25+ 字段),视觉增强(品牌头分隔线、封面圆角、标题 accent、二维码白底卡片+CTA 胶囊、footer 自适应颜色);cfc-web PosterTemplateManage.vue 布局 JSON 编辑器
+- 3) 详情页美化沿用既有卡片化排版;PROJECT-OVERVIEW.md 索引同步
+- 
+- - Bug2: onShow 从子页返回时改走 refresh 语义(重置页码+清空列表再加载),并加请求序号丢弃过期响应,返回详情后列表不再翻倍
+- - load* 补齐 Promise 返回,修复 onRefresh .finally 的 undefined 隐患
+- 
+- 且限定 created_at 为今日,导致关闭的不是用户点击的那条任务实例(或查不到直接跳过)。
+- 改动:新增 TaskService.updateCheckinTaskProgress(taskId, ...),按前端透传的具体
+- taskId 精确定位实例,累计进度达标后复用 awardTaskCompletionRewards 置 completed 并发奖;
+- 睡眠/运动/饮食三个打卡 Controller 均切到新方法(未带 taskId 的兜底路径行为不变)。
+- 次日由 RepeatTaskGenerator 重新生成当日实例,卡片正常重现。
+- 
+- - BasicQuotaService 改日/月双周期配额:AI 对话每天 3 次、饮食识别每天 3 次、报告解读每月 1 次
+- - consumeMemberQuota 按等级分流,FAMILY+ 不落配额;QuotaStatusDTO 增加 unlimited 供前端判断
+- - 测评预约去配额(凭证已购额度预约)、报告上传全员开放、每日任务不限(max_tasks_per_day=0)
+- - 会员价:min_discount_level 为空时默认 BASIC,修复 FREE 也能享会员价的漏洞;活动侧纳入 BASIC
+- - 迁移 405 同步等级/权益/配额口径(原计划编号 382 已被占用)
+- 前端:
+- - AI 对话/饮食识别配额展示改日口径并按 unlimited 隐藏(FAMILY+ 不显示也不拦截)
+- - 升级引导弹窗按日/月双口径出文案,去掉「AI 顾问」「每月3次」旧口径
+- 
+- #2 详情页返回列表不再翻倍:loadData() 统一为「重置页码+清空+加载第一页」
+- #3 补齐活动简介录入(create.vue 表单/回填/海报 intro 入参),海报预览与后台模板管理此前已完成
+- #4 审核中心顶部待审数不减少:ReviewCenter.vue 13 个 Tab 绑定 @refresh-summary,各子 Tab 审核后 emit
+- #5 日期时间统一 yyyy-MM-dd HH:mm(复用 utils/format.js);管理端 4 处 UTC 偏移串少 8 小时改为 fmtLocalTime 转 Asia/Shanghai
+- #6 重复报名误报「网络异常」:新增 showApiError 区分后端业务错误(如已报名)与真实网络错误
+- #7 活动详情页美化
+- #8 我的活动列表纳入已报名活动
+- 
+- - index-home:loadFamilyData 的 catch 路径补调 loadTodayTasks(),即使获取家庭成员列表失败,从打卡页返回后也能刷新任务列表,确保已完成任务卡片消失
+- - 统一从 ../../utils/api.js 引入 completeTask as completeTaskApi(与 index-home 别名一致)
+- - 新增修复报告 docs/superpowers/plans/verify/fix-homepage-task-close.md
+- 
+
+### 新功能
+- F-1 数据层 + W0.1 配置归口(cf_balance/cf_earn_rule/service_config + 迁移407-412)
+- 新增过期优惠券物理清理定时任务
+- 下单页兑换CF券后自动选中并立即使用
+- 活动详情重设计三合一
+- 9.9 元体验会员(BASIC)权益口径修正与会员价机制
+
+### Bug 修复
+- 心愿券单券日限次生效 + 结算页改用服务端抵扣
+- 修复 4 问题(formatDate/任务不关闭/计数/成员切换),9 文件
+- :活动时间收敛为 5 分钟粒度(只到分,小程序+后台)
+- 发现活动列表摘要纯文本化 + 详情返回不再重复追加
+- 修复首页每日打卡任务完成后卡片不关闭
+- 活动模块 8 项问题收口(列表/返回/简介/海报/日期/报名/审核计数/我的活动)
+- 修复首页每日任务打卡后卡片不消失
+
+### 文档
+- 任务5配额状态接口与定时任务独立核验通过(verify-task5)
+
+
 ## v1.0.1782 (2026-10-23)
 ## v1.0.1782 (2026-10-23)
 
 
 ### Bug 修复
 ### Bug 修复

+ 116 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1782
+> 当前版本: v1.0.1784
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,121 @@
 
 
 ---
 ---
 
 
+## v1.0.1784 (2026-10-24)
+
+### 重构
+- F-6 存量迁移 三账本合一(迁移417)
+- W0.3 审计日志统一(audit_log 表 + AOP 切面 + 查询接口,迁移 414)
+- F-2 CF值统一账本服务(CfBalanceService + 统一发放入口 + 迁移416)
+- W0.2 双配置中心合并,service_config 收编 product_config
+
+### 其他
+- 
+- 并提供统一发放入口 earnByRule(memberId, ruleCode, refId, ctx):读规则(启用+生效期+params_json)
+- → 会员等级日限/累计上限/冷却三道闸 → 公式/固定/逐人数量计算 → 幂等入账。
+- 要点:
+- - 并发安全:所有变更先 SELECT ... FOR UPDATE 锁余额行,事务内更新余额并写流水;
+- - 幂等:(member_id, type, ref_type, ref_id) 唯一键 uk_ref_type 兜底,命中返回原结果;
+-   拦截类跳过写 type=skip、amount=0 流水可追溯(今日已发放次数仅统计 type=earn);
+- - 分→CF 换算由 params.amt_unit=cent + cf.rate.cf_per_yuan 驱动(F02/F06/F18,1元=100CF),
+-   公式经白名单求值器 CfFormulaEvaluator(floor/ceil/round/min/max/abs/cfg),业务数值不落代码;
+- - 管理员调整 ref_type=admin_adjust 并留痕 operator_user_id。
+- 数据层:迁移416 补 operator_user_id、幂等键升级为 (member_id,type,ref_type,ref_id)、
+- 种子 cf.withdraw_enabled=false / cf.rate.cf_per_yuan=100;schema.sql 同步。
+- 迁移编号说明(主理人 2026-10-25 编排):407-412(F-1)、413(W0.2 product_config 收编)、
+- 414(W0.3 审计)、415(F-6 存量迁移 lan-204)均已占用,本 F-2 使用 416。
+- 测试:CfBalanceServiceTest 20 例 + CfFormulaEvaluatorTest 8 例全部通过;mvn clean compile 通过。
+- 
+-   若目标环境存在 product_config 表则按 product.{productKey}.{key} 规则并入(product_key→product_code→product_id→group_key→default 标识规则),表缺失时幂等空跑;同步 schema.sql
+- - ServiceConfigItem 新增 productKey 字段;ConfigService 支持按 productKey 过滤列表明细 + product 侧配置 get/set(读 service_config WHERE product_key=?)
+- - AdminServiceConfigController /list、/save 支持 productKey 维度
+- - 新增 AdminProductConfigController:product_config 写接口 410 Gone(30 天只读回滚窗口,2026-11-23 可删表)
+- - cfc-web 配置中心统一入口(ConfigCenter.vue + 路由 + 菜单),product_key 可筛选;out/ 备份脚本
+- 注:本基线(origin/cfclub)与测试库不存在 product_config 表/实体,迁移数据并入为空跑(0→0),
+- ProductConfigServiceImpl 亦不存在,step4 以 ConfigService 同等语义实现。
+- 
+- - 新建 8 表: cf_balance / cf_balance_log / cf_earn_rule(+24行F02-F24种子) / cf_earn_rule_history / cf_direct_sales_tier / cf_indirect_sales_tier / cf_earn_daily_limit(+N11 20行) / service_config
+- - coupon / user_coupon 补 family_id(优惠券家庭化 N3)
+- - DatabaseInitializer 迁移 407-412,全部 try-catch + 存在性判断,可重复执行
+- - schema.sql 同步建表定义
+- - 实体 + Mapper(@TableName/@TableId AUTO,MapperScan 覆盖)
+- 配置治理(W0.1):
+- - service_config 10 项 CF 通用运营参数 + cf.version
+- - sys_config/products 散落参数迁入 service_config,旧位置标记(不删值,过渡兼容)
+- - ConfigService.getConfig(本地缓存 TTL 60s → DB → 兜底常量)+ CfConfigCache.invalidate
+- - 新增 /api/admin/service-config 与 /api/admin/cf-earn-rule 配置接口,save/toggle 挂缓存失效 + 规则变更留痕
+- - L1 常量类 OrderConstants / AdminConstants(仅 CF 无关技术参数)
+- 不含任何发放/扣减业务逻辑(F-2/F-3 批)
+- 
+- 
+- - FamilyPlatformPointsService.exchangeCouponByCf: 返回券实例ID(family_coupon.id);FamilyPlatformBalanceController 透传到 Result.data
+- - checkout.vue doExchangeCoupon: 兑换成功后按实例ID在 /coupon/list 结果中精确匹配并自动选中,交服务端 applyCouponDiscount 计算折扣
+- 
+- 
+- 统一家庭成员口径:memberType 赋值 + family_members userId 去重兜底
+-   同一 userId 多条记录只保留一条(优先 status=active + updated_at 最新)
+-   userId 为 null 的孤儿记录保持原样(local 成员)
+- - 补全 memberType 赋值:userId 非空且关联 User 有 openid → 'login';否则 → 'local'
+-   parent 段维持 'parent' 不变
+- - 保持 isSelf 唯一标记逻辑不变
+- - UserService.getVisibleMembers() 两段去重逻辑保持正确(复用 listMembers)
+- - mvn clean compile 验证通过
+- - 不改 schema、不新增接口
+- 
+- 2) 海报模板 layoutConfig 扩展(迁移404):entity+service JSON 校验+schema.sql;ActivityPoster.vue 全量读取 layout 配置(9 元素组/25+ 字段),视觉增强(品牌头分隔线、封面圆角、标题 accent、二维码白底卡片+CTA 胶囊、footer 自适应颜色);cfc-web PosterTemplateManage.vue 布局 JSON 编辑器
+- 3) 详情页美化沿用既有卡片化排版;PROJECT-OVERVIEW.md 索引同步
+- 
+- - Bug2: onShow 从子页返回时改走 refresh 语义(重置页码+清空列表再加载),并加请求序号丢弃过期响应,返回详情后列表不再翻倍
+- - load* 补齐 Promise 返回,修复 onRefresh .finally 的 undefined 隐患
+- 
+- 且限定 created_at 为今日,导致关闭的不是用户点击的那条任务实例(或查不到直接跳过)。
+- 改动:新增 TaskService.updateCheckinTaskProgress(taskId, ...),按前端透传的具体
+- taskId 精确定位实例,累计进度达标后复用 awardTaskCompletionRewards 置 completed 并发奖;
+- 睡眠/运动/饮食三个打卡 Controller 均切到新方法(未带 taskId 的兜底路径行为不变)。
+- 次日由 RepeatTaskGenerator 重新生成当日实例,卡片正常重现。
+- 
+- - BasicQuotaService 改日/月双周期配额:AI 对话每天 3 次、饮食识别每天 3 次、报告解读每月 1 次
+- - consumeMemberQuota 按等级分流,FAMILY+ 不落配额;QuotaStatusDTO 增加 unlimited 供前端判断
+- - 测评预约去配额(凭证已购额度预约)、报告上传全员开放、每日任务不限(max_tasks_per_day=0)
+- - 会员价:min_discount_level 为空时默认 BASIC,修复 FREE 也能享会员价的漏洞;活动侧纳入 BASIC
+- - 迁移 405 同步等级/权益/配额口径(原计划编号 382 已被占用)
+- 前端:
+- - AI 对话/饮食识别配额展示改日口径并按 unlimited 隐藏(FAMILY+ 不显示也不拦截)
+- - 升级引导弹窗按日/月双口径出文案,去掉「AI 顾问」「每月3次」旧口径
+- 
+- #2 详情页返回列表不再翻倍:loadData() 统一为「重置页码+清空+加载第一页」
+- #3 补齐活动简介录入(create.vue 表单/回填/海报 intro 入参),海报预览与后台模板管理此前已完成
+- #4 审核中心顶部待审数不减少:ReviewCenter.vue 13 个 Tab 绑定 @refresh-summary,各子 Tab 审核后 emit
+- #5 日期时间统一 yyyy-MM-dd HH:mm(复用 utils/format.js);管理端 4 处 UTC 偏移串少 8 小时改为 fmtLocalTime 转 Asia/Shanghai
+- #6 重复报名误报「网络异常」:新增 showApiError 区分后端业务错误(如已报名)与真实网络错误
+- #7 活动详情页美化
+- #8 我的活动列表纳入已报名活动
+- 
+- - index-home:loadFamilyData 的 catch 路径补调 loadTodayTasks(),即使获取家庭成员列表失败,从打卡页返回后也能刷新任务列表,确保已完成任务卡片消失
+- - 统一从 ../../utils/api.js 引入 completeTask as completeTaskApi(与 index-home 别名一致)
+- - 新增修复报告 docs/superpowers/plans/verify/fix-homepage-task-close.md
+- 
+
+### 新功能
+- F-1 数据层 + W0.1 配置归口(cf_balance/cf_earn_rule/service_config + 迁移407-412)
+- 新增过期优惠券物理清理定时任务
+- 下单页兑换CF券后自动选中并立即使用
+- 活动详情重设计三合一
+- 9.9 元体验会员(BASIC)权益口径修正与会员价机制
+
+### Bug 修复
+- 心愿券单券日限次生效 + 结算页改用服务端抵扣
+- 修复 4 问题(formatDate/任务不关闭/计数/成员切换),9 文件
+- :活动时间收敛为 5 分钟粒度(只到分,小程序+后台)
+- 发现活动列表摘要纯文本化 + 详情返回不再重复追加
+- 修复首页每日打卡任务完成后卡片不关闭
+- 活动模块 8 项问题收口(列表/返回/简介/海报/日期/报名/审核计数/我的活动)
+- 修复首页每日任务打卡后卡片不消失
+
+### 文档
+- 任务5配额状态接口与定时任务独立核验通过(verify-task5)
+
+
 ## v1.0.1782 (2026-10-23)
 ## v1.0.1782 (2026-10-23)
 
 
 ### Bug 修复
 ### Bug 修复