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

feat(membership): 9.9 元体验会员(BASIC)权益口径修正与会员价机制

后端:
- 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次」旧口径
zxyj-web 2 недель назад
Родитель
Сommit
744f15debe
21 измененных файлов с 1127 добавлено и 190 удалено
  1. 50 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 7 6
      cfc-backend/src/main/java/com/etotem/cfc/controller/BasicQuotaController.java
  3. 2 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  4. 3 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminBasicQuotaController.java
  5. 3 2
      cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java
  6. 6 1
      cfc-backend/src/main/java/com/etotem/cfc/dto/QuotaStatusDTO.java
  7. 10 61
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivationQuotaService.java
  8. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
  9. 1 10
      cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentAppointmentService.java
  10. 202 63
      cfc-backend/src/main/java/com/etotem/cfc/service/BasicQuotaService.java
  11. 19 11
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  12. 8 9
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  13. 9 7
      cfc-backend/src/main/java/com/etotem/cfc/task/BasicQuotaScheduledTasks.java
  14. 10 7
      cfc-backend/src/main/resources/schema.sql
  15. 4 4
      cfc-frontend/pages/ai/chat.vue
  16. 5 3
      cfc-frontend/pages/ai/components/UpgradeGuideModal.vue
  17. 27 1
      cfc-frontend/pages/growth/diet-checkin.vue
  18. 230 0
      docs/superpowers/plans/2026-10-23-basic-membership-spec-revision.md
  19. 102 0
      docs/superpowers/plans/verify/exec-task5.md
  20. 211 0
      docs/superpowers/plans/verify/exec-task6.md
  21. 216 0
      docs/superpowers/plans/verify/verify-task6.md

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10977,6 +10977,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         backfillMembershipLevelOrder();
         fixMembershipLevelOrder();
         removeReportUploadQuota();
+        applyBasicMembership20261023Spec();
 
         createProductDimensionMappingTable();
         createProductRecommendationLogTable();
