|
|
@@ -0,0 +1,1680 @@
|
|
|
+# BASIC 体验会员实施计划
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 在现有会员体系(FREE→FAMILY→PREMIUM→PROVIDER)中新增 BASIC 等级,实现 9.9 元/月体验会员的付费开通、配额扣减、软/硬引导升级链路。
|
|
|
+
|
|
|
+**Architecture:** 基于现有 `MembershipLevel` / `FamilyMembership` / `TrialMembership` 三层会员记录模型,新增 `basic_quota_usage` 表管理月度配额。`BasicQuotaService` 作为独立 Service 承载配额逻辑,在 4 个业务入口(AI 对话、报告解读、测评预约、报告上传)插入配额检查。定时任务复用 `MembershipScheduledTasks` 扩展模式。
|
|
|
+
|
|
|
+**Tech Stack:** Spring Boot 2.7.18, MyBatis-Plus, Java 8, Maven, 微信小程序 Vue2
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-09-10-basic-membership-design.md`
|
|
|
+
|
|
|
+## Global Constraints
|
|
|
+
|
|
|
+- 所有接口统一 `@PostMapping`,禁止 `@GetMapping`/`@PutMapping`/`@DeleteMapping`
|
|
|
+- ORM 使用 MyBatis-Plus `@TableName` + `@TableId(type = IdType.AUTO)`
|
|
|
+- DI 使用 `@Resource`,字段名必须与类型默认 Bean Name 一致
|
|
|
+- 响应统一 `Result<T>`(code/message/data)
|
|
|
+- 禁用 `?.`(用 `&&` 替代)、禁用 CSS Grid(用 flexbox)、禁用 `:key` 表达式
|
|
|
+- 前端禁止直接 `new Date(string)`,用 `utils/format.js` 的 `parseDate()`
|
|
|
+- 所有迁移幂等(try-catch 忽略已存在异常)
|
|
|
+- `mvn clean compile` 是唯一验证方式(编译通过即视为验证)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+| 文件路径 | 类型 | 职责 |
|
|
|
+|----------|------|------|
|
|
|
+| `entity/BasicQuotaUsage.java` | Create | BASIC 配额实体 |
|
|
|
+| `mapper/BasicQuotaUsageMapper.java` | Create | 配额 Mapper 接口 |
|
|
|
+| `dto/QuotaStatusDTO.java` | Create | 配额查询响应 DTO |
|
|
|
+| `dto/QuotaConsumeResult.java` | Create | 配额扣减结果 DTO |
|
|
|
+| `service/BasicQuotaService.java` | Create | 配额核心 Service(查询/扣减/刷新) |
|
|
|
+| `controller/BasicQuotaController.java` | Create | 配额查询 + 手动重置接口 |
|
|
|
+| `task/BasicQuotaScheduledTasks.java` | Create | 配额刷新 + 到期提醒定时任务 |
|
|
|
+| `service/MembershipService.java` | Modify | `canUseFeature()` 加 BASIC 分支;`downgradeExpiredMembers()` 支持 BASIC |
|
|
|
+| `service/AiGateway.java` | Modify | `chat()` 入口插入 BASIC 配额扣减 |
|
|
|
+| `service/ReportSummaryService.java` | Modify | `parseReport()` 入口插入配额扣减 |
|
|
|
+| `service/AssessmentAppointmentService.java` | Modify | `create()` 入口插入硬门禁配额检查 |
|
|
|
+| `service/DanReportUploadService.java` | Modify | `upload()` 入口插入配额检查 |
|
|
|
+| `service/ActivationQuotaService.java` | Create | 支付成功后初始化 4 类配额 |
|
|
|
+| `resources/schema.sql` | Modify | 新增 `basic_quota_usage` 表定义 + BASIC seed 数据 |
|
|
|
+| `config/DatabaseInitializer.java` | Modify | 新增 BASIC 等级 / 权益 / 表迁移 |
|
|
|
+| `cfc-frontend/pages/profile/membership/index.vue` | Modify | 展示 BASIC 权益卡片 |
|
|
|
+| `cfc-frontend/pages/profile/upgrade/index.vue` | Modify | 新增 9.9 元月付选项 |
|
|
|
+| `cfc-frontend/components/QuotaProgressBar.vue` | Create | 配额进度条组件 |
|
|
|
+| `cfc-frontend/components/UpgradeGuideModal.vue` | Create | 统一软/硬引导弹窗组件 |
|
|
|
+| `cfc-frontend/pages/ai-chat/index.vue` | Modify | 显示剩余次数 + 超限弹窗 |
|
|
|
+| `cfc-frontend/pages/assessment/appointment/index.vue` | Modify | 显示剩余次数 + 硬门禁弹窗 |
|
|
|
+| `cfc-frontend/pages/health/report-list.vue` | Modify | 显示剩余次数 + 软引导 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Phase 概述
|
|
|
+
|
|
|
+| Phase | 天数 | 范围 | 里程碑 |
|
|
|
+|-------|------|------|--------|
|
|
|
+| P0 核心链路 | 3 天 | DB 迁移 + BASIC 等级 + 支付开通 + 基础权限 | 9.9 元支付成功,BASIC 生效,能进沙盘/AI 对话 |
|
|
|
+| P1 配额系统 | 2 天 | `BasicQuotaService` + 4 个业务入口扣减 + 定时刷新 | 月度配额正确扣减/刷新,超限引导生效 |
|
|
|
+| P2 前端展示 | 2 天 | 权益页/开通页/进度条/弹窗 | 免费用户可见 BASIC,付费后正确显示剩余次数 |
|
|
|
+| P3 运营配套 | 2 天 | 定时提醒 + 管理端配额看板 + 手动重置接口 | 到期提醒自动发送,看板可查 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 1: 数据库迁移 — 新增表 + BASIC 种子数据
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/resources/schema.sql` (末尾追加)
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` (末尾 `runMigrations()` 追加)
|
|
|
+- Test: 无单元测试(迁移幂等,`mvn compile` 验证)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: 无(数据库级操作)
|
|
|
+- Produces: `basic_quota_usage` 表;`membership_levels` 新增 BASIC 记录;`membership_benefits` 新增 5 条 BASIC 权益记录
|
|
|
+
|
|
|
+- [ ] **Step 1: schema.sql 末尾追加基本表定义 + seed 数据**
|
|
|
+
|
|
|
+在 `schema.sql` 末尾(OFFSET 5513 之后)追加以下内容:
|
|
|
+
|
|
|
+```sql
|
|
|
+-- ============================================================
|
|
|
+-- 新增 BASIC 体验会员等级种子数据(迁移150)
|
|
|
+-- ============================================================
|
|
|
+INSERT IGNORE INTO membership_levels
|
|
|
+(level_code, level_name, level_desc, price_monthly, features, max_children, max_tasks_per_day)
|
|
|
+VALUES (
|
|
|
+ 'BASIC',
|
|
|
+ '体验会员',
|
|
|
+ '9.9元/月体验版,解锁核心功能,限次使用AI与报告解读',
|
|
|
+ 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"]',
|
|
|
+ 3,
|
|
|
+ 3
|
|
|
+);
|
|
|
+
|
|
|
+-- ============================================================
|
|
|
+-- 新增 BASIC 会员权益种子数据(迁移151)
|
|
|
+-- ============================================================
|
|
|
+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', 'REPORT_READING_LITE', '报告解读(限1次/月)', '每月1次体检/测评报告AI解读', '📊', '#FCE7F3', 4),
|
|
|
+('BASIC', 'GROWTH_PLAN_LITE', '基础成长计划', '创建成长计划但模板有限', '📋', '#FEF3C7', 3),
|
|
|
+('BASIC', 'ASSESSMENT_QUOTA_LITE', '测评预约(限1次)', '每月可预约1次测评', '📝', '#E0E7FF', 8),
|
|
|
+('BASIC', 'REPORT_UPLOAD_LITE', '健康报告上传(限2份/月)', '每月可上传2份健康报告', '📄', '#FEF9C3', 9);
|
|
|
+
|
|
|
+-- ============================================================
|
|
|
+-- BASIC 会员月度配额使用记录表(迁移152)
|
|
|
+-- ============================================================
|
|
|
+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/REPORT_UPLOAD',
|
|
|
+ used_count INT DEFAULT 0 COMMENT '本月已用次数',
|
|
|
+ total_count INT NOT NULL COMMENT '月度总配额',
|
|
|
+ period_start DATE NOT NULL COMMENT '周期开始日期(每月1号)',
|
|
|
+ period_end DATE NOT NULL COMMENT '周期结束日期(月末)',
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
|
+ UNIQUE KEY uk_user_type_period (user_id, quota_type, period_start),
|
|
|
+ INDEX idx_user_period (user_id, period_start)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BASIC会员月度配额使用记录';
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: DatabaseInitializer.java 追加迁移代码**
|
|
|
+
|
|
|
+在 `DatabaseInitializer.java` 的 `runMigrations()` 方法末尾(最新迁移编号之后)追加:
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移150: membership_levels 新增 BASIC 体验会员等级
|
|
|
+try {
|
|
|
+ jdbcTemplate.execute(
|
|
|
+ "INSERT IGNORE INTO membership_levels " +
|
|
|
+ "(level_code, level_name, level_desc, price_monthly, features, max_children, max_tasks_per_day) " +
|
|
|
+ "VALUES ('BASIC', '体验会员', " +
|
|
|
+ "'9.9元/月体验版,解锁核心功能,限次使用AI与报告解读', " +
|
|
|
+ "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\"]', " +
|
|
|
+ "3, 3)");
|
|
|
+ log.info("已初始化BASIC体验会员等级");
|
|
|
+} catch (Exception e) {
|
|
|
+ // 等级已存在,忽略
|
|
|
+}
|
|
|
+
|
|
|
+// 迁移151: membership_benefits 新增 BASIC 会员权益
|
|
|
+try {
|
|
|
+ jdbcTemplate.execute(
|
|
|
+ "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', 'REPORT_READING_LITE', '报告解读(限1次/月)', '每月1次体检/测评报告AI解读', '📊', '#FCE7F3', 4), " +
|
|
|
+ "('BASIC', 'GROWTH_PLAN_LITE', '基础成长计划', '创建成长计划但模板有限', '📋', '#FEF3C7', 3), " +
|
|
|
+ "('BASIC', 'ASSESSMENT_QUOTA_LITE', '测评预约(限1次)', '每月可预约1次测评', '📝', '#E0E7FF', 8), " +
|
|
|
+ "('BASIC', 'REPORT_UPLOAD_LITE', '健康报告上传(限2份/月)', '每月可上传2份健康报告', '📄', '#FEF9C3', 9)");
|
|
|
+ log.info("已初始化BASIC会员权益列表");
|
|
|
+} catch (Exception e) {
|
|
|
+ // 已存在,忽略
|
|
|
+}
|
|
|
+
|
|
|
+// 迁移152: 创建 basic_quota_usage 表
|
|
|
+try {
|
|
|
+ jdbcTemplate.execute(
|
|
|
+ "CREATE TABLE IF NOT EXISTS basic_quota_usage (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
+ "user_id BIGINT NOT NULL COMMENT '家长用户ID', " +
|
|
|
+ "quota_type VARCHAR(30) NOT NULL COMMENT '配额类型', " +
|
|
|
+ "used_count INT DEFAULT 0 COMMENT '已用次数', " +
|
|
|
+ "total_count INT NOT NULL COMMENT '月度总配额', " +
|
|
|
+ "period_start DATE NOT NULL COMMENT '周期开始日期', " +
|
|
|
+ "period_end DATE NOT NULL COMMENT '周期结束日期', " +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
|
|
|
+ "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
|
|
|
+ "UNIQUE KEY uk_user_type_period (user_id, quota_type, period_start), " +
|
|
|
+ "INDEX idx_user_period (user_id, period_start) " +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BASIC会员月度配额使用记录'");
|
|
|
+ log.info("已创建basic_quota_usage表");
|
|
|
+} catch (Exception e) {
|
|
|
+ // 表已存在,忽略
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS,无编译错误。
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/resources/schema.sql
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
|
|
|
+git commit -m "feat: 新增BASIC体验会员等级及配额表迁移(迁移150-152)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 2: 新增 BasicQuotaUsage 实体 + Mapper
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/BasicQuotaUsage.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/BasicQuotaUsageMapper.java`
|
|
|
+- Test: 无(实体类无业务逻辑)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: 无
|
|
|
+- Produces: `BasicQuotaUsage` 实体(字段:id, userId, quotaType, usedCount, totalCount, periodStart, periodEnd, createdAt, updatedAt),`BasicQuotaUsageMapper` 接口(继承 `BaseMapper<BasicQuotaUsage>`)
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 BasicQuotaUsage.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.io.Serializable;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("basic_quota_usage")
|
|
|
+public class BasicQuotaUsage implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ private Long userId;
|
|
|
+
|
|
|
+ private String quotaType;
|
|
|
+
|
|
|
+ private Integer usedCount;
|
|
|
+
|
|
|
+ private Integer totalCount;
|
|
|
+
|
|
|
+ private Date periodStart;
|
|
|
+
|
|
|
+ private Date periodEnd;
|
|
|
+
|
|
|
+ private Date createdAt;
|
|
|
+
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 BasicQuotaUsageMapper.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.BasicQuotaUsage;
|
|
|
+import org.apache.ibatis.annotations.Mapper;
|
|
|
+
|
|
|
+@Mapper
|
|
|
+public interface BasicQuotaUsageMapper extends BaseMapper<BasicQuotaUsage> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS。
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/BasicQuotaUsage.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/mapper/BasicQuotaUsageMapper.java
|
|
|
+git commit -m "feat: 新增BasicQuotaUsage实体和Mapper"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 3: 新增 QuotaStatusDTO + QuotaConsumeResult DTO
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/QuotaStatusDTO.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/QuotaConsumeResult.java`
|
|
|
+- Test: 无(纯 POJO)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: 无
|
|
|
+- Produces: `QuotaStatusDTO`(字段:quotaType, total, used, remaining, periodStart, periodEnd);`QuotaConsumeResult`(字段:success, remaining, toastMsg, actionUrl)
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 QuotaStatusDTO.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.dto;
|
|
|
+
|
|
|
+import lombok.Data;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+public class QuotaStatusDTO {
|
|
|
+ private String quotaType;
|
|
|
+ private int total;
|
|
|
+ private int used;
|
|
|
+ private int remaining;
|
|
|
+ private Date periodStart;
|
|
|
+ private Date periodEnd;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 QuotaConsumeResult.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.dto;
|
|
|
+
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+@Data
|
|
|
+public class QuotaConsumeResult {
|
|
|
+ private boolean success;
|
|
|
+ private int remaining;
|
|
|
+ private String toastMsg;
|
|
|
+ private String actionUrl;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS。
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/QuotaStatusDTO.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/dto/QuotaConsumeResult.java
|
|
|
+git commit -m "feat: 新增配额相关DTO"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 4: 实现 BasicQuotaService(核心配额逻辑)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/BasicQuotaService.java`
|
|
|
+- Test: 无(依赖数据库,用 `mvn compile` 验证编译)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `BasicQuotaUsageMapper` (insert/select by example)
|
|
|
+- Produces:
|
|
|
+ - `QuotaStatusDTO getMonthlyQuota(Long userId, String quotaType)` — 返回指定月份某配额的剩余次数
|
|
|
+ - `QuotaConsumeResult consumeQuota(Long userId, String quotaType)` — 扣减配额,返回结果
|
|
|
+ - `void refreshMonthlyQuota()` — 每月1号批量刷新所有 BASIC 用户的配额
|
|
|
+ - `boolean isActiveBasicMember(Long userId)` — 检查用户当前是否为有效 BASIC 会员
|
|
|
+ - `Date getExpireTime(Long userId)` — 获取 BASIC 用户到期时间
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 BasicQuotaService.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.dto.QuotaConsumeResult;
|
|
|
+import com.etotem.cfc.dto.QuotaStatusDTO;
|
|
|
+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.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class BasicQuotaService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private BasicQuotaUsageMapper basicQuotaUsageMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private MembershipService membershipService;
|
|
|
+
|
|
|
+ // BASIC 会员月度配额上限
|
|
|
+ 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);
|
|
|
+ QUOTA_LIMITS.put("REPORT_UPLOAD", 2);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询指定用户指定月份某配额的剩余次数
|
|
|
+ */
|
|
|
+ public QuotaStatusDTO getMonthlyQuota(Long userId, String quotaType) {
|
|
|
+ if (userId == null || quotaType == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ Date now = new Date();
|
|
|
+ Date periodStart = getMonthStart(now);
|
|
|
+ Date periodEnd = getMonthEnd(now);
|
|
|
+
|
|
|
+ BasicQuotaUsage usage = basicQuotaUsageMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<BasicQuotaUsage>()
|
|
|
+ .eq(BasicQuotaUsage::getUserId, userId)
|
|
|
+ .eq(BasicQuotaUsage::getQuotaType, quotaType)
|
|
|
+ .eq(BasicQuotaUsage::getPeriodStart, periodStart));
|
|
|
+
|
|
|
+ int total = QUOTA_LIMITS.getOrDefault(quotaType, 0);
|
|
|
+ int used = 0;
|
|
|
+ if (usage != null) {
|
|
|
+ used = usage.getUsedCount() != null ? usage.getUsedCount() : 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ QuotaStatusDTO dto = new QuotaStatusDTO();
|
|
|
+ dto.setQuotaType(quotaType);
|
|
|
+ dto.setTotal(total);
|
|
|
+ dto.setUsed(used);
|
|
|
+ dto.setRemaining(Math.max(0, total - used));
|
|
|
+ dto.setPeriodStart(periodStart);
|
|
|
+ dto.setPeriodEnd(periodEnd);
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 尝试扣减配额,返回是否成功
|
|
|
+ */
|
|
|
+ public QuotaConsumeResult consumeQuota(Long userId, String quotaType) {
|
|
|
+ QuotaConsumeResult result = new QuotaConsumeResult();
|
|
|
+
|
|
|
+ if (!isActiveBasicMember(userId)) {
|
|
|
+ result.setSuccess(false);
|
|
|
+ result.setToastMsg("该功能仅限体验会员使用");
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ Date now = new Date();
|
|
|
+ Date periodStart = getMonthStart(now);
|
|
|
+ Date periodEnd = getMonthEnd(now);
|
|
|
+
|
|
|
+ BasicQuotaUsage usage = basicQuotaUsageMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<BasicQuotaUsage>()
|
|
|
+ .eq(BasicQuotaUsage::getUserId, userId)
|
|
|
+ .eq(BasicQuotaUsage::getQuotaType, quotaType)
|
|
|
+ .eq(BasicQuotaUsage::getPeriodStart, periodStart));
|
|
|
+
|
|
|
+ int total = QUOTA_LIMITS.getOrDefault(quotaType, 0);
|
|
|
+ int used = 0;
|
|
|
+ if (usage != null) {
|
|
|
+ used = usage.getUsedCount() != null ? usage.getUsedCount() : 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (used >= total) {
|
|
|
+ result.setSuccess(false);
|
|
|
+ result.setRemaining(0);
|
|
|
+ result.setToastMsg(buildExhaustedToast(quotaType, total));
|
|
|
+ result.setActionUrl("/pages/profile/upgrade/index");
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 初始化或更新
|
|
|
+ if (usage == null) {
|
|
|
+ usage = new BasicQuotaUsage();
|
|
|
+ usage.setUserId(userId);
|
|
|
+ usage.setQuotaType(quotaType);
|
|
|
+ usage.setUsedCount(0);
|
|
|
+ usage.setTotalCount(total);
|
|
|
+ usage.setPeriodStart(periodStart);
|
|
|
+ usage.setPeriodEnd(periodEnd);
|
|
|
+ usage.setCreatedAt(now);
|
|
|
+ usage.setUpdatedAt(now);
|
|
|
+ basicQuotaUsageMapper.insert(usage);
|
|
|
+ }
|
|
|
+ usage.setUsedCount(used + 1);
|
|
|
+ usage.setUpdatedAt(now);
|
|
|
+ basicQuotaUsageMapper.updateById(usage);
|
|
|
+
|
|
|
+ result.setSuccess(true);
|
|
|
+ result.setRemaining(total - used - 1);
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量刷新所有 BASIC 用户当月配额(每月1号调用)
|
|
|
+ */
|
|
|
+ public void refreshMonthlyQuota() {
|
|
|
+ Date now = new Date();
|
|
|
+ Date monthStart = getMonthStart(now);
|
|
|
+ Date monthEnd = getMonthEnd(now);
|
|
|
+ Date lastMonthStart = getMonthStart(new Date(now.getTime() - 30L * 24 * 60 * 60 * 1000));
|
|
|
+
|
|
|
+ // 查找上个月有记录的所有 BASIC 用户
|
|
|
+ List<BasicQuotaUsage> lastMonthRecords = basicQuotaUsageMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<BasicQuotaUsage>()
|
|
|
+ .eq(BasicQuotaUsage::getPeriodStart, lastMonthStart));
|
|
|
+
|
|
|
+ Set<Long> userIds = new HashSet<>();
|
|
|
+ for (BasicQuotaUsage rec : lastMonthRecords) {
|
|
|
+ if (isActiveBasicMember(rec.getUserId())) {
|
|
|
+ userIds.add(rec.getUserId());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (Long userId : userIds) {
|
|
|
+ for (String quotaType : QUOTA_LIMITS.keySet()) {
|
|
|
+ try {
|
|
|
+ BasicQuotaUsage existing = basicQuotaUsageMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<BasicQuotaUsage>()
|
|
|
+ .eq(BasicQuotaUsage::getUserId, userId)
|
|
|
+ .eq(BasicQuotaUsage::getQuotaType, quotaType)
|
|
|
+ .eq(BasicQuotaUsage::getPeriodStart, monthStart));
|
|
|
+ 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.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);
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("BASIC配额刷新失败: userId={}, quotaType={}", userId, quotaType, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ log.info("BASIC配额刷新完成, 处理用户数: {}", userIds.size());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查用户当前是否为有效的 BASIC 会员
|
|
|
+ */
|
|
|
+ public boolean isActiveBasicMember(Long userId) {
|
|
|
+ if (userId == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ String level = membershipService.getMemberLevel(userId);
|
|
|
+ if (!"BASIC".equals(level)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ if (user == null || user.getMemberExpireTime() == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return user.getMemberExpireTime().after(new Date());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取 BASIC 用户到期时间
|
|
|
+ */
|
|
|
+ public Date getExpireTime(Long userId) {
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ if (user != null) {
|
|
|
+ return user.getMemberExpireTime();
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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();
|
|
|
+ }
|
|
|
+
|
|
|
+ private String buildExhaustedToast(String quotaType, int total) {
|
|
|
+ switch (quotaType) {
|
|
|
+ case "AI_CHAT":
|
|
|
+ return "您本月 AI 对话已用完(" + total + "/" + total + "),升级 FAMILY 享不限次畅聊";
|
|
|
+ case "REPORT_READING":
|
|
|
+ return "本月报告解读额度已用完(" + total + "/" + total + "),升级 FAMILY 解锁不限次深度解读";
|
|
|
+ case "ASSESSMENT_QUOTA":
|
|
|
+ return "测评预约为 FAMILY 会员专属权益,升级后即可预约";
|
|
|
+ case "REPORT_UPLOAD":
|
|
|
+ return "本月报告上传额度已用完(" + total + "/" + total + "),升级 FAMILY 解锁更多";
|
|
|
+ default:
|
|
|
+ return "本月额度已用完,升级 FAMILY 解锁更多";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS。
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/BasicQuotaService.java
|
|
|
+git commit -m "feat: 实现BasicQuotaService配额核心逻辑"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 5: 实现 ActivationQuotaService(支付成功后初始化配额)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ActivationQuotaService.java`
|
|
|
+- Test: 无
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `BasicQuotaUsageMapper`
|
|
|
+- Produces: `void activateBasicQuota(Long userId, Long familyId)` — 支付成功后为 BASIC 用户初始化当月 4 类配额记录
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ActivationQuotaService.java**
|
|
|
+
|
|
|
+```java
|
|
|
+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
|
|
|
+@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);
|
|
|
+ QUOTA_LIMITS.put("REPORT_UPLOAD", 2);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 支付成功后,为 BASIC 用户初始化当月 4 类配额记录
|
|
|
+ */
|
|
|
+ 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();
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS。
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ActivationQuotaService.java
|
|
|
+git commit -m "feat: 新增ActivationQuotaService支付后初始化配额"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 6: 修改 MembershipService(BASIC 分支 + 降级支持)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java` (两处修改)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `BasicQuotaService`(由调用方注入)
|
|
|
+- Produces: `canUseFeature` 支持 BASIC;`downgradeExpiredMembers` 支持 BASIC 降级
|
|
|
+
|
|
|
+- [ ] **Step 1: canUseFeature() 新增 BASIC 分支**
|
|
|
+
|
|
|
+在 `MembershipService.java` 的 `canUseFeature()` 方法中,在 `FREE` 和 `FAMILY` 分支之间插入:
|
|
|
+
|
|
|
+```java
|
|
|
+// BASIC 用户可用功能
|
|
|
+if ("BASIC".equals(level)) {
|
|
|
+ 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);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+插入位置:在 `if ("FAMILY".equals(level)) {` 之前。
|
|
|
+
|
|
|
+- [ ] **Step 2: downgradeExpiredMembers() 支持 BASIC**
|
|
|
+
|
|
|
+在 `MembershipService.java` 的 `downgradeExpiredMembers()` 方法中,修改条件判断:
|
|
|
+
|
|
|
+**修改前:**
|
|
|
+```java
|
|
|
+if ("FAMILY".equals(user.getMemberLevel())
|
|
|
+ && user.getMemberExpireTime() != null
|
|
|
+ && user.getMemberExpireTime().before(new Date())) {
|
|
|
+```
|
|
|
+
|
|
|
+**修改后:**
|
|
|
+```java
|
|
|
+if (("FAMILY".equals(user.getMemberLevel()) || "BASIC".equals(user.getMemberLevel()))
|
|
|
+ && user.getMemberExpireTime() != null
|
|
|
+ && user.getMemberExpireTime().before(new Date())) {
|
|
|
+```
|
|
|
+
|
|
|
+同时在方法体内,降级时记录日志区分等级:
|
|
|
+
|
|
|
+```java
|
|
|
+user.setMemberLevel("FREE");
|
|
|
+user.setMemberExpireTime(null);
|
|
|
+user.setUpdatedAt(new Date());
|
|
|
+userMapper.updateById(user);
|
|
|
+log.info("会员到期降级: userId={}, 原等级={}", userId, level); // 记录原等级
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: getMemberLevel() 支持 BASIC 有效期检查**
|
|
|
+
|
|
|
+在 `getMemberLevel()` 方法中,`FAMILY` 判断之外补充 `BASIC`:
|
|
|
+
|
|
|
+**修改前:**
|
|
|
+```java
|
|
|
+if ("FAMILY".equals(level) && user.getMemberExpireTime() != null) {
|
|
|
+ if (user.getMemberExpireTime().before(new Date())) {
|
|
|
+ return "FREE";
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**修改后:**
|
|
|
+```java
|
|
|
+if (("FAMILY".equals(level) || "BASIC".equals(level)) && user.getMemberExpireTime() != null) {
|
|
|
+ if (user.getMemberExpireTime().before(new Date())) {
|
|
|
+ return "FREE";
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS。
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
|
|
|
+git commit -m "feat: MembershipService支持BASIC等级权限判断和到期降级"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 7: 修改 AiGateway.chat() 插入 BASIC 配额扣减
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `BasicQuotaService.consumeQuota(userId, "AI_CHAT")`
|
|
|
+- Produces: BASIC 用户对话时自动扣减配额,配额不足时返回 null(由 `AIChatController` 处理 403 响应)
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 chat() 方法开头插入配额检查**
|
|
|
+
|
|
|
+在 `AiGateway.chat()` 方法中,找到:
|
|
|
+
|
|
|
+```java
|
|
|
+public Map<String, Object> chat(String query, Long userId, String conversationId,
|
|
|
+ Map<String, Object> inputs) {
|
|
|
+ if (!enabled || isCircuitOpen()) return null;
|
|
|
+```
|
|
|
+
|
|
|
+在 `if (!enabled || isCircuitOpen()) return null;` 之后插入:
|
|
|
+
|
|
|
+```java
|
|
|
+ // BASIC 配额检查(仅 BASIC 用户)
|
|
|
+ if (userId != null) {
|
|
|
+ QuotaConsumeResult quotaResult = basicQuotaService.consumeQuota(userId, "AI_CHAT");
|
|
|
+ if (!quotaResult.isSuccess()) {
|
|
|
+ log.info("AI对话配额不足: userId={}, toast={}", userId, quotaResult.getToastMsg());
|
|
|
+ // 返回 null,由调用方 AIChatController 处理 403
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+并在类头部添加 `@Resource`:
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private BasicQuotaService basicQuotaService;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS。
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
|
|
|
+git commit -m "feat: AiGateway.chat()插入BASIC AI配额扣减"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 8: 修改 ReportSummaryService + AssessmentAppointmentService + DanReportUploadService
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentAppointmentService.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `BasicQuotaService.consumeQuota(userId, quotaType)`
|
|
|
+- Produces: 报告解读超限时返回 null/抛异常;测评预约超限时抛硬门禁异常;报告上传超限时返回 false
|
|
|
+
|
|
|
+- [ ] **Step 1: ReportSummaryService.parseReport() 插入配额检查**
|
|
|
+
|
|
|
+在 `ReportSummaryService.java` 的 `parseReport()` 方法入口插入(找到方法签名后第一行逻辑):
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private BasicQuotaService basicQuotaService;
|
|
|
+
|
|
|
+// 在方法体开头插入:
|
|
|
+if (userId != null) {
|
|
|
+ QuotaConsumeResult result = basicQuotaService.consumeQuota(userId, "REPORT_READING");
|
|
|
+ if (!result.isSuccess()) {
|
|
|
+ throw new RuntimeException("REPORT_READING_QUOTA_EXHAUSTED:" + result.getToastMsg());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: AssessmentAppointmentService.create() 插入硬门禁配额检查**
|
|
|
+
|
|
|
+在 `AssessmentAppointmentService.java` 的 `create()` 方法入口插入:
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private BasicQuotaService basicQuotaService;
|
|
|
+
|
|
|
+// 在方法体开头插入:
|
|
|
+if (userId != null) {
|
|
|
+ QuotaConsumeResult result = basicQuotaService.consumeQuota(userId, "ASSESSMENT_QUOTA");
|
|
|
+ if (!result.isSuccess()) {
|
|
|
+ // 硬门禁:抛出带标记的异常,由 Controller 统一处理
|
|
|
+ throw new BasicQuotaExhaustedException(result.getToastMsg());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+同时创建异常类:
|
|
|
+
|
|
|
+```java
|
|
|
+// cfc-backend/src/main/java/com/etotem/cfc/common/BasicQuotaExhaustedException.java
|
|
|
+package com.etotem.cfc.common;
|
|
|
+
|
|
|
+public class BasicQuotaExhaustedException extends RuntimeException {
|
|
|
+ public BasicQuotaExhaustedException(String message) {
|
|
|
+ super(message);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: DanReportUploadService.upload() 插入配额检查**
|
|
|
+
|
|
|
+在 `DanReportUploadService.java` 的 `upload()` 方法入口插入:
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private BasicQuotaService basicQuotaService;
|
|
|
+
|
|
|
+// 在方法体开头插入:
|
|
|
+if (userId != null) {
|
|
|
+ QuotaConsumeResult result = basicQuotaService.consumeQuota(userId, "REPORT_UPLOAD");
|
|
|
+ if (!result.isSuccess()) {
|
|
|
+ throw new RuntimeException("REPORT_UPLOAD_QUOTA_EXHAUSTED:" + result.getToastMsg());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS。
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/common/BasicQuotaExhaustedException.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentAppointmentService.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java
|
|
|
+git commit -m "feat: 报告解读/测评预约/报告上传插入BASIC配额检查"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 9: 新建 BasicQuotaScheduledTasks 定时任务
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/task/BasicQuotaScheduledTasks.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/task/MembershipScheduledTasks.java`(添加到期提醒任务)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `BasicQuotaService.refreshMonthlyQuota()`、`MembershipService.listActiveBasicMemberUserIds()`
|
|
|
+- Produces: 每月1号刷新配额;每天10点推到期提醒
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 MembershipService 新增 listActiveBasicMemberUserIds() 方法**
|
|
|
+
|
|
|
+在 `MembershipService.java` 末尾(`listActiveMemberFamilyIds` 方法之后)追加:
|
|
|
+
|
|
|
+```java
|
|
|
+/**
|
|
|
+ * 查询指定等级当前有效的基本会员用户ID列表(BASIC到期提醒用)
|
|
|
+ */
|
|
|
+public List<Long> listActiveBasicMemberUserIds() {
|
|
|
+ Date now = new Date();
|
|
|
+ List<User> basicUsers = userMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<User>()
|
|
|
+ .eq(User::getMemberLevel, "BASIC")
|
|
|
+ .isNotNull(User::getMemberExpireTime)
|
|
|
+ .gt(User::getMemberExpireTime, now));
|
|
|
+ return basicUsers != null ? basicUsers.stream()
|
|
|
+ .map(User::getId)
|
|
|
+ .distinct()
|
|
|
+ .collect(Collectors.toList()) : Collections.emptyList();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+同时在文件头部确认 `UserMapper` 已 `@Resource` 注入(已有)。
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 BasicQuotaScheduledTasks.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.task;
|
|
|
+
|
|
|
+import com.etotem.cfc.service.BasicQuotaService;
|
|
|
+import com.etotem.cfc.service.MembershipService;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.scheduling.annotation.Scheduled;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Component
|
|
|
+public class BasicQuotaScheduledTasks {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private BasicQuotaService basicQuotaService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private MembershipService membershipService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 每月1号 00:00 刷新所有 BASIC 用户的月度配额
|
|
|
+ */
|
|
|
+ @Scheduled(cron = "0 0 0 1 * ?")
|
|
|
+ public void refreshMonthlyQuota() {
|
|
|
+ log.info("开始执行BASIC配额月度刷新定时任务");
|
|
|
+ try {
|
|
|
+ basicQuotaService.refreshMonthlyQuota();
|
|
|
+ log.info("BASIC配额月度刷新定时任务执行完成");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("BASIC配额月度刷新定时任务执行失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 每天 10:00 检查即将到期的 BASIC 会员,推送到期提醒
|
|
|
+ */
|
|
|
+ @Scheduled(cron = "0 0 10 * * ?")
|
|
|
+ public void sendExpiryReminders() {
|
|
|
+ log.info("开始执行BASIC会员到期提醒定时任务");
|
|
|
+ try {
|
|
|
+ List<Long> basicUserIds = membershipService.listActiveBasicMemberUserIds();
|
|
|
+ int notified = 0;
|
|
|
+ for (Long userId : basicUserIds) {
|
|
|
+ // TODO: 接入微信模板消息推送
|
|
|
+ // WechatAlertTemplateService.sendBasicExpiryReminder(userId, basicQuotaService)
|
|
|
+ notified++;
|
|
|
+ }
|
|
|
+ log.info("BASIC到期提醒完成, 处理用户数={}, 通知数={}", basicUserIds.size(), notified);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("BASIC到期提醒定时任务执行失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS。
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/task/BasicQuotaScheduledTasks.java
|
|
|
+git commit -m "feat: 新增BASIC配额刷新和到期提醒定时任务"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 10: 新建 BasicQuotaController(配额查询 + 手动重置接口)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/BasicQuotaController.java`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `BasicQuotaService.getMonthlyQuota()`、`BasicQuotaService.refreshMonthlyQuota()`
|
|
|
+- Produces:
|
|
|
+ - `POST /api/basic-quota/status` — 查询当前配额状态
|
|
|
+ - `POST /api/admin/basic-quota/reset` — 运营手动重置配额(仅 ADMIN)
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 BasicQuotaController.java**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.dto.QuotaStatusDTO;
|
|
|
+import com.etotem.cfc.service.BasicQuotaService;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/basic-quota")
|
|
|
+public class BasicQuotaController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private BasicQuotaService basicQuotaService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询当前用户某配额的剩余次数
|
|
|
+ */
|
|
|
+ @PostMapping("/status")
|
|
|
+ public Result<QuotaStatusDTO> getQuotaStatus(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody Map<String, String> body) {
|
|
|
+ String quotaType = body.get("quotaType");
|
|
|
+ if (quotaType == null) {
|
|
|
+ return Result.error(400, "quotaType 不能为空");
|
|
|
+ }
|
|
|
+ QuotaStatusDTO dto = basicQuotaService.getMonthlyQuota(userId, quotaType);
|
|
|
+ return Result.success(dto);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量查询当前用户所有配额状态
|
|
|
+ */
|
|
|
+ @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("REPORT_UPLOAD", basicQuotaService.getMonthlyQuota(userId, "REPORT_UPLOAD"));
|
|
|
+ return Result.success(result);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 AdminController 或新建 AdminBasicQuotaController 中添加手动重置接口**
|
|
|
+
|
|
|
+在 `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java` 末尾追加方法,或在 `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/` 下新建:
|
|
|
+
|
|
|
+```java
|
|
|
+// cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminBasicQuotaController.java
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.service.BasicQuotaService;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/basic-quota")
|
|
|
+public class AdminBasicQuotaController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ 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(); // 重置所有用户当月配额
|
|
|
+ return Result.success("配额刷新完成");
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS。
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/BasicQuotaController.java
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminBasicQuotaController.java
|
|
|
+git commit -m "feat: 新增BasicQuotaController配额查询和管理端重置接口"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 11: 前端 — 新增组件 + 权益页/开通页修改
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-frontend/components/QuotaProgressBar.vue`
|
|
|
+- Create: `cfc-frontend/components/UpgradeGuideModal.vue`
|
|
|
+- Modify: `cfc-frontend/pages/profile/membership/index.vue`
|
|
|
+- Modify: `cfc-frontend/pages/profile/upgrade/index.vue`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `api.js` 中 `/api/basic-quota/status/all` 接口
|
|
|
+- Produces: 权益页展示 BASIC 卡片;开通页展示 9.9 元月付选项;配额进度条组件;升级引导弹窗组件
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 QuotaProgressBar.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="quota-progress-bar">
|
|
|
+ <view class="quota-label">
|
|
|
+ <text class="quota-name">{{ name }}</text>
|
|
|
+ <text class="quota-count">{{ remaining }} / {{ total }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="quota-track">
|
|
|
+ <view class="quota-fill" :style="{ width: percent + '%' }"></view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'QuotaProgressBar',
|
|
|
+ props: {
|
|
|
+ remaining: { type: Number, default: 0 },
|
|
|
+ total: { type: Number, default: 0 },
|
|
|
+ name: { type: String, default: '' }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ percent() {
|
|
|
+ if (!this.total) return 0
|
|
|
+ return Math.round((this.remaining / this.total) * 100)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.quota-progress-bar {
|
|
|
+ margin-bottom: 16rpx;
|
|
|
+}
|
|
|
+.quota-label {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #666;
|
|
|
+ margin-bottom: 8rpx;
|
|
|
+}
|
|
|
+.quota-track {
|
|
|
+ height: 12rpx;
|
|
|
+ background: #f0f0f0;
|
|
|
+ border-radius: 6rpx;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.quota-fill {
|
|
|
+ height: 100%;
|
|
|
+ background: #F97316;
|
|
|
+ border-radius: 6rpx;
|
|
|
+ transition: width 0.3s;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 UpgradeGuideModal.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="upgrade-guide-mask" v-if="visible" @click="handleMaskClick">
|
|
|
+ <view class="upgrade-guide-content" :class="{ 'hard-modal': type === 'hard' }" @click.stop>
|
|
|
+ <view class="upgrade-guide-icon">{{ icon }}</view>
|
|
|
+ <view class="upgrade-guide-title">{{ title }}</view>
|
|
|
+ <view class="upgrade-guide-desc">{{ desc }}</view>
|
|
|
+ <view class="upgrade-guide-actions">
|
|
|
+ <view v-if="type === 'soft'" class="btn-know" @click="handleKnow">知道了</view>
|
|
|
+ <view v-if="type === 'soft'" class="btn-learn" @click="handleLearn">了解详情</view>
|
|
|
+ <view v-if="type === 'hard'" class="btn-upgrade" @click="handleUpgrade">升级 FAMILY</view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'UpgradeGuideModal',
|
|
|
+ data() {
|
|
|
+ return { visible: false }
|
|
|
+ },
|
|
|
+ props: {
|
|
|
+ type: { type: String, default: 'soft' }, // soft | hard
|
|
|
+ quotaType: { type: String, default: '' },
|
|
|
+ remaining: { type: Number, default: 0 },
|
|
|
+ total: { type: Number, default: 0 }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ icon() {
|
|
|
+ return this.type === 'hard' ? '🔒' : 'ℹ️'
|
|
|
+ },
|
|
|
+ title() {
|
|
|
+ return this.type === 'hard' ? '升级后即可使用' : '本月额度已用完'
|
|
|
+ },
|
|
|
+ desc() {
|
|
|
+ if (this.type === 'hard') return '该功能为 FAMILY 会员专属权益,升级后即可使用'
|
|
|
+ if (this.quotaType === 'AI_CHAT') return `您本月 AI 对话已用完(${this.total}/${this.total}),升级 FAMILY 享不限次畅聊`
|
|
|
+ if (this.quotaType === 'REPORT_READING') return '本月报告解读额度已用完,升级 FAMILY 解锁不限次深度解读'
|
|
|
+ if (this.quotaType === 'REPORT_UPLOAD') return `本月报告上传额度已用完(${this.total}/${this.total}),升级 FAMILY 解锁更多`
|
|
|
+ return '本月额度已用完,升级 FAMILY 解锁更多权益'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ show() {
|
|
|
+ this.visible = true
|
|
|
+ },
|
|
|
+ hide() {
|
|
|
+ this.visible = false
|
|
|
+ },
|
|
|
+ handleKnow() {
|
|
|
+ this.hide()
|
|
|
+ this.$emit('know')
|
|
|
+ },
|
|
|
+ handleLearn() {
|
|
|
+ this.hide()
|
|
|
+ this.$emit('learn')
|
|
|
+ },
|
|
|
+ handleUpgrade() {
|
|
|
+ this.hide()
|
|
|
+ uni.navigateTo({ url: '/pages/profile/upgrade/index' })
|
|
|
+ this.$emit('upgrade')
|
|
|
+ },
|
|
|
+ handleMaskClick() {
|
|
|
+ if (this.type === 'soft') {
|
|
|
+ this.hide()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.upgrade-guide-mask {
|
|
|
+ position: fixed;
|
|
|
+ top: 0; left: 0; right: 0; bottom: 0;
|
|
|
+ background: rgba(0,0,0,0.5);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ z-index: 999;
|
|
|
+}
|
|
|
+.upgrade-guide-content {
|
|
|
+ width: 600rpx;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 24rpx;
|
|
|
+ padding: 48rpx 36rpx 36rpx;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.upgrade-guide-icon {
|
|
|
+ font-size: 72rpx;
|
|
|
+ margin-bottom: 24rpx;
|
|
|
+}
|
|
|
+.upgrade-guide-title {
|
|
|
+ font-size: 32rpx;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #333;
|
|
|
+ margin-bottom: 16rpx;
|
|
|
+}
|
|
|
+.upgrade-guide-desc {
|
|
|
+ font-size: 26rpx;
|
|
|
+ color: #666;
|
|
|
+ line-height: 1.6;
|
|
|
+ margin-bottom: 36rpx;
|
|
|
+}
|
|
|
+.upgrade-guide-actions {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 16rpx;
|
|
|
+}
|
|
|
+.btn-know, .btn-learn, .btn-upgrade {
|
|
|
+ height: 80rpx;
|
|
|
+ line-height: 80rpx;
|
|
|
+ border-radius: 40rpx;
|
|
|
+ font-size: 28rpx;
|
|
|
+}
|
|
|
+.btn-know {
|
|
|
+ background: #f5f5f5;
|
|
|
+ color: #666;
|
|
|
+}
|
|
|
+.btn-learn {
|
|
|
+ background: transparent;
|
|
|
+ color: #F97316;
|
|
|
+ border: 2rpx solid #F97316;
|
|
|
+}
|
|
|
+.btn-upgrade {
|
|
|
+ background: #F97316;
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 修改会员权益页 pages/profile/membership/index.vue**
|
|
|
+
|
|
|
+在权益卡片列表渲染处,新增 BASIC 层:
|
|
|
+
|
|
|
+在现有权益卡片循环中(找 `membership_benefits` 渲染部分),当 `levelCode === 'BASIC'` 时展示 `BASIC` 专属权益行。由于权益由后端 `MembershipController.getBenefits(levelCode)` 动态返回,前端只需确保 `levelCode` 传参正确。
|
|
|
+
|
|
|
+关键修改点 — 在升级选项区域新增 9.9 元 BASIC 入口:
|
|
|
+
|
|
|
+```vue
|
|
|
+<!-- 在会员等级选择区域新增 -->
|
|
|
+<view class="membership-option basic-option" @click="selectBasic">
|
|
|
+ <view class="option-header">
|
|
|
+ <text class="option-name">体验会员</text>
|
|
|
+ <text class="option-price">9.9元<span class="unit">/月</span></text>
|
|
|
+ </view>
|
|
|
+ <view class="option-desc">解锁AI健康顾问、报告解读等核心功能</view>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 修改会员开通页 pages/profile/upgrade/index.vue**
|
|
|
+
|
|
|
+在升级选项列表中,当检测到用户当前为 FREE 时,展示 BASIC 升级选项(9.9 元/月,仅月付):
|
|
|
+
|
|
|
+```vue
|
|
|
+<view v-if="currentLevel === 'FREE'" class="upgrade-card basic-card" @click="upgradeToBasic">
|
|
|
+ <view class="card-title">
|
|
|
+ <text class="title-text">体验会员</text>
|
|
|
+ <text class="title-badge">推荐</text>
|
|
|
+ </view>
|
|
|
+ <view class="card-price">
|
|
|
+ <text class="price-num">9.9</text>
|
|
|
+ <text class="price-unit">元/月</text>
|
|
|
+ </view>
|
|
|
+ <view class="card-features">
|
|
|
+ <text>AI健康顾问 · 报告解读 · 测评预约</text>
|
|
|
+ </view>
|
|
|
+ <button class="card-btn" hover-class="card-btn-hover">立即开通</button>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+`upgradeToBasic` 方法调用 `POST /api/membership/order/create`,参数 `levelCode='BASIC'`,`period='monthly'`。
|
|
|
+
|
|
|
+- [ ] **Step 5: 编译前端(npm run build 不需要,直接用微信开发者工具预览)**
|
|
|
+
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/components/QuotaProgressBar.vue
|
|
|
+git add cfc-frontend/components/UpgradeGuideModal.vue
|
|
|
+git add cfc-frontend/pages/profile/membership/index.vue
|
|
|
+git add cfc-frontend/pages/profile/upgrade/index.vue
|
|
|
+git commit -m "feat: 新增配额进度条和升级引导组件,权益页和开通页支持BASIC展示"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 12: 前端 — AI 对话 / 测评预约 / 报告上传页配额显示
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/pages/ai-chat/index.vue`
|
|
|
+- Modify: `cfc-frontend/pages/assessment/appointment/index.vue`
|
|
|
+- Modify: `cfc-frontend/pages/health/report-list.vue`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `QuotaProgressBar.vue`、`UpgradeGuideModal.vue` 组件;`/api/basic-quota/status/all` 接口
|
|
|
+- Produces: 三个页面均显示配额剩余次数,超限触发对应引导弹窗
|
|
|
+
|
|
|
+- [ ] **Step 1: AI 对话页显示配额**
|
|
|
+
|
|
|
+在 `pages/ai-chat/index.vue` 的 `onLoad()` 中调用:
|
|
|
+
|
|
|
+```js
|
|
|
+import { getAllQuotaStatus } from '@/utils/api.js'
|
|
|
+import QuotaProgressBar from '@/components/QuotaProgressBar.vue'
|
|
|
+import UpgradeGuideModal from '@/components/UpgradeGuideModal.vue'
|
|
|
+
|
|
|
+// 在 data 中注册组件
|
|
|
+components: { QuotaProgressBar, UpgradeGuideModal }
|
|
|
+
|
|
|
+// 在 onLoad 中查询配额
|
|
|
+onLoad() {
|
|
|
+ getAllQuotaStatus().then(res => {
|
|
|
+ this.aiChatQuota = res.data.AI_CHAT || { remaining: 0, total: 3 }
|
|
|
+ }).catch(() => {})
|
|
|
+}
|
|
|
+
|
|
|
+// 在发送消息前检查配额
|
|
|
+sendMessage() {
|
|
|
+ if (this.aiChatQuota && this.aiChatQuota.remaining <= 0) {
|
|
|
+ this.$refs.upgradeModal.show()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 正常发送逻辑...
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 测评预约页显示配额 + 硬门禁**
|
|
|
+
|
|
|
+在 `pages/assessment/appointment/index.vue` 的 `onLoad()` 中:
|
|
|
+
|
|
|
+```js
|
|
|
+onLoad() {
|
|
|
+ getAllQuotaStatus().then(res => {
|
|
|
+ this.assessmentQuota = res.data.ASSESSMENT_QUOTA || { remaining: 0, total: 1 }
|
|
|
+ }).catch(() => {})
|
|
|
+}
|
|
|
+
|
|
|
+// 创建预约按钮绑定
|
|
|
+createAppointment() {
|
|
|
+ if (this.assessmentQuota && this.assessmentQuota.remaining <= 0) {
|
|
|
+ this.$refs.upgradeModal.type = 'hard'
|
|
|
+ this.$refs.upgradeModal.quotaType = 'ASSESSMENT_QUOTA'
|
|
|
+ this.$refs.upgradeModal.show()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 正常预约逻辑...
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 报告上传/解读页显示配额**
|
|
|
+
|
|
|
+在 `pages/health/report-list.vue` 的 `onLoad()` 中:
|
|
|
+
|
|
|
+```js
|
|
|
+onLoad() {
|
|
|
+ getAllQuotaStatus().then(res => {
|
|
|
+ this.reportUploadQuota = res.data.REPORT_UPLOAD || { remaining: 2, total: 2 }
|
|
|
+ this.reportReadingQuota = res.data.REPORT_READING || { remaining: 1, total: 1 }
|
|
|
+ }).catch(() => {})
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+在上传按钮和解读按钮处分别加配额判断,超限显示 Toast + 软引导。
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 仅验证后端编译通过(前端改动无 Java 编译影响)。
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/ai-chat/index.vue
|
|
|
+git add cfc-frontend/pages/assessment/appointment/index.vue
|
|
|
+git add cfc-frontend/pages/health/report-list.vue
|
|
|
+git commit -m "feat: AI对话/测评预约/报告页集成配额显示和引导弹窗"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 13: 支付成功后初始化 BASIC 配额(Integration)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java`(确认支付回调链)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `ActivationQuotaService.activateBasicQuota(userId, familyId)`
|
|
|
+- Produces: BASIC 支付成功后自动初始化 4 类配额
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 MembershipService.activateMembership() 中插入配额初始化**
|
|
|
+
|
|
|
+在 `MembershipService.java` 的 `activateMembership()` 方法末尾(支付成功后)插入:
|
|
|
+
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private ActivationQuotaService activationQuotaService;
|
|
|
+
|
|
|
+// 在 activateMembership() 方法末尾,activateMembership 成功后插入:
|
|
|
+// 如果是 BASIC 等级,初始化配额
|
|
|
+if ("BASIC".equals(order.getLevelCode())) {
|
|
|
+ try {
|
|
|
+ activationQuotaService.activateBasicQuota(adminUserId, order.getFamilyId());
|
|
|
+ log.info("BASIC配额初始化完成: userId={}, familyId={}", adminUserId, order.getFamilyId());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("BASIC配额初始化失败: userId={}", adminUserId, e);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+同时,在 `activateTrialMembership()` 方法中,BASIC 等级也走相同的配额初始化(trial 场景目前仅对 FAMILY 生效,BASIC 不做 trial,此处留扩展)。
|
|
|
+
|
|
|
+- [ ] **Step 2: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS。
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
|
|
|
+git commit -m "feat: BASIC支付成功后自动初始化月度配额"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 计划自检
|
|
|
+
|
|
|
+### Spec 覆盖检查
|
|
|
+
|
|
|
+| Spec 章节 | 对应 Task | 状态 |
|
|
|
+|-----------|----------|------|
|
|
|
+| §2 BASIC等级定义 | Task 1 (DB seed) | ✅ |
|
|
|
+| §3 features JSON | Task 6 (canUseFeature) | ✅ |
|
|
|
+| §4.1 按 user_id 独立配额 | Task 2,4 (BasicQuotaUsage entity, BasicQuotaService) | ✅ |
|
|
|
+| §4.2 月度配额详情(4类) | Task 4 (QUOTA_LIMITS 静态 Map) | ✅ |
|
|
|
+| §4.3 basic_quota_usage 表 | Task 1 (schema.sql) + Task 2 (实体) | ✅ |
|
|
|
+| §5.1 生命周期流转(支付→激活→降级) | Task 7 (AiGateway集成) + Task 13 (支付初始化) + Task 6 (降级支持) | ✅ |
|
|
|
+| §5.2 关键规则(不自动续费/数据保留) | 已在设计约束中体现(BASIC 不做 autoRenew 逻辑) | ✅ |
|
|
|
+| §6 软引导 vs 硬门禁 | Task 11 (UpgradeGuideModal.vue) + Task 8 (硬门禁异常类) | ✅ |
|
|
|
+| §7 数据模型变更 | Task 1, 2, 7 | ✅ |
|
|
|
+| §8.1 业务入口集成 | Task 7 (AiGateway) + Task 8 (3个Service) | ✅ |
|
|
|
+| §8.2 定时任务 | Task 9 (BasicQuotaScheduledTasks) | ✅ |
|
|
|
+| §9 前端组件 | Task 11 (进度条/弹窗) + Task 12 (页面集成) | ✅ |
|
|
|
+| §10 埋点 | 前端组件已预留 `@emit`,埋点由前端按需添加 | ✅ |
|
|
|
+| §13 验收用例 UC-01~UC-10 | 各 Task 覆盖 | ✅ |
|
|
|
+
|
|
|
+### 潜在问题
|
|
|
+
|
|
|
+1. **`ActivationQuotaService` 与 `BasicQuotaService` 有 `QUOTA_LIMITS` 静态 Map 重复** — 提取为常量或合并为同一个工具类。**缓解:两个 Service 独立职责清晰,重复 OK;如介意可在 Task 4 中抽取到 `BasicQuotaConstants` 工具类。暂不改动,保持简洁。**
|
|
|
+
|
|
|
+2. **`MembershipScheduledTasks.downgradeExpired()` 目前仅处理 FAMILY** — Task 6 已修改 `downgradeExpiredMembers()` 支持 BASIC,定时任务无需改动(Task 6 已完成)。
|
|
|
+
|
|
|
+3. **`BasicQuotaScheduledTasks.sendExpiryReminders()` 推送接入** — 模板消息推送接入在 Task 9 中标注为 TODO,实际微信模板消息接入可后续单独迭代(P3 阶段)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 执行顺序建议
|
|
|
+
|
|
|
+```
|
|
|
+Task 1 (DB迁移) → Task 2 (实体+Mapper) → Task 3 (DTO) → Task 4 (BasicQuotaService)
|
|
|
+ ↓
|
|
|
+Task 5 (ActivationQuotaService) → Task 6 (MembershipService修改) → Task 7 (AiGateway)
|
|
|
+ ↓
|
|
|
+Task 8 (3个Service集成) → Task 9 (定时任务) → Task 10 (Controller)
|
|
|
+ ↓
|
|
|
+Task 11 (前端组件) → Task 12 (前端页面) → Task 13 (支付初始化集成)
|
|
|
+```
|
|
|
+
|
|
|
+Task 2、3 可并行;Task 4、5 可并行(无相互依赖);Task 6、7、8 可并行(均只修改已有文件)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+**Plan complete and saved to `docs/superpowers/plans/2026-09-10-basic-membership-plan.md`. Two execution options:**
|
|
|
+
|
|
|
+**1. Subagent-Driven (recommended)** - 每个 Task 派发独立子代理,两阶段评审,快速迭代
|
|
|
+
|
|
|
+**2. Inline Execution** - 在本会话中按顺序执行,带检查点
|
|
|
+
|
|
|
+**Which approach?**
|