Jelajahi Sumber

docs(specs): AI 使用会员门禁架构设计与实现计划 v1.1

- 需求:小程序所有 AI 使用功能为会员加开关,仅有效期内缴费会员可用
- 主理人决策:BASIC 及以上可用 / 复用 ai.coach.member_gate_enabled / 过期可看+删归档 / 报告分析及视觉分析(情绪/舌诊/饮食)不纳入门禁
- 架构:MembershipService 统一 checkAiFeatureGate + /api/ai/gate + 错误码 46003/46004
- 任务拆分:T-B0/B1/B2(backend) + T-F0/F1/F2(frontend)
Agnes Agent 1 Minggu lalu
induk
melakukan
85e93fb56d
1 mengubah file dengan 404 tambahan dan 0 penghapusan
  1. 404 0
      docs/superpowers/specs/2026-11-02-ai-member-gate-design.md

+ 404 - 0
docs/superpowers/specs/2026-11-02-ai-member-gate-design.md

@@ -0,0 +1,404 @@
+# CFC 小程序「AI 使用会员门禁」架构设计与实现计划
+
+- **版本**:v1.1
+- **作者**:gavinyuan(软件架构师)
+- **目标**:为「小程序中所有关于 AI 使用的功能,为会员加一个开关,只有在有效期内的缴费会员才能够使用」产出可落地的架构设计与任务拆分
+- **文档性质**:正式任务书(已合入 cfclub,远端实例按 origin/cfclub 检出可读)
+- **主理人 Q3 拍板(2026-11-02 15:3x)**:`/butler/sessions/create` **纳入会员门禁**(与 chat 新建对话入口一致;过期用户不可新建,但 list/detail/archive/delete 保持开放)。开放问题 1/2/4/5 按架构设计默认口径闭环,无需再确认。
+- **上游口径(主理人四项决策,最高优先)**:
+  1. 等级下限 = **BASIC 及以上**(memberLevel ∈ {BASIC, FAMILY, PREMIUM, ...})才可用;FREE 不可用。
+  2. 开关粒度 = **复用现有 `ai.coach.member_gate_enabled`**,不新建统一开关。
+  3. 过期后权限 = **可查看 + 可删除/归档历史 AI 会话**(不可新建对话/继续对话)。
+  4. 不纳入门禁(保留 FREE 每日 1 次作为引流钩子):`/report/analyze`(报告分析)+ 视觉分析类:**情绪照片识别、舌诊、饮食识别**。
+
+---
+
+## 0. 现状核实结论(代码事实,非臆测)
+
+以下结论均已通过读源码核实,作为设计基线:
+
+| 事实点 | 核实结果 |
+|---|---|
+| 开关存储 | `ai.coach.member_gate_enabled` 是 **sys_config 表种子**(`DatabaseInitializer.java:1103`,默认 `"true"`),**不是 application.yml 属性**。读取走 `sysConfigService.getValue()`。 |
+| 开关现有语义 | 仅被 `AIChatController.canUseAiCoach()` 使用(仅作用于 `/health-coach/send`)。 |
+| 现有等级判定 | `canUseAiCoach()`:开关开 → 仅 `FAMILY`/`PREMIUM` 可用,`BASIC` **被拦截**(与主理人决策 1「BASIC 及以上可用」**冲突,需改**)。 |
+| 现有有效期校验 | `canUseAiCoach()` 仅依赖 `getMemberLevel()`,**不校验 BASIC 的 memberExpireTime**(`getMemberLevel` 只对 FAMILY 做了过期归一化,BASIC 未做)→ 存在「BASIC 已过期但仍放行」漏洞,需在门禁内补齐 isActive/endDate 校验。 |
+| 配额链 | `AiGateway.chat()` 内 `consumeMemberQuota(userId,"AI_CHAT",true)`:FREE 拒绝、BASIC 日 3 次、FAMILY+ 不限次(`BasicQuotaService.java:178-199`)。**需保留,门禁置于其之前(步骤②→③)**。 |
+| 端点现状 | `/chat/send`、`/nutrition/send`、`/butler/send`:**无显式门禁**(仅靠配额链兜底);`/health-coach/send`:有显式门禁(46003);`/report/analyze`:FREE 每日 1 次 Redis 计数,无门禁(保持)。 |
+| 会话 CRUD | `ButlerController`(`/api/ai/butler/sessions/*`)与 `AIChatController`(`/chat/conversations`、`/chat/messages`、`/chat/conversations/{id}/delete`)当前**均无门禁** → 按决策 3 对过期会员保持开放(历史查看/删除/归档),仅新建/发送类加门禁。 |
+| 错误码 | `ERR_AI_COACH_MEMBER_ONLY = 46003`(AIChatController.java:97);`Result`:code 200 成功 / 500 通用错 / 5001 无家庭。 |
+| 前端入口 | `cfc-frontend/pages/ai/chat.vue` `onLoad`(L248-284):仅 `getMyMembership()` 检查 `memberLevel !== 'FREE'`,**无 isActive/endDate 校验**;`UpgradeGuideModal.vue` 已有 `soft`/`hard` 两型(hard=“升级 FAMILY”)。 |
+| 前端 request() | `utils/api.js` `request()`:非 200、非 401、非 5001 时 `uni.showToast(msg)` + `reject(res.data)`(L320-329)。门禁错误码会先弹通用 toast 再 reject → 需支持抑制 toast(见 §4)。 |
+
+> **开关语义扩展结论**:`ai.coach.member_gate_enabled` 键值保留不变,但语义从「仅 AI 教练」扩展为「**全部 AI 使用功能门禁主开关**」。因为该键是 DB 种子、非硬编码且默认 `true`,**扩展语义不破坏存量配置**;若改键名会导致历史已配置值失效、出现两处真相。故**采用「同键复用 + 注释/种子描述更新」方案**,并在 DatabaseInitializer 中更新该种子描述文案以反映新语义。可选:新增别名键(不推荐,引入双真相风险)。
+
+---
+
+## 1. 门禁模型与判定顺序
+
+### 1.1 统一判定方法
+
+在 **`MembershipService`** 中新增(`sysConfigService` 已注入该类,天然可读开关):
+
+```
+// 新增枚举(建议放 dto/enums 包,或 MembershipService 内部)
+enum AiGateStatus { ALLOW, UPGRADE_REQUIRED, MEMBER_EXPIRED }
+
+// 新增 DTO(建议 dto 包)
+class AiGateResult {
+    AiGateStatus status;      // 三态结果
+    boolean enabled;          // 开关是否开启(false=未启用门禁,不拦截)
+    boolean canUse;           // 门禁功能当前是否可用
+    boolean canViewHistory;   // 是否允许查看/删除/归档历史会话(过期= true)
+    String  memberLevel;      // 归一化有效等级(getMemberLevel 结果,用于展示)
+    boolean isActive;         // 当前是否存在有效付费记录(paid && endDate>now)
+    Date    memberExpireTime; // 个人到期时间(展示用)
+    Date    endDate;          // 生效的到期时间(个人或家庭,取对应源)
+    Long    daysRemaining;    // 剩余天数(isActive 时有效)
+    String  reason;           // 'UPGRADE_REQUIRED' | 'MEMBER_EXPIRED' | null
+}
+```
+
+新增方法签名(供所有 AI 端点与统一门禁 API 复用):
+
+```
+public boolean isAiMemberGateEnabled()          // 读 sys_config,缺失/异常默认 true(沿用现有安全口径)
+public AiGateResult checkAiFeatureGate(Long userId)  // 主门禁判定,返回结构化结果
+private AiGateInfo getMemberGateInfo(Long userId)    // 内部:计算 level / isActive / endDate / everPaid
+```
+
+### 1.2 判定顺序铁律(伪代码)
+
+```
+AiGateResult checkAiFeatureGate(userId):
+    enabled = isAiMemberGateEnabled()          // ① 开关
+    info = getMemberGateInfo(userId)           //   等级 / isActive / endDate / everPaid
+
+    if !enabled:
+        return ALLOW(canUse=true, canViewHistory=true, reason=null)   // 开关关 → 不门禁,走原逻辑
+
+    if info.isActive:                          // ② 存在有效付费记录
+        if info.effectiveLevelRank >= BASIC_RANK:
+            return ALLOW(canUse=true, canViewHistory=true, reason=null)   // → ③ 配额检查 → ④ 调 AI
+        else:
+            return UPGRADE_REQUIRED(canUse=false, canViewHistory=false)   // 理论不可达,防御
+    else:                                      // 无有效付费记录
+        if info.everPaid:                      // 曾购买过(个人等级>=BASIC 或 家庭有 paid 记录)
+            return MEMBER_EXPIRED(canUse=false, canViewHistory=true)      // 决策3:可看历史+删/归档,禁新建
+        else:
+            return UPGRADE_REQUIRED(canUse=false, canViewHistory=false)   // 从未付费(FREE)→ 引导升级
+```
+
+> **关键点**:`getMemberGateInfo` 必须**独立于 `getMemberLevel()` 的归一化**去判定「isActive / everPaid」,否则 FAMILY 过期被归一化为 FREE 后会被误判为 `UPGRADE_REQUIRED` 而非 `MEMBER_EXPIRED`(决策 3 要求过期用户能看历史,必须能识别出「曾经是付费会员」)。
+
+`getMemberGateInfo` 内部逻辑(示意):
+
+```
+AiGateInfo getMemberGateInfo(userId):
+    user = userMapper.selectById(userId)
+    now = new Date()
+
+    // 个人身份(不归一化 PROVIDER,仅用于"是否付费"判定)
+    personalRaw   = user.memberLevel ?: "FREE"
+    personalExpire= user.memberExpireTime
+    personalPaid  = personalRaw != "FREE" && personalRaw != "PROVIDER"
+    personalActive= personalPaid && personalExpire != null && personalExpire.after(now)
+
+    // 家庭身份(family_memberships)
+    familyActive  = getActiveFamilyMembership(familyId) != null            // paid && endDate>now
+    familyAnyPaid = familyHasAnyPaidRecord(familyId)                        // 任意 paymentStatus='paid'(含过期)
+    familyEndDate = activeFamily != null ? activeFamily.endDate : null
+    familyRaw     = activeFamily != null ? activeFamily.levelCode : "FREE"
+
+    // 原始有效等级(未归一化),取个人/家庭更高者
+    effectiveRaw   = higher(personalRaw, familyRaw, byLevelOrder)
+    effectiveRank  = getLevelOrder(effectiveRaw)
+
+    isActive  = personalActive || familyActive
+    everPaid  = personalPaid || familyAnyPaid
+
+    // 到期时间:取"当前生效源";isActive 时用 active 源的 endDate
+    endDate = isActive ? (familyActive ? familyEndDate : personalExpire) : null
+    daysRemaining = isActive ? daysBetween(now, endDate) : 0
+
+    return AiGateInfo(effectiveLevel=getMemberLevel(userId), effectiveRank,
+                      isActive, everPaid, endDate, daysRemaining, memberExpireTime=personalExpire)
+```
+
+> **注意**:`effectiveLevel`(对外展示)仍用现有 `getMemberLevel(userId)`(归一化后),保证与全站会员口径一致;但**判定用 `effectiveRank` + `isActive` + `everPaid` 原始维度**,二者分离。
+
+### 1.3 各端点最终行为矩阵
+
+| 端点 | 门禁 | 开关关 | FREE(从未付费) | 过期(曾付费) | BASIC 有效 | FAMILY+ 有效 |
+|---|---|---|---|---|---|---|
+| `/chat/send` | 显式门禁 | 放行(走配额链) | 46003 升级 | 46004 过期 | 放行(日 3 次配额) | 放行(不限次) |
+| `/nutrition/send` | 显式门禁 | 放行 | 46003 | 46004 | 放行(日 3 次) | 放行 |
+| `/health-coach/send` | 显式门禁(改 BASIC+) | 放行 | 46003 | 46004 | 放行 | 放行 |
+| `/butler/send` | 显式门禁 | 放行 | 46003 | 46004 | 放行 | 放行 |
+| `/report/analyze` | **不纳入**(零改动) | — | 免费日 1 次 | 免费日 1 次 | 免费日 1 次 | 免费日 1 次 |
+| 情绪识别(emotion/recognize 等) | **不纳入**(零改动) | — | 放行 | 放行 | 放行 | 放行 |
+| 舌诊(parse-preview?type=tongue 等) | **不纳入**(零改动) | — | 放行 | 放行 | 放行 | 放行 |
+| 饮食识别(diet/record/recognize) | **不纳入**(零改动) | — | 日配额 | 日配额 | 日配额 | 不限次 |
+| `/chat/conversations` `/chat/messages` `/chat/conversations/{id}/delete` | **开放**(历史) | 放行 | 放行(可看,无历史) | **放行(可看+删)** | 放行 | 放行 |
+| `/butler/sessions/list` `detail` `archive` `delete` | **开放**(历史) | 放行 | 放行 | **放行(可看+删+归档)** | 放行 | 放行 |
+| `/butler/sessions/create` | 门禁(新建对话) | 放行 | 46003 | 46004 | 放行 | 放行 |
+
+---
+
+## 2. 统一门禁 API 设计
+
+### 2.1 建议:新增 `/api/ai/gate`(AIChatController)
+
+```
+POST /api/ai/gate        (@RequestAttribute("userId"))
+→ Result<AiGateResult>   // 200 成功,data 为 AiGateResult JSON
+```
+
+返回体示例:
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "enabled": true,
+    "status": "MEMBER_EXPIRED",
+    "canUse": false,
+    "canViewHistory": true,
+    "memberLevel": "FAMILY",
+    "isActive": false,
+    "memberExpireTime": "2026-10-31T00:00:00",
+    "endDate": "2026-10-31T00:00:00",
+    "daysRemaining": 0,
+    "reason": "MEMBER_EXPIRED",
+    "features": {
+      "chat":        { "canUse": false, "reason": "MEMBER_EXPIRED" },
+      "nutrition":   { "canUse": false, "reason": "MEMBER_EXPIRED" },
+      "healthCoach": { "canUse": false, "reason": "MEMBER_EXPIRED" },
+      "butler":      { "canUse": false, "reason": "MEMBER_EXPIRED" },
+      "reportAnalyze": { "canUse": true,  "reason": null },
+      "emotion":     { "canUse": true,  "reason": null },
+      "tongue":      { "canUse": true,  "reason": null },
+      "diet":        { "canUse": true,  "reason": null }
+    }
+  }
+}
+```
+
+**说明**:
+- `features` 显式列出「纳入门禁」的 4 项(chat/nutrition/healthCoach/butler,均同源共享门禁判定,返回同一 status)与「不纳入」的 4 项(固定 `canUse=true`),前端据此一次请求即可渲染会员状态条/功能锁。
+- 兼容保留现有 `/api/ai/health-coach/gate`(`aiCoachGateStatus`),其返回的 `enabled/canUse/memberLevel/memberExpireTime` 与新 gate 对齐;前端新代码统一改调 `/api/ai/gate`。
+- `memberLevel` 使用归一化口径(`getMemberLevel`),与 `/api/membership/my` 一致,避免前端两处口径打架。
+
+---
+
+## 3. 门禁端点改造清单(逐一列出)
+
+### 3.1 后端(`AIChatController`)
+
+| 端点 | 改造 |
+|---|---|
+| `/chat/send` (`sendMessage`) | 方法开头插入:`AiGateResult g = membershipService.checkAiFeatureGate(userId); if (!g.canUse) return gateError(g);`(在 `buildChatInputs` 之前) |
+| `/nutrition/send` (`sendNutritionMessage`) | 同上,插入在参数校验后、`healthAnalysisService.analyze` 前 |
+| `/health-coach/send` (`sendHealthCoachMessage`) | 将现有 `canUseAiCoach(userId)` 门禁**替换**为 `checkAiFeatureGate(userId)`(并把等级下限从 FAMILY/PREMIUM 提升到 BASIC+,见下);`ERR_AI_COACH_MEMBER_ONLY` 逻辑并入 `gateError()` |
+| `/butler/send` (`sendHealthButlerMessage`) | 同上插入门禁 |
+| 新增 `/api/ai/gate` | 见 §2 |
+| `canUseAiCoach()` | 逻辑改造/收敛到 `checkAiFeatureGate`;或直接废弃,改由门禁统一方法替代 |
+
+**`canUseAiCoach` 判定收敛**(决策 1:BASIC 及以上):
+- 现逻辑:`FAMILY || PREMIUM` → 需改为 `getLevelOrder(level) >= getLevelOrder("BASIC")`(BASIC/FAMILY/PREMIUM 均可用)。
+- 且需补齐 isActive/endDate 校验(现有 getMemberLevel 对 BASIC 不做过期判定)→ 统一走 `checkAiFeatureGate`,不再手写。
+
+**私有工具 `gateError(AiGateResult g)`**:
+```
+if g.status == MEMBER_EXPIRED: return Result.error(46004, "会员已过期,请续费后继续使用")
+else:                          return Result.error(46003, "该功能为会员专享,请先开通会员")
+```
+
+### 3.2 后端(`ButlerController`)
+
+| 端点 | 改造 |
+|---|---|
+| `/sessions/create` | **新增门禁**(新建会话=新建对话入口,过期/FREE 拦截,返回 46003/46004) |
+| `/sessions/list` `detail` `archive` `delete` `update-conversation` | **零改动**(保持开放,满足决策 3 历史查看/删除/归档) |
+
+### 3.3 后端(不纳入门禁,零改动回归)
+
+- `AIChatController` `/report/analyze`(保留 FREE 日 1 次 Redis 计数逻辑)。
+- `controller/mind/EmotionRecognitionController` `/recognize`、`EmotionCheckinController` `/emotion/recognize`。
+- `controller/HealthReportController` 舌诊 `parse-preview?type=tongue` / `confirm?type=tongue` / `discard?type=tongue`。
+- `controller/diet/DietRecordController` `/recognize`(`consumeFoodRecognizeQuota` 日配额)。
+- **不得触碰** `AiGateway.chat()` / `consumeMemberQuota()` 配额链(门禁在控制器层前置,配额链保持现状)。
+
+### 3.4 前端(`cfc-frontend`)
+
+**`utils/api.js`**:
+- 新增 `export const aiGateStatus = () => request('/api/ai/gate', 'POST', {}, { _noToast: true })`
+- `request()` 支持 `options._noToast`:当 `res.data.code` 非 200/401/5001 且 `_noToast` 为真时,**跳过 `uni.showToast`**,直接 `reject(res.data)`(保留 code/message 供调用方路由)。
+
+**`pages/ai/chat.vue`**:
+- `onLoad` 重构:由 `getMyMembership()` 改为 `aiGateStatus()`:
+  - `status === 'ALLOW'` → 加载 mascot、`loadConversations()`、处理 `pendingFirstMessage`。
+  - `status === 'UPGRADE_REQUIRED'` → 展示 `UpgradeGuideModal`(type=`hard`,或 `uni.navigateTo('/pages/membership/upgrade')`),不加载会话。
+  - `status === 'MEMBER_EXPIRED'` → 展示 `UpgradeGuideModal`(type=`expired`),**仍 `loadConversations()`(可看历史)**,但禁用输入/新会话。
+- 新增 data:`memberGate`(存 `AiGateResult`)、`gateBlocked`(bool)、`gateStatus`(string)。
+- `sendMessage()` 开头:若 `gateBlocked`(非 ALLOW)→ 直接弹对应弹窗并 return,不发请求。
+- `sendMessage()` `catch(e)`:识别 `e.code === 46003` → 弹 hard;`e.code === 46004` → 弹 expired;其它 → 保留「抱歉,我暂时无法回答」。
+- 会话侧拉「+ 新会话」:若 `gateStatus==='MEMBER_EXPIRED'||'UPGRADE_REQUIRED'` → 拦截并弹窗。
+
+**`pages/ai/components/UpgradeGuideModal.vue`**:
+- 新增 `type='expired'` 分支:标题「会员已过期」、描述「您的会员已到期,续费后即可继续使用 AI 功能。历史会话仍可查看与删除。」,按钮「去续费」(`handleUpgrade` → `/pages/membership/upgrade`),icon 改为 `⏰` 或 `🔒`。
+- 可选:新增 `title/desc` 插槽或 props 以复用,减少硬编码。
+
+**可选 P1-F1:会员状态条**:
+- 在 `chat.vue` 顶部(导航栏下方)新增细条:显示「会员到期 2026-10-31,点击续费」,仅 `status==='MEMBER_EXPIRED'` 时展示;`isActive` 且剩余 < N 天也可展示「即将到期」。
+
+---
+
+## 4. 错误码与前端交互设计
+
+### 4.1 错误码约定
+
+| 错误码 | 常量 | 语义 | HTTP | 前端动作 |
+|---|---|---|---|---|
+| `46003` | `ERR_AI_COACH_MEMBER_ONLY`(复用) | `UPGRADE_REQUIRED`:非会员(FREE/从未付费),需升级 | 200(业务码,Result 结构) | 弹 `UpgradeGuideModal type='hard'`(升级 FAMILY),跳 `/pages/membership/upgrade` |
+| `46004` | 新增 `ERR_AI_MEMBER_EXPIRED` | `MEMBER_EXPIRED`:曾付费但已过期,需续费 | 200 | 弹 `UpgradeGuideModal type='expired'`,保留历史可看;跳续费 |
+
+> 现有 `/health-coach/send` 已用 46003,语义兼容("会员专享");新增 46004 用于区分「过期」与「从未付费」两类,满足决策 3 的差异化交互。
+
+### 4.2 前端 `request()` 识别与路由
+
+现有行为(api.js L320-329):非 200 → `uni.showToast(message)` + `reject(res.data)`。
+
+**改造**:新增 `options._noToast` 请求选项:
+```
+if (res.data.code === 200) resolve(res.data)
+else {
+  if (!options._noToast) uni.showToast({ title: res.data.message || '请求失败', icon: 'none' })
+  reject(res.data)   // 保留 code/message 对象
+}
+```
+
+**调用方路由**(chat.vue `sendMessage` catch):
+```
+catch (e) {
+  const code = e && e.code
+  if (code === 46003) { this.showUpgradeModal('hard'); return }
+  if (code === 46004) { this.showUpgradeModal('expired'); return }
+  // 通用失败
+  const lastMsg = this.messages[this.messages.length - 1]
+  if (lastMsg) lastMsg.answer = '抱歉,我暂时无法回答,请稍后再试。'
+}
+```
+
+**注意**:AI 发送类调用(`aiSendMessage` 等)建议都传 `_noToast`,避免「toast + 弹窗」叠加。若不想改所有调用点,可在 `request()` 内**全局特判** `[46003, 46004]` 跳过 toast(但会耦合通用层与业务码,权衡后建议用 `_noToast` 更干净)。
+
+---
+
+## 5. 边界与风险
+
+### 5.1 边界与不可破坏项
+
+1. **不破坏 `consumeMemberQuota` 配额链**:门禁是控制器层前置过滤,`AiGateway.chat()` / `BasicQuotaService.consumeMemberQuota` **零改动**。门禁放行后仍走原配额链(FREE 拒绝、BASIC 3/日、FAMILY+ 不限)。
+2. **429/黑窗无关**:本项目「opencode 黑窗」是调度层概念(hub.py 派发/调度),与本功能无交集,**不涉及**。
+3. **不纳入门禁端点零改动**:`/report/analyze`、情绪、舌诊、饮食识别 **一行不改**,只做回归验证(见 5.3)。
+4. **开关默认 true**:改完后 chat/nutrition/butler 从「无门禁」变「默认门禁」,是**预期行为变化**(功能意图本身)。需在发布说明中提示:若线上已有 FREE 用户使用 chat(理论被配额链挡住),不受影响。
+5. **等级下限提升至 BASIC 对 health-coach 是行为放宽**:原 BASIC 被 46003 拦截,改后 BASIC 放行(日配额内)。需同步更新 `AIChatControllerGateTest`(原「开关开启_BASIC用户被拦截」用例改为「BASIC 放行」)。
+
+### 5.2 风险
+
+| 风险 | 影响 | 缓解 |
+|---|---|---|
+| `getMemberLevel` 对 BASIC 不做过期归一化 | 误放行过期 BASIC | 门禁用独立 `getMemberGateInfo`(isActive/everPaid 原始维度)判定,不依赖 getMemberLevel 归一化 |
+| FAMILY 过期被归一化为 FREE | 误判 UPGRADE_REQUIRED 而非 MEMBER_EXPIRED,过期用户看不了历史 | `everPaid` 独立判定(个人等级>=BASIC 或 家庭有 paid 记录),确保过期→MEMBER_EXPIRED |
+| 前端 `_noToast` 未全局覆盖 | 部分调用点 toast+弹窗叠加 | AI 发送类统一传 `_noToast`;或 request() 全局特判 46003/46004 |
+| 改动 health-coach 门禁影响现有测试 | 测试红 | 更新 `AIChatControllerGateTest`,新增 BASIC 放行用例 |
+| 开关关闭时 health-coach 行为 | 关闭→所有登录用户可用(现状保持) | 维持现状;决策 2 复用同一开关 |
+
+### 5.3 回归测试点清单(矩阵)
+
+| 场景 | 开关 | 等级 | /chat/send | /nutrition/send | /health-coach/send | /butler/send | /report/analyze | 视觉(情绪/舌诊/饮食) | butler 会话 CRUD |
+|---|---|---|---|---|---|---|---|---|---|
+| 会员有效 | 开 | FAMILY | 放行 | 放行 | 放行 | 放行 | 免费日1次 | 放行 | 全开放 |
+| 会员有效 | 开 | BASIC | 放行(3/日) | 放行(3/日) | 放行 | 放行(3/日) | 免费日1次 | 放行 | 全开放 |
+| 会员有效 | 开 | PREMIUM | 放行 | 放行 | 放行 | 放行 | 免费日1次 | 放行 | 全开放 |
+| 会员过期 | 开 | (曾FAMILY) | **46004** | **46004** | **46004** | **46004** | 免费日1次 | 放行 | **list/detail/archive/delete 放行;create 46004** |
+| 从未付费 | 开 | FREE | **46003** | **46003** | **46003** | **46003** | 免费日1次 | 放行 | **list 放行(空);create 46003** |
+| 开关关闭 | 关 | 任意 | 放行(走配额) | 放行 | 放行 | 放行 | 免费日1次 | 放行 | 全开放 |
+| 配额耗尽 | 开 | BASIC(3/3) | 配额拒(非门禁) | 配额拒 | —(不限) | 配额拒 | — | — | — |
+
+---
+
+## 6. 任务拆分建议(适配 opencode 派发)
+
+> 按 P0/P1/P2 拆分,后端任务与前端任务**相互独立**,可并行派发到不同实例。每任务给验收标准(DoD)。
+
+### 后端任务(backend 实例)
+
+**T-B0(P0)门禁核心**
+- 文件:`MembershipService.java`(新增 `isAiMemberGateEnabled`/`checkAiFeatureGate`/`getMemberGateInfo`);新增 `dto/AiGateResult.java`、`enums/AiGateStatus.java`(或并入 dto)。
+- 依赖:无(可在 T-B1 之前完成)。
+- DoD:单测覆盖——开关关→ALLOW;FREE→UPGRADE_REQUIRED;BASIC 有效→ALLOW;FAMILY 有效→ALLOW;曾付费过期→MEMBER_EXPIRED(canViewHistory=true);BASIC 过期→MEMBER_EXPIRED。
+
+**T-B1(P0)AIChatController 门禁接线 + gate API**
+- 文件:`AIChatController.java`(`/chat/send`、`/nutrition/send`、`/health-coach/send`、`/butler/send` 加显式门禁;`canUseAiCoach` 收敛为 BASIC+;新增 `/api/ai/gate`;`gateError()`;错误码 46004)。
+- 依赖:T-B0。
+- DoD:`AIChatControllerGateTest` 更新通过(BASIC 放行、FAMILY 放行、FREE 46003、过期 46004、开关关放行);`/api/ai/gate` 返回符合 §2 结构;`/report/analyze` 零改动回归。
+
+**T-B2(P1)ButlerController 门禁接线**
+- 文件:`ButlerController.java`(`/sessions/create` 加门禁;其余 CRUD 保持开放)。
+- 依赖:T-B0。
+- DoD:过期用户 `list/detail/archive/delete` 放行、`create` 46004;FREE `create` 46003。
+
+### 前端任务(frontend 实例)
+
+**T-F0(P0)api.js 扩展**
+- 文件:`utils/api.js`(新增 `aiGateStatus()`;`request()` 支持 `_noToast`)。
+- 依赖:无(与后端并行,靠契约联调)。
+- DoD:`aiGateStatus()` 命中 `/api/ai/gate`;`_noToast` 生效(非 200 不弹 toast 且 reject 带 code)。
+
+**T-F1(P0)chat.vue + UpgradeGuideModal 门禁交互**
+- 文件:`pages/ai/chat.vue`(onLoad 重构 + sendMessage 路由 + 新会话拦截 + 状态条可选)、`pages/ai/components/UpgradeGuideModal.vue`(`type='expired'`)。
+- 依赖:T-F0(后端 T-B0/T-B1 联调)。
+- DoD:ALLOW 正常加载;UPGRADE_REQUIRED 弹 hard;MEMBER_EXPIRED 弹 expired 且**能加载历史会话**、不能新建/发送;send 收到 46003/46004 正确路由。
+
+**T-F2(P1)会员状态条(可选)**
+- 文件:`pages/ai/chat.vue`(导航栏下状态条)。
+- 依赖:T-F1。
+- DoD:过期/临期显示续费入口,点击跳 `/pages/membership/upgrade`。
+
+> 说明:T-B0 是后端串行前置;T-B1/T-B2 可并行;T-F0 与后端并行,T-F1 依赖 T-F0(可先用 mock 联调)。
+
+---
+
+## 7. 开放问题(需主理人再确认)
+
+1. **`ai.coach.member_gate_enabled` 关闭时的行为**:当前 health-coach 为「关闭→所有登录用户可用」,改后 chat/nutrition/butler 在开关关闭时是否同样「完全放行(走原配额链)」?本设计按「关闭→不门禁,走原逻辑」处理(与现有 health-coach 语义一致)。**待确认**:若关闭时也希望对 FREE 保留配额拒绝(现状即是),则无需额外处理。
+2. **健康助手 / 营养师 / 管家是否同一门禁**:本设计判定为**同源同开关、同等级下限(BASIC+)、同过期口径**(统一 `checkAiFeatureGate`)。**待确认**:是否存在某功能需独立等级门槛(如营养师要求 FAMILY)?
+3. **`/butler/sessions/create` 是否纳入门禁**:本设计建议**纳入**(新建对话入口),与决策 3「不可新建对话」一致;但主理人决策 3 原文仅明确 list/detail/archive/delete 开放。**待确认**:create 是否也保留开放(允许过期用户建空会话但发不了消息)?
+4. **BASIC 过期判定口径**:现有 `getMemberLevel` 仅对 FAMILY 做过期归一化,BASIC 未做。本设计在门禁内**独立**补 isActive/endDate 校验,但不改动 `getMemberLevel` 全局行为(避免影响其它模块)。**待确认**:是否需要把 BASIC 过期归一化下沉到 `getMemberLevel` 全局统一(影响面更大,建议本轮不动)。
+5. **`/api/ai/gate` 新增 vs 扩展现有 `/health-coach/gate`**:本设计建议**新增** `/api/ai/gate`(返回全量 features),保留旧接口兼容。**待确认**:是否接受新增端点(避免改坏现有 health-coach 入口页消费逻辑)。
+6. **错误码 46004 是否与现有冲突**:需确认 46000-46999 段 46004 未被其它业务占用(当前仅见 46003)。
+
+---
+
+## 附:涉及文件清单
+
+**后端改动**:
+- `cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java`(新增门禁方法)
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java`(门禁接线 + `/api/ai/gate` + 错误码)
+- `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/ButlerController.java`(`/sessions/create` 门禁)
+- `cfc-backend/src/main/java/com/etotem/cfc/dto/AiGateResult.java`(新增,或并入 dto)
+- `cfc-backend/src/main/java/com/etotem/cfc/enums/AiGateStatus.java`(新增)
+- `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(更新种子描述文案,不改键/值)
+- `cfc-backend/src/test/java/com/etotem/cfc/controller/ai/AIChatControllerGateTest.java`(更新用例)
+
+**后端零改动(回归)**:
+- `AIChatController` `/report/analyze`;`EmotionRecognitionController`、`EmotionCheckinController`;`HealthReportController`(舌诊);`DietRecordController`(饮食);`AiGateway`、`BasicQuotaService`(配额链)。
+
+**前端改动**:
+- `cfc-frontend/utils/api.js`(`aiGateStatus` + `_noToast`)
+- `cfc-frontend/pages/ai/chat.vue`(onLoad 重构 + sendMessage 路由 + 新会话拦截 + 可选状态条)
+- `cfc-frontend/pages/ai/components/UpgradeGuideModal.vue`(`type='expired'`)
+
+**前端零改动(回归)**:`pages/mind`、`pages/health`(舌诊)、`pages/diet`(饮食)、`pages/report`(报告分析)相关调用点。