@@ -11885,6 +11886,55 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    /**
+     * 迁移405: 9.9 元体验会员(BASIC)权益口径修正(2026-10-23 用户定稿,单一事实源)。
+     * <ul>
+     *   <li>AI 对话:每天 3 次(原"AI健康顾问"月配额口径,展示名同步改为"AI 对话");</li>
+     *   <li>饮食拍照识别:每天 3 次;</li>
+     *   <li>报告解读:保留每月 1 次;</li>
+     *   <li>测评预约:不是特殊功能 → 移除 assessment_quota_lite 与 ASSESSMENT_QUOTA 配额(预约凭已购测评额度);</li>
+     *   <li>报告上传:全员开放 → 移除 health_report_upload_lite;</li>
+     *   <li>每日任务:不限 → max_tasks_per_day=0;</li>
+     *   <li>指定商品会员价:discount_purchase 生效(products.member_price + min_discount_level 机制)。</li>
+     * </ul>
+     * 配额周期粒度(日/月)由 BasicQuotaService.QUOTA_PERIODS 按类型判定,本迁移同步 features/权益/描述。
+     */
+    private void applyBasicMembership20261023Spec() {
+        try {
+            jdbcTemplate.update(
+                    "UPDATE membership_levels SET level_desc = ?, features = ?, max_tasks_per_day = 0 " +
+                            "WHERE level_code = 'BASIC'",
+                    "9.9元/月体验版:AI对话每天3次、饮食识别每天3次、报告解读每月1次,享指定商品会员价",
+                    "[\"free_activities\",\"free_courses\",\"discount_purchase\",\"invite_family\",\"mascot_ai_lite\",\"ai_report_reading_lite\",\"growth_plan_lite\",\"task_template_lite\",\"referral_commission\"]");
+            log.info("迁移405: 已更新 BASIC 等级口径(features/max_tasks_per_day/level_desc)");
+        } catch (Exception e) {
+            log.warn("迁移405: 更新 BASIC 等级失败: {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.update(
+                    "UPDATE membership_benefits SET benefit_name = ?, benefit_desc = ? " +
+                            "WHERE level_code = 'BASIC' AND benefit_code = 'AI_CHAT_LITE'",
+                    "AI对话(限3次/天)", "每天3次AI对话问答");
+            jdbcTemplate.update(
+                    "DELETE FROM membership_benefits WHERE level_code = 'BASIC' AND benefit_code = 'ASSESSMENT_QUOTA_LITE'");
+            jdbcTemplate.execute(
+                    "INSERT IGNORE INTO membership_benefits (level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES " +
+                            "('BASIC', 'FOOD_RECOGNIZE_LITE', '饮食拍照识别(限3次/天)', '每天3次食材图片识别', '📷', '#FEF9C3', 2), " +
+                            "('BASIC', 'MEMBER_PRICE', '指定商品会员价', '购买指定商品享受会员价', '🏷️', '#FEF3C7', 5)");
+            log.info("迁移405: 已更新 BASIC 权益列表(AI对话更名/去测评预约/增识别与会员价)");
+        } catch (Exception e) {
+            log.warn("迁移405: 更新 BASIC 权益失败: {}", e.getMessage());
+        }
+        // 配额记录切新制度:ASSESSMENT_QUOTA 整体移除;AI_CHAT/FOOD_RECOGNIZE 月记录改按日懒重建(删除旧记录避免月初日/月周期重合碰撞)
+        try {
+            int deleted = jdbcTemplate.update(
+                    "DELETE FROM basic_quota_usage WHERE quota_type IN ('ASSESSMENT_QUOTA','AI_CHAT','FOOD_RECOGNIZE')");
+            log.info("迁移405: 已清理旧口径配额记录({} 行)", deleted);
+        } catch (Exception e) {
+            log.warn("迁移405: 清理配额记录失败: {}", e.getMessage());
+        }
+    }
+
     // 迁移353: 任务提醒配置表(用户级,一行/用户;提醒渠道/时机/偏移分钟可配置)
     private void createTaskReminderConfigTable() {
         try {

+ 7 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/BasicQuotaController.java

@@ -17,7 +17,7 @@ public class BasicQuotaController {
     private BasicQuotaService basicQuotaService;
 
     /**
-     * 查询当前用户某配额的剩余次数
+     * 查询当前用户某配额的剩余次数(日配额看今天,月配额看本月)
      */
     @PostMapping("/status")
     public Result<QuotaStatusDTO> getQuotaStatus(
@@ -27,20 +27,21 @@ public class BasicQuotaController {
         if (quotaType == null) {
             return Result.error(400, "quotaType 不能为空");
         }
-        QuotaStatusDTO dto = basicQuotaService.getMonthlyQuota(userId, quotaType);
+        QuotaStatusDTO dto = basicQuotaService.getQuotaStatus(userId, quotaType);
         return Result.success(dto);
     }
 
     /**
      * 批量查询当前用户所有配额状态
+     * 2026-10-23 新口径:AI_CHAT/FOOD_RECOGNIZE 为日配额,REPORT_READING 为月配额;ASSESSMENT_QUOTA 已移除
      */
     @PostMapping("/status/all")
     public Result<Map<String, QuotaStatusDTO>> getAllQuotaStatus(
             @RequestAttribute("userId") Long userId) {
         Map<String, QuotaStatusDTO> result = new HashMap<>();
-        result.put("AI_CHAT", basicQuotaService.getMonthlyQuota(userId, "AI_CHAT"));
-        result.put("REPORT_READING", basicQuotaService.getMonthlyQuota(userId, "REPORT_READING"));
-        result.put("ASSESSMENT_QUOTA", basicQuotaService.getMonthlyQuota(userId, "ASSESSMENT_QUOTA"));
+        result.put("AI_CHAT", basicQuotaService.getQuotaStatus(userId, "AI_CHAT"));
+        result.put("FOOD_RECOGNIZE", basicQuotaService.getQuotaStatus(userId, "FOOD_RECOGNIZE"));
+        result.put("REPORT_READING", basicQuotaService.getQuotaStatus(userId, "REPORT_READING"));
         return Result.success(result);
     }
-}
+}

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -2750,9 +2750,9 @@ public class HealthReportController {
      * 触发 AI 解读
      */
     private void triggerAiAnalysis(Long reportId, Long subjectId, Long proxyUploaderId) {
-        // BASIC 配额检查(报告 AI 解读)
+        // BASIC 配额检查(报告 AI 解读,2026-10-23 新口径:BASIC 1 次/月;FAMILY 及以上不限次;FREE 维持既有口径扣减)
         if (proxyUploaderId != null) {
-            com.etotem.cfc.dto.QuotaConsumeResult quotaResult = basicQuotaService.consumeQuota(proxyUploaderId, "REPORT_READING");
+            com.etotem.cfc.dto.QuotaConsumeResult quotaResult = basicQuotaService.consumeMemberQuota(proxyUploaderId, "REPORT_READING", false);
             if (!quotaResult.isSuccess()) {
                 log.info("报告 AI 解读配额不足: userId={}, toast={}", proxyUploaderId, quotaResult.getToastMsg());
                 return;

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminBasicQuotaController.java

@@ -15,13 +15,14 @@ public class AdminBasicQuotaController {
     private BasicQuotaService basicQuotaService;
 
     /**
-     * 运营手动重置指定用户的当月配额(应急用)
+     * 兜底创建当前周期配额记录,不重置用量。
+     * 运营手动触发用于解决个别用户配额记录缺失问题(应急用)。
      */
     @PostMapping("/reset")
     public Result<String> resetQuota(@RequestBody Map<String, Object> body) {
         Long userId = Long.valueOf(body.get("userId").toString());
         String quotaType = body.get("quotaType") != null ? body.get("quotaType").toString() : null;
-        basicQuotaService.refreshMonthlyQuota(); // 重置所有用户当月配额
+        basicQuotaService.refreshQuotas(); // 兜底创建当前周期配额记录,不重置用量
         return Result.success("配额刷新完成");
     }
 }

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java

@@ -130,13 +130,14 @@ public class ActivityDTO {
 
     /**
      * Resolve final price based on membership level.
-     * Members (FAMILY/PREMIUM) get memberPrice if available, otherwise base price.
+     * Members (BASIC/FAMILY/PREMIUM) get memberPrice if available, otherwise base price.
+     * 2026-10-23:BASIC(9.9 体验会员)纳入会员价范围("享受指定商品的会员价"新口径)。
      * 服务商(PROVIDER)为独立角色,不走会员权益,不享受会员价。
      */
     public static Integer resolveMemberPrice(Integer basePrice, Integer memberPrice, String memberLevel) {
         if (basePrice == null) return null;
         // If user has member level and a memberPrice is set, use memberPrice
-        if (memberLevel != null && ("FAMILY".equals(memberLevel) || "PREMIUM".equals(memberLevel))) {
+        if (memberLevel != null && ("BASIC".equals(memberLevel) || "FAMILY".equals(memberLevel) || "PREMIUM".equals(memberLevel))) {
             if (memberPrice != null) {
                 return memberPrice;
             }

+ 6 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/QuotaStatusDTO.java

@@ -11,4 +11,9 @@ public class QuotaStatusDTO {
     private int remaining;
     private Date periodStart;
     private Date periodEnd;
-}
+    /**
+     * 是否不限次(FAMILY 及以上等级)。
+     * 2026-10-23 新增:避免前端对不限次用户展示"剩余 N 次"进度条/误拦发送。
+     */
+    private Boolean unlimited;
+}

+ 10 - 61
cfc-backend/src/main/java/com/etotem/cfc/service/ActivationQuotaService.java

@@ -1,76 +1,25 @@
 package com.etotem.cfc.service;
 
-import com.etotem.cfc.entity.BasicQuotaUsage;
-import com.etotem.cfc.mapper.BasicQuotaUsageMapper;
-import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
-import java.util.*;
 
-@Slf4j
+/**
+ * BASIC 会员开通时的配额初始化。
+ * 2026-10-23 新口径:配额类型与周期(AI_CHAT 3次/天、FOOD_RECOGNIZE 3次/天、REPORT_READING 1次/月;
+ * ASSESSMENT_QUOTA 已移除)统一由 {@link BasicQuotaService} 维护,本类仅作开通钩子委托,
+ * 避免两处配额表不一致(旧实现自带一份 QUOTA_LIMITS,曾漏 FOOD_RECOGNIZE)。
+ */
 @Service
 public class ActivationQuotaService {
 
     @Resource
-    private BasicQuotaUsageMapper basicQuotaUsageMapper;
-
-    private static final Map<String, Integer> QUOTA_LIMITS = new HashMap<>();
-    static {
-        QUOTA_LIMITS.put("AI_CHAT", 3);
-        QUOTA_LIMITS.put("REPORT_READING", 1);
-        QUOTA_LIMITS.put("ASSESSMENT_QUOTA", 1);
-    }
+    private BasicQuotaService basicQuotaService;
 
     /**
-     * 支付成功后,为 BASIC 用户初始化当月 3 类配额记录
+     * 支付成功后,为 BASIC 用户初始化当前周期的配额记录(used=0)
      */
     public void activateBasicQuota(Long userId, Long familyId) {
-        if (userId == null) {
-            return;
-        }
-        Date now = new Date();
-        Date monthStart = getMonthStart(now);
-        Date monthEnd = getMonthEnd(now);
-
-        for (Map.Entry<String, Integer> entry : QUOTA_LIMITS.entrySet()) {
-            try {
-                BasicQuotaUsage usage = new BasicQuotaUsage();
-                usage.setUserId(userId);
-                usage.setQuotaType(entry.getKey());
-                usage.setUsedCount(0);
-                usage.setTotalCount(entry.getValue());
-                usage.setPeriodStart(monthStart);
-                usage.setPeriodEnd(monthEnd);
-                usage.setCreatedAt(now);
-                usage.setUpdatedAt(now);
-                basicQuotaUsageMapper.insert(usage);
-                log.info("BASIC配额初始化: userId={}, quotaType={}, total={}", userId, entry.getKey(), entry.getValue());
-            } catch (Exception e) {
-                log.warn("BASIC配额初始化失败: userId={}, quotaType={}", userId, entry.getKey(), e);
-            }
-        }
-    }
-
-    private Date getMonthStart(Date date) {
-        Calendar cal = Calendar.getInstance();
-        cal.setTime(date);
-        cal.set(Calendar.DAY_OF_MONTH, 1);
-        cal.set(Calendar.HOUR_OF_DAY, 0);
-        cal.set(Calendar.MINUTE, 0);
-        cal.set(Calendar.SECOND, 0);
-        cal.set(Calendar.MILLISECOND, 0);
-        return cal.getTime();
-    }
-
-    private Date getMonthEnd(Date date) {
-        Calendar cal = Calendar.getInstance();
-        cal.setTime(date);
-        cal.set(Calendar.DAY_OF_MONTH, cal.getActualMaximum(Calendar.DAY_OF_MONTH));
-        cal.set(Calendar.HOUR_OF_DAY, 23);
-        cal.set(Calendar.MINUTE, 59);
-        cal.set(Calendar.SECOND, 59);
-        cal.set(Calendar.MILLISECOND, 999);
-        return cal.getTime();
+        basicQuotaService.initUserQuotaRecords(userId);
     }
-}
+}

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -180,8 +180,9 @@ public class AiGateway {
         if (!enabled || isCircuitOpen()) return null;
 
         // BASIC 配额检查(仅对真实用户生效;userId=0 为系统级后台任务,如文章自动分类/打标,不受配额限制)
+        // 2026-10-23 新口径:AI 对话 BASIC 3 次/天;FAMILY 及以上不限次;FREE 拒绝(前端入口本就仅会员可见)
         if (userId != null && userId != 0L) {
-            com.etotem.cfc.dto.QuotaConsumeResult quotaResult = basicQuotaService.consumeQuota(userId, "AI_CHAT");
+            com.etotem.cfc.dto.QuotaConsumeResult quotaResult = basicQuotaService.consumeMemberQuota(userId, "AI_CHAT", true);
             if (!quotaResult.isSuccess()) {
                 log.info("AI对话配额不足: userId={}, toast={}", userId, quotaResult.getToastMsg());
                 return null;

+ 1 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentAppointmentService.java

@@ -24,18 +24,9 @@ public class AssessmentAppointmentService extends ServiceImpl<AssessmentAppointm
     @Resource
     private EnergyService energyService;
 
-    @Resource
-    private BasicQuotaService basicQuotaService;
-
     public AssessmentAppointment createAppointment(Long userId, Long memberId, Long guideId,
                                                    Long assessorId, Long packageId, String appointmentDate, String appointmentTime, String notes) {
-        // BASIC 硬门禁配额检查(测评预约)
-        if (userId != null) {
-            com.etotem.cfc.dto.QuotaConsumeResult quotaResult = basicQuotaService.consumeQuota(userId, "ASSESSMENT_QUOTA");
-            if (!quotaResult.isSuccess()) {
-                throw new com.etotem.cfc.common.BasicQuotaExhaustedException(quotaResult.getToastMsg());
-            }
-        }
+        // 2026-10-23 新口径:测评预约不是特殊功能,不设 BASIC 会员配额(原 ASSESSMENT_QUOTA 硬门禁已移除,迁移 382)
 
         AssessmentAppointment appointment = new AssessmentAppointment();
         appointment.setUserId(userId);

+ 202 - 63
cfc-backend/src/main/java/com/etotem/cfc/service/BasicQuotaService.java

@@ -6,7 +6,6 @@ import com.etotem.cfc.entity.BasicQuotaUsage;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.BasicQuotaUsageMapper;
 import com.etotem.cfc.mapper.UserMapper;
-import com.etotem.cfc.mapper.UserMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -14,10 +13,44 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.util.*;
 
+/**
+ * BASIC 体验会员配额服务。
+ *
+ * <p>配额周期(2026-10-23 新口径,用户定稿):
+ * <ul>
+ *   <li>{@code AI_CHAT}(AI 对话):BASIC 每天 3 次;FAMILY 及以上不限次</li>
+ *   <li>{@code FOOD_RECOGNIZE}(饮食拍照识别):BASIC 每天 3 次;FAMILY 及以上不限次</li>
+ *   <li>{@code REPORT_READING}(报告解读):BASIC 每月 1 次;FAMILY 及以上不限次</li>
+ * </ul>
+ * 注:{@code ASSESSMENT_QUOTA} 已移除——测评预约不是特殊功能,不设会员配额(迁移 382)。
+ *
+ * <p>周期粒度由 {@link #periodOf(String)} 决定:日配额按自然日(00:00-23:59:59)滚动,
+ * 月配额按自然月。到期自动换周期(懒重置:查不到当前周期记录即按 used=0 新建),
+ * 无需定时任务干预;{@link #refreshQuotas()} 仅做当前周期记录的兜底初始化。
+ */
 @Slf4j
 @Service
 public class BasicQuotaService {
 
+    /** 配额周期粒度 */
+    public enum QuotaPeriod {
+        DAILY, MONTHLY
+    }
+
+    // BASIC 会员配额上限(数值=周期内次数)
+    public static final Map<String, Integer> QUOTA_LIMITS = new HashMap<>();
+    // 配额周期:AI_CHAT / FOOD_RECOGNIZE 为日配额;REPORT_READING 为月配额
+    public static final Map<String, QuotaPeriod> QUOTA_PERIODS = new HashMap<>();
+
+    static {
+        QUOTA_LIMITS.put("AI_CHAT", 3);
+        QUOTA_LIMITS.put("FOOD_RECOGNIZE", 3);
+        QUOTA_LIMITS.put("REPORT_READING", 1);
+        QUOTA_PERIODS.put("AI_CHAT", QuotaPeriod.DAILY);
+        QUOTA_PERIODS.put("FOOD_RECOGNIZE", QuotaPeriod.DAILY);
+        QUOTA_PERIODS.put("REPORT_READING", QuotaPeriod.MONTHLY);
+    }
+
     @Resource
     private BasicQuotaUsageMapper basicQuotaUsageMapper;
 
@@ -27,32 +60,30 @@ public class BasicQuotaService {
     @Resource
     private MembershipService membershipService;
 
-    // BASIC 会员月度配额上限
-    public static final Map<String, Integer> QUOTA_LIMITS = new HashMap<>();
-    static {
-        QUOTA_LIMITS.put("AI_CHAT", 3);
-        QUOTA_LIMITS.put("REPORT_READING", 1);
-        QUOTA_LIMITS.put("ASSESSMENT_QUOTA", 1);
-        QUOTA_LIMITS.put("FOOD_RECOGNIZE", 3);
-    }
-
     /**
-     * 按配额类型返回月度上限(0 表示无配额限制)
+     * 按配额类型返回周期内上限(0 表示无配额限制/未知类型)
      */
     public static int getQuotaLimit(String quotaType) {
         return QUOTA_LIMITS.getOrDefault(quotaType, 0);
     }
 
     /**
-     * 查询指定用户指定月份某配额的剩余次数
+     * 按配额类型返回周期粒度(未知类型按月处理)
+     */
+    public static QuotaPeriod periodOf(String quotaType) {
+        return QUOTA_PERIODS.getOrDefault(quotaType, QuotaPeriod.MONTHLY);
+    }
+
+    /**
+     * 查询指定用户当前周期某配额的剩余次数(日配额看今天,月配额看本月)
      */
-    public QuotaStatusDTO getMonthlyQuota(Long userId, String quotaType) {
+    public QuotaStatusDTO getQuotaStatus(Long userId, String quotaType) {
         if (userId == null || quotaType == null) {
             return null;
         }
         Date now = new Date();
-        Date periodStart = getMonthStart(now);
-        Date periodEnd = getMonthEnd(now);
+        Date periodStart = periodStart(quotaType, now);
+        Date periodEnd = periodEnd(quotaType, now);
 
         BasicQuotaUsage usage = basicQuotaUsageMapper.selectOne(
                 new LambdaQueryWrapper<BasicQuotaUsage>()
@@ -73,18 +104,23 @@ public class BasicQuotaService {
         dto.setRemaining(Math.max(0, total - used));
         dto.setPeriodStart(periodStart);
         dto.setPeriodEnd(periodEnd);
+        // FAMILY 及以上不限次:下发 unlimited 标记,前端不展示进度条、不做剩余次数拦截
+        int userOrder = membershipService.getLevelOrder(membershipService.getMemberLevel(userId));
+        if (userOrder > membershipService.getLevelOrder("BASIC")) {
+            dto.setUnlimited(true);
+        }
         return dto;
     }
 
     /**
-     * 尝试扣减配额,返回是否成功
+     * 尝试扣减配额,返回是否成功(按配额类型的周期粒度定位当前周期记录,查不到即新建)
      */
     public QuotaConsumeResult consumeQuota(Long userId, String quotaType) {
         QuotaConsumeResult result = new QuotaConsumeResult();
 
         Date now = new Date();
-        Date periodStart = getMonthStart(now);
-        Date periodEnd = getMonthEnd(now);
+        Date periodStart = periodStart(quotaType, now);
+        Date periodEnd = periodEnd(quotaType, now);
 
         BasicQuotaUsage usage = basicQuotaUsageMapper.selectOne(
                 new LambdaQueryWrapper<BasicQuotaUsage>()
@@ -129,49 +165,88 @@ public class BasicQuotaService {
     }
 
     /**
-     * 批量刷新所有有效 BASIC 用户当月配额(每月1号调用)
+     * 会员权益配额扣减(AI 对话 / 报告解读等 BASIC 权益):
+     * <ul>
+     *   <li>FAMILY 及以上(level_order &gt; BASIC):不限次,直接放行,不落配额记录;</li>
+     *   <li>BASIC:走对应配额(日/月周期);</li>
+     *   <li>FREE 及以下:{@code rejectFree=true} 时拒绝并提示升级(AI 对话、饮食识别——
+     *       前端入口本就仅会员可见,后端做一致硬门禁);{@code rejectFree=false} 时按现状扣减
+     *       (报告解读:上传已对全员开放,FREE 维持既有 1 次/月口径,不静默放开)。</li>
+     * </ul>
+     * userId 为 null/0(系统级后台任务,如文章自动分类)时直接放行。
      */
-    public void refreshMonthlyQuota() {
-        Date now = new Date();
-        Date monthStart = getMonthStart(now);
-        Date monthEnd = getMonthEnd(now);
+    public QuotaConsumeResult consumeMemberQuota(Long userId, String quotaType, boolean rejectFree) {
+        QuotaConsumeResult result = new QuotaConsumeResult();
+        if (userId == null || userId == 0L) {
+            result.setSuccess(true);
+            return result;
+        }
+        int userOrder = membershipService.getLevelOrder(membershipService.getMemberLevel(userId));
+        int basicOrder = membershipService.getLevelOrder("BASIC");
+        if (userOrder > basicOrder) {
+            // FAMILY/PREMIUM/LIFETIME:不限次
+            result.setSuccess(true);
+            return result;
+        }
+        if (userOrder < basicOrder && rejectFree) {
+            result.setSuccess(false);
+            result.setRemaining(0);
+            result.setToastMsg(buildFreeRejectToast(quotaType));
+            result.setActionUrl("/pages/membership/upgrade");
+            return result;
+        }
+        return consumeQuota(userId, quotaType);
+    }
 
-        List<BasicQuotaUsage> lastMonthRecords = basicQuotaUsageMapper.selectList(
+    /**
+     * 图片解析(食材识别)配额校验:
+     * - FAMILY 及以上:不限次放行;
+     * - BASIC:走 FOOD_RECOGNIZE 日配额(3 次/天);
+     * - FREE:拒绝并提示升级。
+     */
+    public QuotaConsumeResult consumeFoodRecognizeQuota(Long userId) {
+        return consumeMemberQuota(userId, "FOOD_RECOGNIZE", true);
+    }
+
+    /**
+     * 兜底初始化:为所有有效 BASIC 用户创建当前周期的配额记录(used=0)。
+     * 不重置已在周期内的记录(日配额按天、月配额按月自然滚动,懒重置已覆盖)。
+     */
+    public void refreshQuotas() {
+        Date now = new Date();
+        List<BasicQuotaUsage> allRecords = basicQuotaUsageMapper.selectList(
                 new LambdaQueryWrapper<BasicQuotaUsage>());
 
         Set<Long> userIds = new HashSet<>();
-        for (BasicQuotaUsage rec : lastMonthRecords) {
+        for (BasicQuotaUsage rec : allRecords) {
             if (isActiveBasicMember(rec.getUserId())) {
                 userIds.add(rec.getUserId());
             }
         }
 
         for (Long userId : userIds) {
-            for (String quotaType : QUOTA_LIMITS.keySet()) {
+            for (Map.Entry<String, Integer> entry : QUOTA_LIMITS.entrySet()) {
+                String quotaType = entry.getKey();
                 try {
+                    Date periodStart = periodStart(quotaType, now);
+                    Date periodEnd = periodEnd(quotaType, now);
                     BasicQuotaUsage existing = basicQuotaUsageMapper.selectOne(
                             new LambdaQueryWrapper<BasicQuotaUsage>()
                                     .eq(BasicQuotaUsage::getUserId, userId)
                                     .eq(BasicQuotaUsage::getQuotaType, quotaType)
-                                    .eq(BasicQuotaUsage::getPeriodStart, monthStart));
+                                    .eq(BasicQuotaUsage::getPeriodStart, periodStart));
                     if (existing == null) {
                         BasicQuotaUsage newUsage = new BasicQuotaUsage();
                         newUsage.setUserId(userId);
                         newUsage.setQuotaType(quotaType);
                         newUsage.setUsedCount(0);
-                        newUsage.setTotalCount(QUOTA_LIMITS.get(quotaType));
-                        newUsage.setPeriodStart(monthStart);
-                        newUsage.setPeriodEnd(monthEnd);
+                        newUsage.setTotalCount(entry.getValue());
+                        newUsage.setPeriodStart(periodStart);
+                        newUsage.setPeriodEnd(periodEnd);
                         newUsage.setCreatedAt(now);
                         newUsage.setUpdatedAt(now);
                         basicQuotaUsageMapper.insert(newUsage);
-                        log.info("BASIC配额刷新初始化: userId={}, quotaType={}", userId, quotaType);
-                    } else {
-                        existing.setUsedCount(0);
-                        existing.setPeriodStart(monthStart);
-                        existing.setPeriodEnd(monthEnd);
-                        existing.setUpdatedAt(now);
-                        basicQuotaUsageMapper.updateById(existing);
+                        log.info("BASIC配额初始化: userId={}, quotaType={}, total={}", userId, quotaType, entry.getValue());
                     }
                 } catch (Exception e) {
                     log.warn("BASIC配额刷新失败: userId={}, quotaType={}", userId, quotaType, e);
@@ -181,6 +256,18 @@ public class BasicQuotaService {
         log.info("BASIC配额刷新完成, 处理用户数={}", userIds.size());
     }
 
+    /**
+     * 兼容旧名的配额刷新入口({@link AdminBasicQuotaController} 应急调用,不在本计划改动范围)。
+     * 2026-10-23 起语义与 {@link #refreshQuotas()} 一致:只兜底创建当前周期缺失记录,
+     * 不再重置周期内已有用量(旧"每月重置"行为已废除,防止日配额被清零重发)。
+     *
+     * @deprecated 请改调 {@link #refreshQuotas()}
+     */
+    @Deprecated
+    public void refreshMonthlyQuota() {
+        refreshQuotas();
+    }
+
     /**
      * 检查用户当前是否为有效的 BASIC 会员
      */
@@ -210,32 +297,71 @@ public class BasicQuotaService {
         return null;
     }
 
+    // ==================== 周期计算 ====================
+
+    public Date periodStart(String quotaType, Date date) {
+        return periodOf(quotaType) == QuotaPeriod.DAILY ? getDayStart(date) : getMonthStart(date);
+    }
+
+    public Date periodEnd(String quotaType, Date date) {
+        return periodOf(quotaType) == QuotaPeriod.DAILY ? getDayEnd(date) : getMonthEnd(date);
+    }
+
     /**
-     * 图片解析(食材识别)配额校验:
-     * - FAMILY 及以上(level_order &gt; BASIC):不限次放行;
-     * - BASIC:走 FOOD_RECOGNIZE 月度配额;
-     * - FREE:拒绝并提示升级。
+     * 为指定用户初始化所有配额类型的当前周期记录(used=0,已存在则跳过)。
+     * 用于支付成功开通 BASIC 时的配额初始化(ActivationQuotaService 委托)。
      */
-    public QuotaConsumeResult consumeFoodRecognizeQuota(Long userId) {
-        QuotaConsumeResult result = new QuotaConsumeResult();
-        if (userId == null || userId == 0L) {
-            result.setSuccess(true);
-            return result;
-        }
-        int userOrder = membershipService.getLevelOrder(membershipService.getMemberLevel(userId));
-        int basicOrder = membershipService.getLevelOrder("BASIC");
-        if (userOrder < basicOrder) {
-            result.setSuccess(false);
-            result.setRemaining(0);
-            result.setToastMsg("图片解析为体验会员及以上专属权益,升级后即可使用");
-            result.setActionUrl("/pages/membership/upgrade");
-            return result;
+    public void initUserQuotaRecords(Long userId) {
+        if (userId == null) {
+            return;
         }
-        if (userOrder > basicOrder) {
-            result.setSuccess(true);
-            return result;
+        Date now = new Date();
+        for (Map.Entry<String, Integer> entry : QUOTA_LIMITS.entrySet()) {
+            String quotaType = entry.getKey();
+            try {
+                Date periodStart = periodStart(quotaType, now);
+                BasicQuotaUsage existing = basicQuotaUsageMapper.selectOne(
+                        new LambdaQueryWrapper<BasicQuotaUsage>()
+                                .eq(BasicQuotaUsage::getUserId, userId)
+                                .eq(BasicQuotaUsage::getQuotaType, quotaType)
+                                .eq(BasicQuotaUsage::getPeriodStart, periodStart));
+                if (existing == null) {
+                    BasicQuotaUsage usage = new BasicQuotaUsage();
+                    usage.setUserId(userId);
+                    usage.setQuotaType(quotaType);
+                    usage.setUsedCount(0);
+                    usage.setTotalCount(entry.getValue());
+                    usage.setPeriodStart(periodStart);
+                    usage.setPeriodEnd(periodEnd(quotaType, now));
+                    usage.setCreatedAt(now);
+                    usage.setUpdatedAt(now);
+                    basicQuotaUsageMapper.insert(usage);
+                    log.info("BASIC配额初始化: userId={}, quotaType={}, total={}", userId, quotaType, entry.getValue());
+                }
+            } catch (Exception e) {
+                log.warn("BASIC配额初始化失败: userId={}, quotaType={}", userId, quotaType, e);
+            }
         }
-        return consumeQuota(userId, "FOOD_RECOGNIZE");
+    }
+
+    private Date getDayStart(Date date) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        cal.set(Calendar.MILLISECOND, 0);
+        return cal.getTime();
+    }
+
+    private Date getDayEnd(Date date) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        cal.set(Calendar.HOUR_OF_DAY, 23);
+        cal.set(Calendar.MINUTE, 59);
+        cal.set(Calendar.SECOND, 59);
+        cal.set(Calendar.MILLISECOND, 999);
+        return cal.getTime();
     }
 
     private Date getMonthStart(Date date) {
@@ -260,16 +386,29 @@ public class BasicQuotaService {
         return cal.getTime();
     }
 
+    // ==================== 文案 ====================
+
     private String buildExhaustedToast(String quotaType, int total) {
         switch (quotaType) {
             case "AI_CHAT":
-                return "您本月 AI 对话已用完(" + total + "/" + total + "),升级 FAMILY 享不限次畅聊";
+                return "您今日 AI 对话已用完(" + total + "/" + total + "),升级 FAMILY 享不限次畅聊";
+            case "FOOD_RECOGNIZE":
+                return "今日饮食识别额度已用完(" + total + "/" + total + "),升级 FAMILY 享不限次识别";
             case "REPORT_READING":
                 return "本月报告解读额度已用完(" + total + "/" + total + "),升级 FAMILY 解锁不限次深度解读";
-            case "ASSESSMENT_QUOTA":
-                return "测评预约为 FAMILY 会员专属权益,升级后即可预约";
             default:
                 return "本月额度已用完,升级 FAMILY 解锁更多";
         }
     }
-}
+
+    private String buildFreeRejectToast(String quotaType) {
+        switch (quotaType) {
+            case "AI_CHAT":
+                return "AI 对话为体验会员及以上专属权益,升级后即可使用";
+            case "FOOD_RECOGNIZE":
+                return "图片解析为体验会员及以上专属权益,升级后即可使用";
+            default:
+                return "该功能为体验会员及以上专属权益,升级后即可使用";
+        }
+    }
+}

+ 19 - 11
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -245,14 +245,14 @@ public class MembershipService implements MembershipServiceInterface {
         }
 
         if ("BASIC".equals(level)) {
+            // 2026-10-23 新口径:移除 assessment_quota_lite(测评预约非特殊功能,凭已购额度预约)、
+            // health_report_upload_lite(报告上传全员开放);保留 ai_report_reading_lite(报告解读 1 次/月)
             return "free_activities".equals(feature)
                     || "free_courses".equals(feature)
                     || "discount_purchase".equals(feature)
                     || "invite_family".equals(feature)
                     || "mascot_ai_lite".equals(feature)
                     || "ai_report_reading_lite".equals(feature)
-                    || "assessment_quota_lite".equals(feature)
-                    || "health_report_upload_lite".equals(feature)
                     || "growth_plan_lite".equals(feature)
                     || "task_template_lite".equals(feature)
                     || "referral_commission".equals(feature);
@@ -785,6 +785,15 @@ public class MembershipService implements MembershipServiceInterface {
             // 方案B:全家同步会员身份(使用 membership.getEndDate() 统一到期时间,修正原硬编码 365 天 bug)
             syncMembershipToFamilyMembers(order.getFamilyId(), order.getLevelCode(), expireTime, "pay", order.getId());
 
+            // BASIC 开通时初始化当前周期配额记录(AI_CHAT/FOOD_RECOGNIZE 当日、REPORT_READING 当月,均 used=0)
+            if ("BASIC".equals(order.getLevelCode())) {
+                try {
+                    activationQuotaService.activateBasicQuota(adminUserId, order.getFamilyId());
+                } catch (Exception e) {
+                    log.error("BASIC配额初始化异常: userId={}, orderNo={}", adminUserId, order.getOrderNo(), e);
+                }
+            }
+
             // 发放JOIN会员券(按等级匹配grantType=JOIN的券模板)
             try {
                 couponService.grantFamilyJoinCoupons(order.getFamilyId(), order.getLevelCode(), order.getOrderNo());
@@ -1709,10 +1718,10 @@ public class MembershipService implements MembershipServiceInterface {
         // 各等级权益描述(备用,后续可按 benefit_code 维度扩展对比行)
         List<Map<String, Object>> rows = new ArrayList<>();
 
-        // 1. 商品折扣:FREE=普通价格;BASIC=无;FAMILY=9折;PREMIUM=8折(bps 配置)
+        // 1. 商品折扣:FREE=普通价格;BASIC=指定商品会员价;FAMILY=9折;PREMIUM=8折(bps 配置)
         rows.add(compareRow("商品折扣", columns, code -> {
             if ("FREE".equals(code)) return "普通价格";
-            if ("BASIC".equals(code)) return "—";
+            if ("BASIC".equals(code)) return "指定商品会员价";
             String cfgKey = "FAMILY".equals(code) ? "member_discount_family" : "member_discount_premium";
             return formatDiscountBps(sysConfigService.getValue(cfgKey));
         }));
@@ -1738,10 +1747,10 @@ public class MembershipService implements MembershipServiceInterface {
             return "平台券+会员专享券";
         }));
 
-        // 5. AI健康顾问:BASIC 限次,FAMILY+ 不限
-        rows.add(compareRow("AI健康顾问", columns, code -> {
+        // 5. AI 对话:BASIC 每天3次,FAMILY+ 不限(2026-10-23 新口径:原"AI健康顾问"更名"AI对话",月配额改日配额)
+        rows.add(compareRow("AI 对话", columns, code -> {
             if ("FREE".equals(code)) return "—";
-            if ("BASIC".equals(code)) return "每月" + BasicQuotaService.getQuotaLimit("AI_CHAT") + "次";
+            if ("BASIC".equals(code)) return "每天" + BasicQuotaService.getQuotaLimit("AI_CHAT") + "次";
             return "不限次数";
         }));
 
@@ -1753,18 +1762,17 @@ public class MembershipService implements MembershipServiceInterface {
             return "不限次数";
         }));
 
-        // 7. 测评预约:FREE 仅查看;BASIC 限次;FAMILY 不限;PREMIUM 优先
+        // 7. 测评预约:2026-10-23 新口径——不是特殊功能,无会员等级限制(凭已购测评额度预约,原 BASIC 1次/月配额已移除)
         rows.add(compareRow("测评预约", columns, code -> {
             if ("FREE".equals(code)) return "仅查看";
-            if ("BASIC".equals(code)) return "每月" + BasicQuotaService.getQuotaLimit("ASSESSMENT_QUOTA") + "次";
             if ("PREMIUM".equals(code)) return "不限·优先";
             return "不限次数";
         }));
 
-        // 8. 食材图片解析:BASIC 限次,FAMILY+ 不限
+        // 8. 食材图片解析:BASIC 每天3次,FAMILY+ 不限(2026-10-23 新口径:月配额改日配额)
         rows.add(compareRow("食材图片解析", columns, code -> {
             if ("FREE".equals(code)) return "—";
-            if ("BASIC".equals(code)) return "每月" + BasicQuotaService.getQuotaLimit("FOOD_RECOGNIZE") + "次";
+            if ("BASIC".equals(code)) return "每天" + BasicQuotaService.getQuotaLimit("FOOD_RECOGNIZE") + "次";
             return "不限次数";
         }));
 

+ 8 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -2158,20 +2158,16 @@ public class ProductOrderService {
 
     /**
      * 获取用户有效的会员价。
-     * 规则:如果产品 min_discount_level 为 NULL,所有会员均可享受 memberPrice;
-     * 如果产品 min_discount_level 设置(如 BASIC/FAMILY/PREMIUM),
-     * 则仅当用户会员等级 >= min_discount_level 时才返回 memberPrice,
+     * 规则:产品设置了 member_price 时——
+     * - 产品 min_discount_level 为 NULL:默认 BASIC(付费会员即可享;2026-10-23 修复 FREE 也可享的漏洞);
+     * - 产品 min_discount_level 已设置(如 BASIC/FAMILY/PREMIUM):仅当用户会员等级 &gt;= min_discount_level 时返回 memberPrice,
      * 否则返回原价 product.getPrice()。
      */
     private Integer getEligibleMemberPrice(Product product, Long buyerId) {
         if (product == null || product.getMemberPrice() == null || product.getMemberPrice() <= 0) {
             return product != null ? product.getPrice() : 0;
         }
-        // 产品未设置最低级别,所有会员均可享会员价
-        if (product.getMinDiscountLevel() == null) {
-            return product.getMemberPrice();
-        }
-        // 已设置最低级别,检查用户会员等级
+        // 用户会员等级(FREE 兜底)
         String userLevel = "FREE";
         if (buyerId != null) {
             User user = userMapper.selectById(buyerId);
@@ -2179,9 +2175,12 @@ public class ProductOrderService {
                 userLevel = membershipService.getMemberLevel(buyerId);
             }
         }
+        // 产品未设置最低级别时默认 BASIC(9.9 体验会员即可享"指定商品会员价")
+        String minLevel = (product.getMinDiscountLevel() == null || product.getMinDiscountLevel().isEmpty())
+                ? "BASIC" : product.getMinDiscountLevel();
         // 比较级别数字:级别数字越小越低
         int userLevelOrder = membershipService.getLevelOrder(userLevel);
-        int minLevelOrder = membershipService.getLevelOrder(product.getMinDiscountLevel());
+        int minLevelOrder = membershipService.getLevelOrder(minLevel);
         // 用户级别 >= 最低要求级别,则可享会员价
         if (userLevelOrder >= minLevelOrder) {
             return product.getMemberPrice();

+ 9 - 7
cfc-backend/src/main/java/com/etotem/cfc/task/BasicQuotaScheduledTasks.java

@@ -20,16 +20,18 @@ public class BasicQuotaScheduledTasks {
     private MembershipService membershipService;
 
     /**
-     * 每月1号 00:00 刷新所有 BASIC 用户的月度配额
+     * 每天 00:00 兜底初始化所有 BASIC 用户当前周期的配额记录(used=0)。
+     * 2026-10-23 新口径:AI_CHAT/FOOD_RECOGNIZE 为日配额(按天滚动),REPORT_READING 为月配额(按月滚动);
+     * 周期切换由 consumeQuota 懒重置自动完成,本任务仅做记录兜底创建,不重置周期内已产生的用量。
      */
-    @Scheduled(cron = "0 0 0 1 * ?")
-    public void refreshMonthlyQuota() {
-        log.info("开始执行BASIC配额月度刷新定时任务");
+    @Scheduled(cron = "0 0 0 * * ?")
+    public void refreshQuotas() {
+        log.info("开始执行BASIC配额刷新定时任务");
         try {
-            basicQuotaService.refreshMonthlyQuota();
-            log.info("BASIC配额月度刷新定时任务执行完成");
+            basicQuotaService.refreshQuotas();
+            log.info("BASIC配额刷新定时任务执行完成");
         } catch (Exception e) {
-            log.error("BASIC配额月度刷新定时任务执行失败", e);
+            log.error("BASIC配额刷新定时任务执行失败", e);
         }
     }
 

+ 10 - 7
cfc-backend/src/main/resources/schema.sql

@@ -5845,7 +5845,9 @@ CREATE TABLE IF NOT EXISTS pearl_interaction_reminder (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='珍珠图互动提醒';
 
 -- ============================================================
--- BASIC 体验会员(迁移360 恢复,对应提交 f86dd164b 的反向还原)
+-- BASIC 体验会员(迁移360 恢复 + 迁移405 权益口径修正 2026-10-23)
+-- 权益口径:AI对话3次/天、饮食拍照识别3次/天、报告解读1次/月、指定商品会员价、
+--           每日任务不限、测评预约无会员限制(凭已购测评额度)、报告上传全员开放
 -- ============================================================
 
 INSERT IGNORE INTO membership_levels
@@ -5853,25 +5855,26 @@ INSERT IGNORE INTO membership_levels
 VALUES (
     'BASIC',
     '体验会员',
-    '9.9元/月体验版,解锁核心功能,限次使用AI与报告解读',
+    '9.9元/月体验版:AI对话每天3次、饮食识别每天3次、报告解读每月1次,享指定商品会员价',
     990,
-    '["free_activities","free_courses","discount_purchase","invite_family","mascot_ai_lite","ai_report_reading_lite","assessment_quota_lite","health_report_upload_lite","growth_plan_lite","task_template_lite","referral_commission"]',
+    '["free_activities","free_courses","discount_purchase","invite_family","mascot_ai_lite","ai_report_reading_lite","growth_plan_lite","task_template_lite","referral_commission"]',
     3,
-    3
+    0
 );
 
 INSERT IGNORE INTO membership_benefits (level_code, benefit_code, benefit_name, benefit_desc, icon, bg_color, sort_order) VALUES
-('BASIC', 'AI_CHAT_LITE', 'AI健康顾问(限3次/月)', '每月3次AI健康顾问问答', '🤖', '#DBEAFE', 1),
+('BASIC', 'AI_CHAT_LITE', 'AI对话(限3次/天)', '每天3次AI对话问答', '🤖', '#DBEAFE', 1),
+('BASIC', 'FOOD_RECOGNIZE_LITE', '饮食拍照识别(限3次/天)', '每天3次食材图片识别', '📷', '#FEF9C3', 2),
 ('BASIC', 'REPORT_READING_LITE', '报告解读(限1次/月)', '每月1次体检/测评报告AI解读', '📊', '#FCE7F3', 4),
 ('BASIC', 'GROWTH_PLAN_LITE', '基础成长计划', '创建成长计划但模板有限', '📋', '#FEF3C7', 3),
-('BASIC', 'ASSESSMENT_QUOTA_LITE', '测评预约(限1次)', '每月可预约1次测试', '📅', '#E0E7FF', 8);
+('BASIC', 'MEMBER_PRICE', '指定商品会员价', '购买指定商品享受会员价', '🏷️', '#FEF3C7', 5);
 
 -- ============================================================
 
 CREATE TABLE IF NOT EXISTS basic_quota_usage (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     user_id BIGINT NOT NULL COMMENT '家长用户ID(非family_id)',
-    quota_type VARCHAR(30) NOT NULL COMMENT '配额类型: AI_CHAT/REPORT_READING/ASSESSMENT_QUOTA/FOOD_RECOGNIZE',
+    quota_type VARCHAR(30) NOT NULL COMMENT '配额类型: AI_CHAT/FOOD_RECOGNIZE(日配额)/REPORT_READING(月配额)',
     used_count INT DEFAULT 0 COMMENT '本月已用次数',
     total_count INT NOT NULL COMMENT '月度总配额',
     period_start DATE NOT NULL COMMENT '周期开始日期(每月1号)',

+ 4 - 4
cfc-frontend/pages/ai/chat.vue

@@ -152,8 +152,8 @@
      </view>
      <view class="chat-disclaimer">本服务为生成内容,结果仅供参考</view>
      <!-- 配额进度条 -->
-     <view class="quota-bar-wrapper" v-if="aiChatQuota">
-       <quota-progress-bar :remaining="aiChatQuota.remaining || 0" :total="aiChatQuota.total || 3" name="本月AI对话剩余"></quota-progress-bar>
+     <view class="quota-bar-wrapper" v-if="aiChatQuota && !aiChatQuota.unlimited">
+       <quota-progress-bar :remaining="aiChatQuota.remaining || 0" :total="aiChatQuota.total || 3" name="今日AI对话剩余"></quota-progress-bar>
      </view>
      <!-- 升级引导弹窗 -->
      <upgrade-guide-modal ref="upgradeGuideModal"></upgrade-guide-modal>
@@ -375,8 +375,8 @@ export default {
     const text = this.inputText.trim()
     if (!text || this.sending) return
 
-    // BASIC 配额检查
-    if (this.aiChatQuota && this.aiChatQuota.remaining <= 0) {
+    // BASIC 配额检查(unlimited=true 不拦截)
+    if (this.aiChatQuota && !this.aiChatQuota.unlimited && this.aiChatQuota.remaining <= 0) {
       this.$refs.upgradeGuideModal.type = 'soft'
       this.$refs.upgradeGuideModal.quotaType = 'AI_CHAT'
       this.$refs.upgradeGuideModal.total = this.aiChatQuota.total

+ 5 - 3
cfc-frontend/pages/ai/components/UpgradeGuideModal.vue

@@ -32,13 +32,15 @@ export default {
       return this.type === 'hard' ? '🔒' : 'ℹ️'
     },
     title() {
-      return this.type === 'hard' ? '升级后即可使用' : '本月额度已用完'
+      if (this.quotaType === 'REPORT_READING') return '本月额度已用完'
+      return '今日额度已用完'
     },
     desc() {
       if (this.type === 'hard') return '该功能为 FAMILY 会员专属权益,升级后即可使用'
-      if (this.quotaType === 'AI_CHAT') return '您本月 AI 对话已用完(' + this.total + '/' + this.total + '),升级 FAMILY 享不限次畅聊'
+      if (this.quotaType === 'AI_CHAT') return '您今日 AI 对话已用完(' + this.total + '/' + this.total + '),升级 FAMILY 享不限次畅聊'
+      if (this.quotaType === 'FOOD_RECOGNIZE') return '今日饮食识别额度已用完(' + this.total + '/' + this.total + '),升级 FAMILY 享不限次识别'
       if (this.quotaType === 'REPORT_READING') return '本月报告解读额度已用完,升级 FAMILY 解锁不限次深度解读'
-      return '本月额度已用完,升级 FAMILY 解锁更多权益'
+      return '额度已用完,升级 FAMILY 解锁更多权益'
     }
   },
   methods: {

+ 27 - 1
cfc-frontend/pages/growth/diet-checkin.vue

@@ -54,6 +54,9 @@
           <text class="card-icon">📎</text>
           <text class="card-title">添加媒体</text>
         </view>
+        <view class="recognize-quota-hint" v-if="foodRecognizeQuota && !foodRecognizeQuota.unlimited && foodRecognizeQuota.total > 0">
+          <text class="recognize-quota-hint-text">今日还可识别 {{foodRecognizeQuota.remaining || 0}}/{{foodRecognizeQuota.total}} 次,升级 FAMILY 不限次</text>
+        </view>
         <view class="card-body">
           <view class="media-toolbar">
             <view class="tool-btn" @click="choosePhoto">
@@ -183,6 +186,7 @@ export default {
       todayText: '',
       showRecognizeModal: false,
       recognizedFoods: [],
+      foodRecognizeQuota: null,
       mealTypes: [
         { label: '早餐', value: 'breakfast' },
         { label: '午餐', value: 'lunch' },
@@ -257,6 +261,13 @@ export default {
       if (this.recordTimer) { clearInterval(this.recordTimer); this.recordTimer = null }
       uni.showToast({ title: '录音失败', icon: 'none' })
     })
+    // 加载饮食识别配额
+    var selfQuota = this
+    getAllQuotaStatus().then(function(res) {
+      if (res.data && res.data.FOOD_RECOGNIZE) {
+        selfQuota.foodRecognizeQuota = res.data.FOOD_RECOGNIZE
+      }
+    }).catch(function() {})
     this.loadHistory()
   },
   onUnload: function() {
@@ -322,7 +333,9 @@ export default {
                     fail: function() { resolve({ data: { code: 500 } }) }
                   })
                 })
-                if (recognizeRes.data && recognizeRes.data.code === 200 && recognizeRes.data.data && recognizeRes.data.data.foods) {
+                if (recognizeRes.data && recognizeRes.data.code === 403) {
+                    uni.showToast({ title: recognizeRes.data.message || '今日饮食识别额度已用完', icon: 'none' })
+                  } else if (recognizeRes.data && recognizeRes.data.code === 200 && recognizeRes.data.data && recognizeRes.data.data.foods) {
                   var foods = recognizeRes.data.data.foods
                   for (var f = 0; f < foods.length; f++) {
                     self.recognizedFoods.push({ name: foods[f].name, category: foods[f].category })
@@ -583,6 +596,19 @@ success: function(res) {
 /* ===== 底部留白 ===== */
 .bottom-spacer { height: 40rpx; }
 
+/* ===== 识别配额提示条 ===== */
+.recognize-quota-hint {
+  display: flex; align-items: center; justify-content: center;
+  padding: 12rpx 24rpx;
+  background: linear-gradient(90deg, #FFF7ED, #FFFAF0);
+  border-bottom: 1rpx solid $facade-fill-2;
+}
+.recognize-quota-hint-text {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  font-weight: 500;
+}
+
 /* ===== 识别结果弹窗 ===== */
 .recognize-overlay {
   position: fixed; top: 0; left: 0; right: 0; bottom: 0;

+ 230 - 0
docs/superpowers/plans/2026-10-23-basic-membership-spec-revision.md

@@ -0,0 +1,230 @@
+# 9.9 元体验会员(BASIC)权益口径修正 开发计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 `subagent-driven-development`(推荐)或 `executing-plans` 逐任务实现本计划。步骤使用复选框(`- [ ]`)语法跟踪进度。
+>
+> **协作模式(用户 2026-10-23 定):** 每个任务由**一个执行智能体完成**、**另一个独立核验智能体验证**;执行与核验不得为同一会话。核验通过后该任务才可标记完成。
+
+**目标:** 按用户定稿的新口径修正 9.9 元体验会员(BASIC)权益实现——AI 对话与饮食拍照识别改为**每天 3 次**、每日任务**不限**、开放**指定商品会员价**、测评预约**去掉会员配额**(改为凭已购测评额度)、**去掉"AI 顾问"命名**、报告上传**全员开放**、报告解读**保留每月 1 次**。
+
+**架构:** 配额制度集中在 `BasicQuotaService`(日/月双周期 + 按等级分流),业务入口(AI 网关/报告解读/测评预约)只做门禁调用;等级与权益元数据走 `membership_levels`/`membership_benefits`(迁移 382 + schema.sql 双写);会员价复用既有 `products.member_price + min_discount_level` 机制并补 BASIC;前端仅改配额展示文案与失败提示。
+
+**技术栈:** Spring Boot 2.7.18 + MyBatis-Plus + Java 8 + MySQL 8.0(后端 cfc-backend);uni-app + Vue2(小程序 cfc-frontend)。**验证方式:** `cd E:/cfc/cfc-backend && mvn clean compile -q` 编译通过;前端以代码审查 + 关键 diff 核对为主(uni-app 需 HBuilderX/CLI 构建,沙箱内不强制)。
+
+---
+
+## 需求口径(单一事实源,用户 2026-10-23 逐条定稿)
+
+| # | 权益 | 口径 | 对应实现 |
+|---|------|------|----------|
+| 1 | 家庭成员 | 可添加 **3 人** | `max_children=3`(不变) |
+| 2 | AI 对话 | **每天 3 次**(原"AI健康顾问"月配额,更名 + 改日配额) | `AI_CHAT` 日配额;FAMILY+ 不限次 |
+| 3 | 每日任务 | **不限**(原 3 个/日) | `max_tasks_per_day=0`(0=不限,无强制点,仅展示) |
+| 4 | 指定商品会员价 | **享受**(原无) | `discount_purchase` 生效:`products.member_price` + `min_discount_level`(NULL 时默认 BASIC) |
+| 5 | 饮食拍照识别 | **每天 3 次**(原月配额) | `FOOD_RECOGNIZE` 日配额;FAMILY+ 不限次 |
+| 6 | 测评预约 | **不是特殊功能**(原 BASIC 1 次/月硬门禁) | 移除 `ASSESSMENT_QUOTA` 配额与 `assessment_quota_lite`;预约凭已购测评额度(`AssessmentQuotaService`,不动) |
+| 7 | AI 顾问 | **没有该功能命名/权益** | 权益展示名"AI健康顾问"→"AI 对话";BASIC 保留对话入口(否则"AI 对话 3 次/天"无入口) |
+| 8 | 报告上传 | **全员开放,无会员限制** | 移除 `health_report_upload_lite`(本就无强制点,配额早在迁移 352 移除) |
+| 9 | 报告解读 | **保留每月 1 次**(用户拍板) | `REPORT_READING` 月配额不变;FAMILY+ 不限次 |
+
+**等级分流总规则(`consumeMemberQuota`):** FAMILY 及以上(`level_order > BASIC`)不限次直接放行;BASIC 走配额;FREE 对 AI_CHAT/FOOD_RECOGNIZE 拒绝(与前端入口门禁一致),对 REPORT_READING 维持既有扣减口径(上传已对全员开放,不静默放开也不硬拦)。
+
+---
+
+## 当前草稿实现状态(重要)
+
+**编排智能体已按上述口径完成全部代码草稿(未编译、未提交、未验证)**,工作区即最新口径实现。执行智能体**不应从零重写**,而应:① `git diff`  review 草稿与附录文件清单的逐项对照;② 修正缺陷;③ 完成本任务的编译/审查自验。核验智能体独立验收。
+
+**草稿已改文件(12 个):**
+
+| 文件 | 改动要点 |
+|------|----------|
+| `cfc-backend/.../service/BasicQuotaService.java` | 整文件重写:`QUOTA_LIMITS`(AI_CHAT=3/FOOD_RECOGNIZE=3/REPORT_READING=1) + `QUOTA_PERIODS`(日/月);`getMonthlyQuota→getQuotaStatus`;新增 `consumeMemberQuota(userId,type,rejectFree)` 等级分流;`refreshMonthlyQuota→refreshQuotas`(只建不重置);新增 `initUserQuotaRecords`;`unlimited` 标记;toast 改"今日" |
+| `cfc-backend/.../dto/QuotaStatusDTO.java` | 新增 `unlimited` 字段 |
+| `cfc-backend/.../service/AiGateway.java` | AI_CHAT 改调 `consumeMemberQuota(userId,"AI_CHAT",true)` |
+| `cfc-backend/.../controller/HealthReportController.java` | REPORT_READING 改调 `consumeMemberQuota(proxyUploaderId,"REPORT_READING",false)` |
+| `cfc-backend/.../service/AssessmentAppointmentService.java` | 移除 ASSESSMENT_QUOTA 硬门禁 + 无用字段 |
+| `cfc-backend/.../service/MembershipService.java` | ① BASIC 分支移除 `assessment_quota_lite`/`health_report_upload_lite`;② 对比表 5 行更新(AI 对话/每天3次、商品折扣=指定商品会员价、测评预约不限、食材识别每天3次);③ `activateMembership` 接通 BASIC 配额初始化 |
+| `cfc-backend/.../service/ActivationQuotaService.java` | 瘦身为委托 `basicQuotaService.initUserQuotaRecords`(消除两处配额表不一致) |
+| `cfc-backend/.../task/BasicQuotaScheduledTasks.java` | 月度刷新→每日 `refreshQuotas()`(cron `0 0 0 * * ?`) |
+| `cfc-backend/.../controller/BasicQuotaController.java` | `status/all` 增加 FOOD_RECOGNIZE;单查改 `getQuotaStatus` |
+| `cfc-backend/.../service/ProductOrderService.java` | `getEligibleMemberPrice`:min_discount_level 为空时默认 BASIC(修复 FREE 也享会员价的漏洞) |
+| `cfc-backend/.../dto/ActivityDTO.java` | `resolveMemberPrice` 纳入 BASIC |
+| `cfc-backend/.../config/DatabaseInitializer.java` | 新增迁移 382 `applyBasicMembership20261023Spec()` 并在 `runMigrations()` 调用 |
+| `cfc-backend/src/main/resources/schema.sql` | BASIC 种子同步新口径(features 9 项、max_tasks_per_day=0、权益 5 条) |
+| `cfc-frontend/pages/ai/chat.vue` | 配额条改"今日AI对话剩余" + `unlimited` 判断 + 发送拦截加 unlimited 判断 |
+| `cfc-frontend/pages/ai/components/UpgradeGuideModal.vue` | 标题/文案按日/月配额分化,新增 FOOD_RECOGNIZE 文案 |
+| `cfc-frontend/pages/growth/diet-checkin.vue` | 新增 `loadFoodRecognizeQuota` + 配额提示条 + 识别失败 toast(403 提示不再静默) |
+
+---
+
+## 规范提醒
+
+- 后端接口统一 `@PostMapping`;响应统一 `Result<T>`;DI 用 `@Resource`
+- 迁移必须 `try-catch` 包裹、幂等(`INSERT IGNORE` / 条件 UPDATE);迁移方法在 `runMigrations()` 有序调用,编号顺延(本次为 **382**)
+- `schema.sql` 与迁移**双写保持一致**(新库安装走 schema.sql,老库升级走迁移)
+- 每任务完成后 `mvn clean compile` 通过方可交核验
+- **Git 纪律:** 提交作者 `zxyj-web <agent@zxyj-w.com>`;分支 `cfclub`;**严禁** `git add tools/xaf_agent_hub/config.json`(含敏感凭证且未 gitignore);只提交本计划相关文件,不夹带无关改动
+- 前端改的是**源码**(`pages/`、`components/`);`dist/`、`unpackage/` 为旧构建产物,不手改,重新编译由用户侧 HBuilderX 完成
+- 已知遗留(**不在本计划范围**,已向用户flag):`MembershipService.listActiveBasicMemberUserIds()` 仍返回空列表(到期提醒空转);`resolveMemberPrice` 未含 LIFETIME;`BasicQuotaExhaustedException` 已成死类
+
+---
+
+## 任务分解
+
+### 任务 1:配额内核改造(BasicQuotaService + QuotaStatusDTO)
+
+**文件:** `cfc-backend/src/main/java/com/etotem/cfc/service/BasicQuotaService.java`、`cfc-backend/src/main/java/com/etotem/cfc/dto/QuotaStatusDTO.java`
+
+**步骤:**
+- [ ] Review 草稿:`QUOTA_LIMITS` 仅含 AI_CHAT=3 / FOOD_RECOGNIZE=3 / REPORT_READING=1(**ASSESSMENT_QUOTA 必须已移除**)
+- [ ] `QUOTA_PERIODS`:AI_CHAT/FOOD_RECOGNIZE=DAILY,REPORT_READING=MONTHLY;`periodStart/periodEnd` 按类型取日/月边界(日 00:00:00.000–23:59:59.999)
+- [ ] `consumeQuota` 按类型周期定位记录、查不到即新建(懒重置:跨周期自动换新)
+- [ ] `consumeMemberQuota(userId, quotaType, rejectFree)`:level_order>BASIC 放行不落记录;==BASIC 扣配额;<BASIC 且 rejectFree 拒绝(toast+actionUrl);userId null/0 放行
+- [ ] `consumeFoodRecognizeQuota` 委托 `consumeMemberQuota(...,"FOOD_RECOGNIZE",true)`
+- [ ] `getQuotaStatus`:FAMILY+ 置 `unlimited=true`(前端据此隐藏进度条、不拦截)
+- [ ] `refreshQuotas()`:只创建当前周期缺失记录,**不得**重置周期内已有用量;`initUserQuotaRecords(userId)` 供开通初始化
+- [ ] toast 文案:AI_CHAT/FOOD_RECOGNIZE 用"今日…已用完(3/3)",REPORT_READING 用"本月…已用完(1/1)"
+- [ ] `mvn clean compile` 通过
+
+**验收标准:** 编译通过;`QUOTA_LIMITS`/`QUOTA_PERIODS` 与需求口径表逐项一致;日配额跨天自动恢复(周期 key=当天 00:00);FAMILY 用户 `getQuotaStatus` 返回 `unlimited=true`。
+
+**核验要点:** 核验智能体逐条比对需求口径表;重点验算 `getDayEnd/getMonthEnd` 边界(23:59:59.999)与唯一键 `(user_id, quota_type, period_start)` 在**月初 1 号**日/月周期重合时不会读到旧记录(依赖任务 4 的清理 SQL)。
+
+---
+
+### 任务 2:业务入口门禁分流(AiGateway / HealthReportController / AssessmentAppointmentService)
+
+**文件:** 上述三个 service/controller
+
+**步骤:**
+- [ ] `AiGateway.chat()`:AI_CHAT 改 `consumeMemberQuota(userId,"AI_CHAT",true)`;保留 userId=0 系统任务旁路;配额不足仍 `return null`
+- [ ] `HealthReportController.triggerAiAnalysis()`:REPORT_READING 改 `consumeMemberQuota(proxyUploaderId,"REPORT_READING",false)`
+- [ ] `AssessmentAppointmentService.createAppointment()`:**删除** ASSESSMENT_QUOTA 扣减与 `BasicQuotaExhaustedException` 抛出;删除不再使用的 `basicQuotaService` 字段;预约真实门禁是 `AssessmentQuotaService.consumeQuota(quotaId, memberId)`(已购额度),保持不动
+- [ ] `mvn clean compile` 通过
+
+**验收标准:** BASIC 用户 AI 对话第 4 次/天返回 null(前端软引导);FAMILY 用户任意次数不被拦;FREE 直调 API 被拒;测评预约不再有会员级配额拦截;`AssessmentAppointmentController` 其他方法无连带破坏。
+
+**核验要点:** 确认 `AssessmentQuotaService`(购买驱动额度池)未被误改;确认 AiGateway 其余逻辑(熔断/PII 脱敏)未动。
+
+---
+
+### 任务 3:会员中心(MembershipService canUseFeature + 对比表 + 开通初始化)
+
+**文件:** `cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java`、`.../service/ActivationQuotaService.java`
+
+**步骤:**
+- [ ] `canUseFeature` BASIC 分支:保留 `free_activities/free_courses/discount_purchase/invite_family/mascot_ai_lite/ai_report_reading_lite/growth_plan_lite/task_template_lite/referral_commission`(9 项);**移除** `assessment_quota_lite`、`health_report_upload_lite`
+- [ ] 对比表 `getMembershipCompareData`:①"AI健康顾问"行更名 **"AI 对话"**,BASIC=**"每天3次"**;② 商品折扣行 BASIC=**"指定商品会员价"**;③ 测评预约行 BASIC=**"不限次数"**(去掉 `getQuotaLimit("ASSESSMENT_QUOTA")` 调用);④ 食材图片解析行 BASIC=**"每天3次"**;⑤ 每日任务上限行随 `max_tasks_per_day=0` 自动显示"—"(不限)
+- [ ] `activateMembership`:`"BASIC".equals(order.getLevelCode())` 时调 `activationQuotaService.activateBasicQuota(adminUserId, familyId)`,try-catch 包裹不阻断开通主流程
+- [ ] `ActivationQuotaService` 委托 `basicQuotaService.initUserQuotaRecords(userId)`(不再自带 QUOTA_LIMITS)
+- [ ] `mvn clean compile` 通过
+
+**验收标准:** BASIC features 与迁移 382 后的 DB 值一致(9 项);对比表 5 行文案与新口径一致;BASIC 支付成功后 `basic_quota_usage` 落 3 类当前周期记录(AI_CHAT/FOOD_RECOGNIZE 当日、REPORT_READING 当月,used=0)。
+
+**核验要点:** 对比表是**用户可见承诺**,逐格核对;确认 `getQuotaLimit("ASSESSMENT_QUOTA")` 已无调用(返回 0 会被误显示)。
+
+---
+
+### 任务 4:数据库迁移 382 + schema.sql 双写
+
+**文件:** `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`、`cfc-backend/src/main/resources/schema.sql`
+
+**步骤:**
+- [ ] `runMigrations()` 中 `removeReportUploadQuota()` 之后调用 `applyBasicMembership20261023Spec()`
+- [ ] 迁移体(try-catch 三段,全部幂等):① `UPDATE membership_levels` BASIC 行:`level_desc='9.9元/月体验版:AI对话每天3次、饮食识别每天3次、报告解读每月1次,享指定商品会员价'`、`features`=9 项 JSON、`max_tasks_per_day=0`;② benefits:`AI_CHAT_LITE` 更名"AI对话(限3次/天)"、删 `ASSESSMENT_QUOTA_LITE`、`INSERT IGNORE` 增 `FOOD_RECOGNIZE_LITE` 与 `MEMBER_PRICE`;③ `DELETE FROM basic_quota_usage WHERE quota_type IN ('ASSESSMENT_QUOTA','AI_CHAT','FOOD_RECOGNIZE')`(切日制度,防月初周期重合撞唯一键;REPORT_READING 记录保留)
+- [ ] `schema.sql` BASIC 种子同步(level_desc/features 9 项/max_tasks_per_day=0;benefits 5 条:AI_CHAT_LITE、FOOD_RECOGNIZE_LITE、REPORT_READING_LITE、GROWTH_PLAN_LITE、MEMBER_PRICE)
+- [ ] `mvn clean compile` 通过
+
+**验收标准:** 迁移可重复执行不报错;新库(schema.sql)与老库(迁移 382)终态一致;`membership_benefits` BASIC 恰好 5 条;旧 `AI_CHAT`/`FOOD_RECOGNIZE` 月记录已清。
+
+**核验要点:** 核对迁移与 schema.sql 的 features JSON **逐字符一致**(顺序也一致);确认 `REPORT_READING_LITE` 未被误删(用户拍板保留);确认迁移编号未与既有 351/352/360/381 冲突。
+
+---
+
+### 任务 5:配额状态接口与定时任务(BasicQuotaController + BasicQuotaScheduledTasks)
+
+**文件:** 上述两个类
+
+**步骤:**
+- [ ] `BasicQuotaController`:单查改调 `getQuotaStatus`;`status/all` 返回 AI_CHAT/FOOD_RECOGNIZE/REPORT_READING 三项(去掉 ASSESSMENT_QUOTA)
+- [ ] `BasicQuotaScheduledTasks`:`refreshMonthlyQuota`→`refreshQuotas`,cron 由 `0 0 0 1 * ?`(每月 1 号)改 `0 0 0 * * ?`(每日 00:00),语义改为"兜底创建当前周期记录"
+- [ ] `mvn clean compile` 通过
+
+**验收标准:** 前端 `/api/basic-quota/status/all` 三类配额均返回且 FAMILY 带 `unlimited=true`;定时任务不会重置周期内用量(否则日配额会被每天清零重发——重点回归!)。
+
+**核验要点:** 回归验算:若 10:00 已用 2 次 AI_CHAT,00:00 任务执行后当天记录 used 仍为 2(任务只建不重置)。
+
+---
+
+### 任务 6:指定商品会员价机制(ProductOrderService + ActivityDTO)
+
+**文件:** 上述两个类
+
+**步骤:**
+- [ ] `ProductOrderService.getEligibleMemberPrice`:`min_discount_level` 为 NULL 时默认按 **BASIC** 判定(修复 FREE 也享会员价的漏洞);等级比较用 `getLevelOrder`
+- [ ] `ActivityDTO.resolveMemberPrice`:会员等级集合纳入 `BASIC`(FAMILY/PREMIUM 保持不变;PROVIDER 仍不享)
+- [ ] `mvn clean compile` 通过
+
+**验收标准:** BASIC 购买"设置 member_price 且 min_discount_level 为空/BASIC"的商品得会员价;FREE 得原价;FAMILY+ 得会员价;`min_discount_level=FAMILY` 的商品 BASIC 得原价。活动(activities)会员价对 BASIC 生效。
+
+**核验要点:** 两个调用点(单商品下单 line≈263、多商品下单 line≈762)行为一致;确认修复不波及特价活动流程(特价覆盖会员价逻辑不变)。
+
+---
+
+### 任务 7:前端配额展示与提示(chat.vue / UpgradeGuideModal.vue / diet-checkin.vue)
+
+**文件:** `cfc-frontend/pages/ai/chat.vue`、`cfc-frontend/pages/ai/components/UpgradeGuideModal.vue`、`cfc-frontend/pages/growth/diet-checkin.vue`
+
+**步骤:**
+- [ ] `chat.vue`:配额条 `name="今日AI对话剩余"`;外层 `v-if="aiChatQuota && !aiChatQuota.unlimited"`;发送前拦截条件加 `!this.aiChatQuota.unlimited &&`(**漏改会导致 FAMILY 被误拦——重点回归**)
+- [ ] `UpgradeGuideModal.vue`:title 按 quotaType 分化(AI_CHAT/FOOD_RECOGNIZE→"今日额度已用完",其余"本月额度已用完");desc 增加 FOOD_RECOGNIZE 文案;AI_CHAT 文案改"今日"
+- [ ] `diet-checkin.vue`:import 增 `getAllQuotaStatus`;data 增 `foodRecognizeQuota`;onLoad 调 `loadFoodRecognizeQuota`(**不得破坏既有 `loadHistory` 方法体——草稿阶段曾误删其开头,需回归**);媒体工具栏下增加权提示条(`v-if` 含 `!unlimited && total>0`);recognize 失败分支 `else if (recognizeRes.data.msg)` 弹 toast(403 配额提示不再静默);样式 `.recognize-quota-hint/.recognize-quota-text`
+- [ ] 代码审查:三个文件的模板/脚本/style 结构完整,无语法错误(uni-app 编译由用户侧完成)
+
+**验收标准:** BASIC 在 AI 对话页见"今日AI对话剩余 X/3"、饮食打卡页见"今日还可识别 X/3 次";FAMILY 两处均不展示;配额耗尽时弹窗/ toast 文案与后端一致。
+
+**核验要点:** 逐处核对 `unlimited` 判断是否加全(漏一处即误拦 FAMILY 或误显示);核对 `loadHistory`/`loadFoodRecognizeQuota` 两方法体完整;确认 `dist/`、`unpackage/` 未被修改。
+
+---
+
+### 任务 8:编译验证、文档更新与提交
+
+**文件:** `docs/superpowers/plans/2026-10-23-basic-membership-spec-revision.md`(本文件)、`docs/2026-10-23-basic-membership-gap-analysis.md`、`E:/cfc/.workbuddy/memory/2026-10-23.md`
+
+**步骤:**
+- [ ] `cd E:/cfc/cfc-backend && mvn clean compile -q` 全量通过(Java 8 兼容:无 `var`/`List.of` 等新语法)
+- [ ] 更新 `docs/2026-10-23-basic-membership-gap-analysis.md`:将"差异清单"改写为"新口径基线"(本计划即实现依据),标注已修复项
+- [ ] 更新 `.workbuddy/memory/2026-10-23.md`:追加"二十六、9.9 会员权益口径修正"章节(口径表 + 迁移 382 + 文件清单 + 遗留项)
+- [ ] Git:仅添加本计划 15 个源码文件 + 文档,作者 `zxyj-web <agent@zxyj-w.com>`,分支 `cfclub`,push 前 `git status` 确认无 `tools/xaf_agent_hub/config.json` 等敏感/无关文件
+- [ ] 提交信息示例:`fix(membership): 9.9体验会员权益口径修正(AI对话/饮食识别改日配额、去测评配额、开放指定商品会员价)`
+
+**验收标准:** 编译零 error;文档/日志已更新;提交内容干净(`git show --stat` 核对文件清单)。
+
+**核验要点:** 核验智能体独立跑一次 `mvn clean compile`;核对 `git show --stat` 文件数=15 个源码 + 文档;确认无敏感文件入库。
+
+---
+
+## 派发顺序与依赖
+
+```
+任务1(配额内核)→ 任务2(入口门禁)→ 任务3(会员中心)→ 任务4(迁移+schema)
+                                                        ↘ 任务5(接口/定时)↗
+任务6(会员价,独立)─┐
+任务7(前端,依赖1的 unlimited 语义)─┘→ 任务8(编译+文档+提交)
+```
+
+- 任务 6 与 1–5 无代码依赖,可并行派发
+- 任务 7 依赖任务 1 的 `unlimited` 字段语义(后端先行)
+- 每个任务:执行智能体交付 → 核验智能体验收 → 通过后才放行下一任务
+- 全部完成后由任务 8 统一编译、更新文档、提交推送
+
+## 风险与回滚
+
+| 风险 | 缓解 |
+|------|------|
+| 日/月双周期唯一键在月初 1 号重合读到旧月记录 | 迁移 382 清理 AI_CHAT/FOOD_RECOGNIZE 旧记录(任务 4) |
+| FAMILY 被误拦/误显示剩余次数 | `unlimited` 标记三处前端判断 + `consumeMemberQuota` 放行(任务 1/7 核验重点) |
+| 定时任务重置周期内用量导致配额无限 | `refreshQuotas` 只建不重置(任务 5 回归用例) |
+| 迁移 382 重复执行 | 全部 `INSERT IGNORE`/条件 UPDATE/DELETE,幂等 |
+| FREE 报告解读口径变化 | `rejectFree=false` 维持既有 1 次/月,不静默放开也不硬拦 |
+| 前端未重新编译导致线上不生效 | `dist/` 为构建产物,需用户侧 HBuilderX 重新编译(计划内已声明,执行智能体不手改 dist) |

+ 102 - 0
docs/superpowers/plans/verify/exec-task5.md

@@ -0,0 +1,102 @@
+# 任务 5 执行报告
+
+> 日期:2026-10-23  
+> 智能体:任务 5 执行智能体  
+> 涉及文件:`BasicQuotaController.java`、`BasicQuotaScheduledTasks.java`、`AdminBasicQuotaController.java`
+
+---
+
+## 一、逐条验收结论
+
+| # | 验收项 | 结论 |
+|---|--------|------|
+| 1 | `BasicQuotaController` 单查接口为 `getQuotaStatus`(`POST /api/basic-quota/status`,接收 `quotaType` 参数) | **PASS** — 已实现,见第 22–32 行 |
+| 2 | `status/all` 返回 AI_CHAT / FOOD_RECOGNIZE / REPORT_READING 三项,无 ASSESSMENT_QUOTA | **PASS** — 第 42–44 行,三项明确列出,无 ASSESSMENT_QUOTA |
+| 3 | `BasicQuotaScheduledTasks.refreshMonthlyQuota` 改名为 `refreshQuotas`,cron 由 `0 0 0 1 * ?` 改为 `0 0 0 * * ?` | **PASS** — 第 27–28 行,cron 与方法名均已更新 |
+| 4 | `refreshQuotas` 语义仅为兜底创建当前周期记录,不重置周期内已有用量 | **PASS** — Javadoc 已明确说明"仅做记录兜底创建,不重置周期内已产生的用量";`BasicQuotaService.refreshQuotas()` 实现在查不到记录时才 insert,已有记录完全不动 |
+| 5 | `AdminBasicQuotaController.java:24` 调用已废弃的 `refreshMonthlyQuota()`,修正为 `refreshQuotas()` 并更新注释 | **已修正** — 详见下方「发现缺陷及修复」 |
+| 6 | 全目录无对 `getMonthlyQuota` 的非兼容残留(`@Deprecated` 委托可保留) | **PASS** — grep 无 `getMonthlyQuota` 调用;`refreshMonthlyQuota()` 作为 `@Deprecated` 委托保留在 `BasicQuotaService` 中(向前兼容),仅 AdminBasicQuotaController 一处旧调用已修正 |
+| 7 | Java 8 兼容,`mvn clean compile -q` 通过 | **PASS** — 编译零错误,退出码 0 |
+
+---
+
+## 二、发现缺陷及修复
+
+### 缺陷:AdminBasicQuotaController 仍调用已废弃方法 + 过时注释
+
+**文件:** `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminBasicQuotaController.java`
+
+**原内容(第 17–25 行):**
+```java
+/**
+ * 运营手动重置指定用户的当月配额(应急用)
+ */
+@PostMapping("/reset")
+public Result<String> resetQuota(@RequestBody Map<String, Object> body) {
+    ...
+    basicQuotaService.refreshMonthlyQuota(); // 重置所有用户当月配额
+    return Result.success("配额刷新完成");
+}
+```
+
+**问题:**
+1. `refreshMonthlyQuota()` 已在 `BasicQuotaService` 中标注 `@Deprecated`,应改调 `refreshQuotas()`
+2. 注释"重置所有用户当月配额"语义错误——新口径下该操作仅兜底创建缺失周期记录,绝不重置用量
+
+**修复后内容:**
+```java
+/**
+ * 兜底创建当前周期配额记录,不重置用量。
+ * 运营手动触发用于解决个别用户配额记录缺失问题(应急用)。
+ */
+@PostMapping("/reset")
+public Result<String> resetQuota(@RequestBody Map<String, Object> body) {
+    ...
+    basicQuotaService.refreshQuotas(); // 兜底创建当前周期配额记录,不重置用量
+    return Result.success("配额刷新完成");
+}
+```
+
+---
+
+## 三、mvn 编译关键输出
+
+```
+cd E:/cfc/cfc-backend && mvn clean compile -q
+```
+
+- **退出码:0**(无输出 = 编译通过)
+- 无任何 error/warning 提示
+
+---
+
+## 四、git diff --stat(含本次修改)
+
+```
+ .gitignore                                         |   4 +
+ cfc-backend/pom.xml                                |   2 +-
+ .../com/etotem/cfc/config/DatabaseInitializer.java |  50 ++++
+ .../cfc/controller/BasicQuotaController.java       |  13 +-
+ .../cfc/controller/HealthReportController.java     |   4 +-
+ .../main/java/com/etotem/cfc/dto/ActivityDTO.java  |   5 +-
+ .../java/com/etotem/cfc/dto/QuotaStatusDTO.java    |   7 +-
+ .../etotem/cfc/service/ActivationQuotaService.java |  71 +----
+ .../java/com/etotem/cfc/service/AiGateway.java     |   3 +-
+ .../cfc/service/AssessmentAppointmentService.java  |  11 +-
+ .../com/etotem/cfc/service/BasicQuotaService.java  | 265 +++++++++++++-----
+ .../com/etotem/cfc/service/MembershipService.java  |  30 +-
+ .../etotem/cfc/service/ProductOrderService.java    |  16 +-
+ .../cfc/task/BasicQuotaScheduledTasks.java         |  16 +-
+ cfc-backend/src/main/resources/schema.sql          |  17 +-
+ cfc-frontend/pages/ai/chat.vue                     |  12 +-
+ .../pages/ai/components/UpgradeGuideModal.vue      |   8 +-
+ cfc-frontend/pages/growth/diet-checkin.vue         |  23 +-
+ cfc-web/AGENTS.md                                  |  15 +
+ tools/xaf_agent_hub/tasks_backlog.json             | 303 +++++++++++++++++----
+ cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminBasicQuotaController.java |   5 +-
+ 21 files changed, 631 insertions(+), 249 deletions(-)
+```
+
+本次任务 5 实际改动文件:**仅 `AdminBasicQuotaController.java`**(5 行变更:2 行注释 + 1 行方法调用 + 1 行 inline 注释 + 1 行 blank)。
+
+`BasicQuotaController.java` 和 `BasicQuotaScheduledTasks.java` 的改动均为前序草稿实现,本次 review 确认已符合规范,无需额外修改。

+ 211 - 0
docs/superpowers/plans/verify/exec-task6.md

@@ -0,0 +1,211 @@
+# 任务6验证报告:指定商品会员价机制
+
+> **执行时间:** 2026-10-24  
+> **执行智能体:** 任务6专用  
+> **核验状态:** 待独立核验
+
+---
+
+## 一、需求回顾(来自 `2026-10-23-basic-membership-spec-revision.md`)
+
+### 需求口径(第4条)
+| # | 权益 | 口径 | 对应实现 |
+|---|------|------|----------|
+| 4 | 指定商品会员价 | **享受**(原无) | `discount_purchase` 生效:`products.member_price` + `min_discount_level`(NULL 时默认 BASIC)|
+
+### 任务6步骤
+- [x] `ProductOrderService.getEligibleMemberPrice`:`min_discount_level` 为 NULL 时默认按 **BASIC** 判定(修复 FREE 也可享的漏洞);等级比较用 `getLevelOrder`
+- [x] `ActivityDTO.resolveMemberPrice`:会员等级集合纳入 `BASIC`(FAMILY/PREMIUM 保持不变;PROVIDER 仍不享)
+- [x] `mvn clean compile` 通过
+
+### 验收标准
+- BASIC 购买"设置 member_price 且 min_discount_level 为空/BASIC"的商品得会员价
+- FREE 得原价
+- FAMILY+ 得会员价
+- `min_discount_level=FAMILY` 的商品 BASIC 得原价
+- 活动(activities)会员价对 BASIC 生效
+
+---
+
+## 二、逐条验收结论
+
+### 验收项 1:ProductOrderService.getEligibleMemberPrice
+
+**代码位置:** `ProductOrderService.java` 第 2166-2188 行
+
+**当前实现:**
+```java
+private Integer getEligibleMemberPrice(Product product, Long buyerId) {
+    if (product == null || product.getMemberPrice() == null || product.getMemberPrice() <= 0) {
+        return product != null ? product.getPrice() : 0;
+    }
+    // 用户会员等级(FREE 兜底)
+    String userLevel = "FREE";
+    if (buyerId != null) {
+        User user = userMapper.selectById(buyerId);
+        if (user != null) {
+            userLevel = membershipService.getMemberLevel(buyerId);
+        }
+    }
+    // 产品未设置最低级别时默认 BASIC
+    String minLevel = (product.getMinDiscountLevel() == null || product.getMinDiscountLevel().isEmpty())
+            ? "BASIC" : product.getMinDiscountLevel();
+    // 比较级别数字:级别数字越小越低
+    int userLevelOrder = membershipService.getLevelOrder(userLevel);
+    int minLevelOrder = membershipService.getLevelOrder(minLevel);
+    // 用户级别 >= 最低要求级别,则可享会员价
+    if (userLevelOrder >= minLevelOrder) {
+        return product.getMemberPrice();
+    }
+    return product.getPrice();
+}
+```
+
+**逻辑分析:**
+
+| 场景 | min_discount_level | userLevel | userLevelOrder | minLevelOrder | 结果 |
+|------|-------------------|-----------|----------------|---------------|------|
+| FREE 买 min_level=NULL 商品 | NULL → "BASIC" | "FREE" | 1 | 2 | ❌ FREE < BASIC → 原价 ✓ |
+| BASIC 买 min_level=NULL 商品 | NULL → "BASIC" | "BASIC" | 2 | 2 | ✅ BASIC >= BASIC → 会员价 ✓ |
+| FAMILY 买 min_level=NULL 商品 | NULL → "BASIC" | "FAMILY" | 3 | 2 | ✅ FAMILY >= BASIC → 会员价 ✓ |
+| BASIC 买 min_level=FAMILY 商品 | "FAMILY" | "BASIC" | 2 | 3 | ❌ BASIC < FAMILY → 原价 ✓ |
+| FAMILY 买 min_level=FAMILY 商品 | "FAMILY" | "FAMILY" | 3 | 3 | ✅ FAMILY >= FAMILY → 会员价 ✓ |
+
+**结论:✅ PASS**  
+- min_discount_level 为 NULL 或空字符串时,默认按 BASIC 判定 ✓  
+- 使用 `getLevelOrder` 进行等级比较 ✓  
+- FREE 用户不再能享受会员价(已修复漏洞)✓  
+- 处理了空字符串边界情况(比草稿更严谨)✓
+
+---
+
+### 验收项 2:ActivityDTO.resolveMemberPrice
+
+**代码位置:** `ActivityDTO.java` 第 137-146 行
+
+**当前实现:**
+```java
+public static Integer resolveMemberPrice(Integer basePrice, Integer memberPrice, String memberLevel) {
+    if (basePrice == null) return null;
+    // If user has member level and a memberPrice is set, use memberPrice
+    if (memberLevel != null && ("BASIC".equals(memberLevel) || "FAMILY".equals(memberLevel) || "PREMIUM".equals(memberLevel))) {
+        if (memberPrice != null) {
+            return memberPrice;
+        }
+    }
+    return basePrice;
+}
+```
+
+**逻辑分析:**
+
+| memberLevel | 是否享会员价 | 说明 |
+|-------------|-------------|------|
+| null / "" | ❌ | 非会员 |
+| "FREE" | ❌ | 免费用户不享受 |
+| "BASIC" | ✅ | 9.9元体验会员纳入 ✓ |
+| "FAMILY" | ✅ | 保持不变 ✓ |
+| "PREMIUM" | ✅ | 保持不变 ✓ |
+| "PROVIDER" | ❌ | 服务商不享受 ✓ |
+
+**结论:✅ PASS**  
+- BASIC 已纳入会员等级集合 ✓  
+- FAMILY 与 PREMIUM 保持不变 ✓  
+- PROVIDER 仍然不享受会员价 ✓
+
+---
+
+### 验收项 3:两个调用点行为一致
+
+**调用点1:** `ProductOrderService.java:263`(单商品下单)
+```java
+order.setUnitPrice(getEligibleMemberPrice(product, buyerId));
+```
+
+**调用点2:** `ProductOrderService.java:762`(多商品下单/购物车)
+```java
+int unitPrice = getEligibleMemberPrice(product, buyerId);
+```
+
+**结论:✅ PASS**  
+两个调用点均调用同一方法 `getEligibleMemberPrice`,逻辑完全一致。
+
+---
+
+### 验收项 4:不波及特价活动流程
+
+**特价订单流程(`createSpecialDealOrder`):**
+```java
+// 第422-423行
+order.setUnitPrice(deal.getSpecialPrice());
+order.setTotalAmount(deal.getSpecialPrice() * quantity);
+```
+
+特价订单直接使用 `deal.getSpecialPrice()` 作为单价,**完全不调用** `getEligibleMemberPrice`。
+
+**结论:✅ PASS**  
+特价覆盖会员价的原有逻辑保持不变。
+
+---
+
+### 验收项 5:Java 8 兼容 + 编译通过
+
+- 代码使用标准 Java 8 语法,无 `var`、`List.of()` 等新特性
+- `mvn clean compile -q` 退出码为 0,编译通过
+
+**结论:✅ PASS**
+
+---
+
+## 三、发现的缺陷与修复
+
+### 发现的缺陷
+
+**原草稿实现:**
+```java
+String minLevel = product.getMinDiscountLevel() != null ? product.getMinDiscountLevel() : "BASIC";
+```
+
+**问题:** 未处理 `min_discount_level` 为空字符串(`""`)的情况。虽然数据库字段为 VARCHAR(50) 且默认值为 NULL,但为保险起见应同时处理空字符串。
+
+**修复后的代码:**
+```java
+String minLevel = (product.getMinDiscountLevel() == null || product.getMinDiscountLevel().isEmpty())
+        ? "BASIC" : product.getMinDiscountLevel();
+```
+
+**影响:** 增强了代码的健壮性,防止未来数据异常时出现 NPE 或逻辑错误。
+
+---
+
+## 四、mvn 编译关键输出
+
+```
+EXIT_CODE=0
+```
+
+编译成功,无任何 error 或 warning。
+
+---
+
+## 五、git diff --stat
+
+```
+ cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java       | 5 +++--
+ cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java | 16 ++++++++--------
+ 2 files changed, 11 insertions(+), 10 deletions(-)
+```
+
+---
+
+## 六、总结
+
+| 验收项 | 结论 |
+|--------|------|
+| 1. getEligibleMemberPrice 逻辑正确性 | ✅ PASS |
+| 2. ActivityDTO.resolveMemberPrice 纳入 BASIC | ✅ PASS |
+| 3. 单商品/多商品调用点一致 | ✅ PASS |
+| 4. 特价活动流程不受影响 | ✅ PASS |
+| 5. Java 8 兼容 + 编译通过 | ✅ PASS |
+
+**任务状态:** ✅ 已完成,等待独立核验智能体验收

+ 216 - 0
docs/superpowers/plans/verify/verify-task6.md

@@ -0,0 +1,216 @@
+# Task 6 独立核验报告:指定商品会员价机制(BASIC 纳入)
+
+> **核验方:** 独立核验智能体  
+> **核验时间:** 2026-10-24  
+> **事实源:** 代码实际行为(不信任 exec-task6.md 结论)
+
+---
+
+## 一、核验范围
+
+| # | 核验项 | 说明 |
+|---|--------|------|
+| 1 | `getEligibleMemberPrice` 逻辑 | min_discount_level=NULL 时按 BASIC 判定;等级比较方向正确;FREE/PROVIDER 不误享 |
+| 2 | 调用点全覆盖 | 单商品下单、多商品下单;确认无绕过路径;评估 CartService/ProductDTO 行为 |
+| 3 | `ActivityDTO.resolveMemberPrice` | BASIC 纳入;FREE/PROVIDER 不享 |
+| 4 | 限时特价/秒杀路径 | 不受本次改动影响 |
+| 5 | 编译验证 | `mvn clean compile -q` 退出码=0 |
+
+---
+
+## 二、逐条核验
+
+### 核验项 1:`ProductOrderService.getEligibleMemberPrice`
+
+**代码位置:** `ProductOrderService.java` 第 2166–2188 行
+
+```java
+private Integer getEligibleMemberPrice(Product product, Long buyerId) {
+    if (product == null || product.getMemberPrice() == null || product.getMemberPrice() <= 0) {
+        return product != null ? product.getPrice() : 0;
+    }
+    // 用户会员等级(FREE 兜底)
+    String userLevel = "FREE";
+    if (buyerId != null) {
+        User user = userMapper.selectById(buyerId);
+        if (user != null) {
+            userLevel = membershipService.getMemberLevel(buyerId);   // ← 行 2175
+        }
+    }
+    // 产品未设置最低级别时默认 BASIC
+    String minLevel = (product.getMinDiscountLevel() == null || product.getMinDiscountLevel().isEmpty())
+            ? "BASIC" : product.getMinDiscountLevel();                  // ← 行 2179-2180
+    // 比较级别数字:级别数字越小越低
+    int userLevelOrder = membershipService.getLevelOrder(userLevel);   // ← 行 2182
+    int minLevelOrder = membershipService.getLevelOrder(minLevel);     // ← 行 2183
+    // 用户级别 >= 最低要求级别,则可享会员价
+    if (userLevelOrder >= minLevelOrder) {                             // ← 行 2185
+        return product.getMemberPrice();
+    }
+    return product.getPrice();
+}
+```
+
+**`membershipService.getMemberLevel` 行为**(MembershipService.java 行 1261–1283):
+- `buyerId` 为 null → 返回 "FREE"
+- `user.getMemberLevel() == "PROVIDER"` → 强制转为 "FREE"(行 1270-1271)
+- 家庭 + 个人取更高级别(`resolveEffectiveLevel`,行 1309-1323)
+
+**production `getLevelOrder` 值**(DatabaseInitializer 迁移 381,行 11820-11831):
+
+| LevelCode | level_order |
+|-----------|-------------|
+| FREE      | 1           |
+| BASIC     | 2           |
+| FAMILY    | 3           |
+| PREMIUM   | 4           |
+| LIFETIME  | 5           |
+| PROVIDER  | 1(与 FREE 同级,视为非会员)|
+
+**场景矩阵:**
+
+| 用户等级 | min_discount_level | userLevelOrder | minLevelOrder | 结果 | 预期 |
+|----------|-------------------|----------------|---------------|------|------|
+| FREE      | NULL/空            | 1              | 2             | 原价 | ✅ |
+| FREE      | "BASIC"            | 1              | 2             | 原价 | ✅ |
+| FREE      | "FAMILY"           | 1              | 3             | 原价 | ✅ |
+| BASIC     | NULL/空            | 2              | 2             | 会员价 | ✅ |
+| BASIC     | "BASIC"            | 2              | 2             | 会员价 | ✅ |
+| BASIC     | "FAMILY"           | 2              | 3             | 原价 | ✅ |
+| FAMILY    | NULL/空            | 3              | 2             | 会员价 | ✅ |
+| PREMIUM   | NULL/空            | 4              | 2             | 会员价 | ✅ |
+| PROVIDER  | NULL/空            | 1              | 2             | 原价 | ✅ |
+| null buyerId | 任何             | 1(兜底)      | 2             | 原价 | ✅ |
+
+**结论:✅ 通过**
+
+> **附注:测试数据与实际数据库不一致**  
+> `MembershipServiceTest.java` 第 67 行将 `BASIC` mock 为 order=1(与 FREE 同级),  
+> 但生产 DB 经迁移 381(行 11822-11826)后 `BASIC=2, FREE=1`。  
+> 此测试 mocking 不准确,但**不影响生产代码**——生产代码直接查询 DB,行为正确。
+
+---
+
+### 核验项 2:所有调用点
+
+| 调用点 | 文件:行号 | 方法 | 是否走 `getEligibleMemberPrice` |
+|--------|-----------|------|-------------------------------|
+| 单商品下单 | `ProductOrderService.java:263` | `create()` | ✅ `order.setUnitPrice(getEligibleMemberPrice(product, buyerId))` |
+| 单商品下单金额 | `ProductOrderService.java:264` | `create()` | ✅ `order.setTotalAmount(getEligibleMemberPrice(product, buyerId) * quantity)` |
+| 多商品下单(购物车) | `ProductOrderService.java:762` | `createMultiItem()` | ✅ `int unitPrice = getEligibleMemberPrice(product, buyerId)` |
+| 限时特价(秒杀) | `ProductOrderService.java:423` | `createSpecialDealOrder()` | ❌ 使用 `deal.getSpecialPrice()`,**不经过**本方法(设计如此) |
+
+**无绕过路径。** 所有结算价路径(单商品/多商品)均经 `getEligibleMemberPrice`。
+
+#### CartService 第 339/358 行:无条件下发 memberPrice
+
+**代码:** `CartService.java` 行 338-358
+
+```java
+dto.setMemberPrice(product.getMemberPrice());          // 行 339(常规 SKU)
+dto.setMemberPrice(linkedProduct.getMemberPrice());    // 行 358(Linked SKU)
+```
+
+**评估:**
+- `memberPrice` 字段始终从 product 直接复制,**不过滤用户等级**
+- 同时 `unitPrice` 也设为 `product.getPrice()`(原价)
+- 注释(ProductOrderService.java 行 1741)明确:"会员体系重构:商品对所有用户统一定价,活动侧保留会员价,商品侧停用"——即**前端**自行判断是否展示会员价
+
+**结论:** 这是一个**已有行为**,非本次改动引入;前端应通过 `minDiscountLevel` + 用户等级自行判断展示。  
+**是否需要修:** 建议由前端负责展示逻辑(当前设计如此)。后端不做过滤是正确的——避免端+后端双源判断不一致。
+
+---
+
+### 核验项 3:`ActivityDTO.resolveMemberPrice`
+
+**代码位置:** `ActivityDTO.java` 第 137-146 行
+
+```java
+public static Integer resolveMemberPrice(Integer basePrice, Integer memberPrice, String memberLevel) {
+    if (basePrice == null) return null;
+    // If user has member level and a memberPrice is set, use memberPrice
+    if (memberLevel != null && ("BASIC".equals(memberLevel) || "FAMILY".equals(memberLevel) || "PREMIUM".equals(memberLevel))) {
+        if (memberPrice != null) {
+            return memberPrice;
+        }
+    }
+    return basePrice;
+}
+```
+
+**场景矩阵:**
+
+| memberLevel | 结果 | 预期 |
+|-------------|------|------|
+| null        | basePrice | ✅ 非会员 |
+| "FREE"      | basePrice | ✅ 免费用户不享 |
+| "BASIC"     | memberPrice(若存在)| ✅ BASIC 纳入 |
+| "FAMILY"    | memberPrice(若存在)| ✅ 保持不变 |
+| "PREMIUM"   | memberPrice(若存在)| ✅ 保持不变 |
+| "PROVIDER"  | basePrice | ✅ 服务商不享 |
+
+**调用方:** `ActivityService.java:117, 140` 均通过 `membershipService.getMemberLevel(userId)` 获取等级后传入,逻辑完整。
+
+**结论:✅ 通过**
+
+---
+
+### 核验项 4:限时特价/秒杀路径
+
+**代码位置:** `ProductOrderService.java` 行 343-488(`createSpecialDealOrder`)
+
+```java
+// 行 423-424
+order.setUnitPrice(deal.getSpecialPrice());
+order.setTotalAmount(deal.getSpecialPrice() * quantity);
+```
+
+- 特价订单**完全不调用** `getEligibleMemberPrice`
+- 原价 = 特价(`deal.getSpecialPrice()`)
+- 该路径在本次任务变更前就已是如此(独立事务入口,规格 §5.2)
+
+**结论:✅ 通过**,特价路径未被影响。
+
+---
+
+### 核验项 5:编译验证
+
+```bash
+mvn clean compile -q
+# 退出码:0(无输出 = 无错误)
+```
+
+**结论:✅ 通过**
+
+---
+
+## 三、发现的问题
+
+### 发现 1:测试 mock 不准确(不阻塞验收)
+
+**文件:** `MembershipServiceTest.java` 行 66-67
+
+```java
+doReturn(1).when(service).getLevelOrder("FREE");   // 测试中 FREE=1
+doReturn(1).when(service).getLevelOrder("BASIC");  // 测试中 BASIC=1 ← 与生产不符
+```
+
+**问题:** 测试将 BASIC mock 为 level_order=1(与 FREE 同级),导致 `getEligibleMemberPrice` 在测试环境中 FREE 和 BASIC 行为相同。  
+**实际生产值:** 迁移 381(行 11820-11831)确保 `BASIC=2, FREE=1`。
+
+**影响:** 单元测试无法验证"FREE 不享会员价"的逻辑分支(在 mock 环境下不区分)。  
+**是否修复:** 本次核验不修改测试代码(测试 mock 是历史遗留,与需求实现无关)。建议后续由开发方修正测试数据。
+
+---
+
+## 四、总结
+
+| 核验项 | 结论 |
+|--------|------|
+| 1. getEligibleMemberPrice 逻辑正确性 | ✅ 通过 |
+| 2. ActivityDTO.resolveMemberPrice 纳入 BASIC | ✅ 通过 |
+| 3. 单商品/多商品调用点一致,无绕过 | ✅ 通过 |
+| 4. 限时特价/秒杀路径不受影响 | ✅ 通过 |
+| 5. mvn clean compile 编译通过 | ✅ 通过 |
+
+**是否建议验收:** ✅ **建议验收**。代码实现符合需求口径。BASIC 用户购买 `member_price` 设置且 `min_discount_level` 为 NULL/BASIC 的商品时获得会员价;FREE 用户获原价;FAMILY/PREMIUM 继续获会员价;`min_discount_level=FAMILY` 的商品 BASIC 用户获原价。唯一发现是单元测试 mock 数据与生产 DB 不一致(不影响生产行为)。