收口文档评审冲突并补齐 Sprint 执行计划

统一性能、token、配额与成本口径,补齐短信登录等缺口任务,并回写脚手架实测暴露的思考模式与峰谷计价约束。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
xsl
2026-08-14 09:40:50 +08:00
co-authored by Cursor
parent e31755e343
commit f4075f6827
13 changed files with 1200 additions and 290 deletions
+200 -64
View File
@@ -1,6 +1,6 @@
# 「美搭」技术架构文档
> 版本:v1.0 | 更新日期:2026-08-13 | 状态:待技术评审
> 版本:**v1.3** | 更新日期:2026-08-13 | 状态:待技术评审
>
> **本文的定位**:把产品需求翻译成可直接拆解为开发任务的技术方案。读完这份文档,应该能回答三个问题——系统由哪些部分组成、数据长什么样、每条关键链路怎么跑通。
>
@@ -66,24 +66,29 @@
└───┬──────────┬──────────┬──────────┬──────────┬──────────┬───────────────┘
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌────────┐┌────────┐┌────────┐┌────────┐┌────────┐┌──────────────┐
│华为服务 ││大模型 ││腾讯云AI ││自有换装 ││腾讯云 ││腾讯云基础设施 │
│账号/推送││DeepSeek││人脸识别 ││发型服务 ││天御审核 ││COS/CDN/PG/ │
│Push ││智谱GLM ││ ││(GPU) ││ ││Redis/CLS │
└────────┘└────────┘└────────┘└────────┘└────────┘└──────────────┘
┌────────┐┌────────┐┌────────┐┌────────┐┌────────┐┌────────┐┌──────────────┐
│华为服务 ││大模型 ││腾讯云AI ││自有换装 ││腾讯云 ││腾讯云 ││腾讯云基础设施 │
│账号/推送││DeepSeek││人脸识别 ││发型服务 ││天御审核 ││短信SMS ││COS/CDN/PG/ │
│Push ││智谱GLM ││ ││(GPU) ││ ││ ││Redis/CLS │
└────────┘└────────┘└────────┘└────────┘└────────┘└────────┘└──────────────┘
注:V1 不接入天气 API(TD-33),Agent 的天气类问题走「反问用户 + 城市月份推断气候区」降级
```
**外部依赖及其失效影响**(每一个都要有降级方案,见 §13.4):
| 外部系统 | 用途 | 失效影响 | 降级策略 |
| --- | --- | --- | --- |
| 华为 Account Kit | 登录 | 新用户无法注册 | 游客模式可继续体验非付费功能 |
| 华为 IAP | 支付(**V2 起**) | 无法购买 | 提示稍后再试,不影响已有权益。V1 不依赖此项 |
| 华为 Account Kit | 登录(通道一) | 该通道不可用 | **手机号验证码登录仍可用**(两条通道不得互相依赖);游客模式可继续体验 |
| **腾讯云短信 SMS** | **登录(通道二,`TD-32`)** | 该通道不可用 | **华为账号登录仍可用**;登录页给出明确提示而非笼统报错 |
| 华为 Push Kit | 任务完成通知 | 通知不到 | 应用内任务中心轮询兜底 |
| DeepSeek / GLM | 文案生成、对话 | 报告与对话不可用 | 双供应商自动切换;均失败时报告排队重试,对话返回兜底话术 |
| 腾讯云人脸识别 | 面部分析 | 报告无法生成面部维度 | 报告降级为「体型+色彩」子集,标注待补全 |
| 自有换装服务 | 出图 | 换装不可用 | 任务转排队,不扣配额,超时退还 |
| 腾讯云天御 | 内容审核 | **不可降级** | 审核失败一律按「拦截」处理(fail-close),宁可少发不可漏审 |
| 华为 IAP | 支付(**V2 起**) | 无法购买 | 提示稍后再试,不影响已有权益。**V1 不依赖此项** |
> 🔑 **登录必须有两条互不依赖的通道**,这是 V1 唯一的单点故障防护。华为账号是唯一通道时,华为侧授权异常会让用户直接进不来产品;而一键登录已推至 V2(`TD-29`),所以手机号验证码是 V1 的必需项,不是可选项。
---
@@ -265,11 +270,11 @@ kb ← agent / report(读取)
| 表 | 关键字段 | 说明 |
| --- | --- | --- |
| `id_user` | `user_id` PK, `status`, `nickname`, `avatar_key`, `register_source`, `created_at`, `last_active_at`, `deleted_at` | 内部主键,与外部身份解耦 |
| `id_user_identity` | `user_id`, `provider`('huawei'), `union_id`, `open_id`, UNIQUE(`provider`,`union_id`) | 一个用户可有多个外部身份 |
| `id_user` | `user_id` PK, `status`('active'/'frozen'/'deleting'/'deleted'), `nickname`, `avatar_key`, `register_source`, `created_at`, `last_active_at`, `deleted_at` | 内部主键,与外部身份解耦。**手机号不存本表**,见下行 |
| `id_user_identity` | `user_id`, `provider`(**'huawei'/'phone'**), `union_id`, `open_id`, **`phone_enc`(AES 加密), `phone_hash`(检索用)**, UNIQUE(`provider`,`union_id`) | 一个用户可有多个外部身份。**手机号作为一种 `provider` 存在本表,不在 `id_user` 冗余** —— 它是登录凭证而非用户属性,且便于统一加密与脱敏(`NFR-SEC-03`)。检索用 `phone_hash`,展示用解密后掩码 |
| `id_guest` | `guest_id`, `device_fp`, `merged_to_user_id`, `created_at` | 游客数据,登录后合并 |
| `id_device` | `device_id`, `user_id`, `push_token`, `os_version`, `last_seen_at` | 推送 Token 管理 |
| `id_consent` | `consent_id`, `user_id`, `consent_type`('terms'/'privacy'/'face'/'minor'), `doc_version`, `granted_at`, `revoked_at`, `client_ip`, `ua` | **合规留证**:人脸的单独同意必须独立成行 |
| `id_consent` | `consent_id`, `user_id`, `consent_type`, **`granted` bool**, `doc_version`, `granted_at`, `revoked_at`, `client_ip`, `ua` | **合规留证**:人脸的单独同意必须独立成行。枚举见下表 |
| `id_session` | `session_id`, `user_id`, `refresh_token_hash`, `expire_at`, `revoked` | 刷新令牌服务端可撤销 |
#### profile 域(画像与记忆)
@@ -277,11 +282,21 @@ kb ← agent / report(读取)
| 表 | 关键字段 | 说明 |
| --- | --- | --- |
| `pf_profile` | `profile_id`, `user_id`, `label`, `is_default` | **预留多档案**(帮家人诊断的场景),V1 只创建默认档案 |
| `pf_diagnosis` | `diagnosis_id`, `profile_id`, `source_report_id`, `dimensions` JSONB, `version`, `overall_confidence`, `created_at` | 34 个诊断维度的结构化结论;按版本追加,不覆盖 |
| `pf_memory` | `memory_id`, `user_id`, `layer`(L0~L5), `mem_key`, `mem_value` JSONB, `confidence` numeric, `status`('active'/'pending_confirm'/'archived'/'rejected'), `source`('explicit'/'implicit'/'report'/'behavior'), `evidence_ref`, `valid_from`, `last_reinforced_at`, `decay_after`, `created_at` | 记忆项主表;索引 (`user_id`,`layer`,`status`) |
| `pf_diagnosis` | `diagnosis_id`, `profile_id`, `source_report_id`, `dimensions` JSONB, `version`, `overall_confidence`, `created_at` | **32 个**诊断维度的结构化结论(字典见美学报告 §4,为唯一事实源;编号 33/34 已随 `TD-31` 移除且不再复用);按版本追加,不覆盖 |
| `pf_memory` | `memory_id`, `user_id`, `layer`(L0~L5), `mem_key`, `mem_value` JSONB, `confidence` numeric, `status`, `source`, `evidence_ref`, `valid_from`, `last_reinforced_at`, `decay_after`, `created_at` | 记忆项主表;索引 (`user_id`,`layer`,`status`)。**`status` 与 `source` 的枚举取值以《用户系统与记忆系统-需求文档》§3.3 为唯一事实源**,见下表 |
| `pf_memory_event` | `event_id`, `memory_id`, `action`('create'/'reinforce'/'decay'/'conflict'/'user_edit'/'delete'), `detail` JSONB, `created_at` | 记忆变更审计,支撑「我的记忆」页面的可解释性 |
| `pf_memory_snapshot` | `user_id`, `snapshot` JSONB, `token_count`, `content_hash`, `built_at`, `expire_at` | 注入 LLM 的画像摘要缓存,避免每轮重算 |
**枚举取值(本轮对齐,此前架构与用户系统文档给了两套不兼容的枚举)**
> ⚠️ 原架构表写 `status`=archived/rejected、`source`=explicit/implicit/report/behavior,与用户系统 §3.3 的枚举无法对应。**以用户系统文档为准**,因为它的取值承载了衰减与冲突消解的语义(`decayed`/`overridden` 必须可区分,否则 §3.6 的冲突消解无法实现)。
| 字段 | 取值 | 语义要点 |
| --- | --- | --- |
| `pf_memory.status` | `active` / `pending_confirm` / `decayed` / `overridden` / `deleted` | `decayed`(置信度衰减至 < 0.3,可恢复)与 `overridden`(被新证据覆盖)**必须区分**,前者可由用户恢复,后者不可 |
| `pf_memory.source` | `explicit_user_input` / `user_setting` / `report_vision` / `self_reported` / `behavior_inferred` / `llm_extracted` | 衰减规则只作用于 `behavior_inferred` 与 `llm_extracted`(用户系统 §3.5),因此**不能合并为笼统的 `implicit`** |
| `id_consent.consent_type` | `privacy`(隐私政策)/ `face`(**人脸,须独立成行**)/ `personalization`(个性化推荐)/ `push`(推送通知)/ `minor`(未成年人确认) | ⚠️ **`personalization` 不可省略**:用户系统 §3.10 要求记忆写入必须建立在该项授权之上,缺了它记忆系统就没有合规依据 |
#### report 域
| 表 | 关键字段 | 说明 |
@@ -321,7 +336,7 @@ kb ← agent / report(读取)
| 表 | 关键字段 | 说明 |
| --- | --- | --- |
| `tk_task` | `task_id`, `user_id`, `task_type`('report'/'tryon'/'hairstyle'/'matting'/'render'), `status`, `progress`, `stage_text`, `idempotency_key` UNIQUE, `payload` JSONB, `error_code`, `retry_count`, `points_reserved`, `submitted_at`, `started_at`, `finished_at`, `eta_seconds` | **全系统唯一的长耗时任务视图**,前端任务中心直接读它 |
| `tk_task` | `task_id`, `user_id`, `task_type`('report'/'tryon'/'hairstyle'/'matting'/'render'), `status`, `progress`, `stage_text`, `idempotency_key` UNIQUE, `payload` JSONB, `error_code`, `retry_count`, **`quota_reserved`**, `submitted_at`, `started_at`, `finished_at`, `eta_seconds` | **全系统唯一的长耗时任务视图**,前端任务中心直接读它。⚠️ 字段名由 `points_reserved` 改为 **`quota_reserved`** —— V1 是配额不是点数,用 points 命名会在实现期误导 |
#### billing 域(V1 为配额与计量,无支付)
@@ -329,10 +344,12 @@ kb ← agent / report(读取)
| 表 | 关键字段 | V1 | 说明 |
| --- | --- | :--: | --- |
| `bl_wallet` | `user_id` PK, `balance`, `version`(乐观锁) | ✅ | V1 存「可用额度」,V2 语义扩展为点数余额。**同一张表,不改结构** |
| `bl_ledger` | `tx_id`, `user_id`, `delta`, `tx_type`, `reason`, `ref_type`, `ref_id`, `balance_after`, `idempotency_key` UNIQUE, `created_at` | ✅ | **流水是真相**,余额是缓存。`tx_type` V1 只用 `system_grant`/`consume`/`refund`,V2 增加 `purchase`/`gift` —— 这是 V1→V2 免迁移的关键 |
| `bl_quota` | `user_id`, `quota_key`, `period_key`, `used`, `limit_value` | ✅ | 周期配额计量(日/月/终身),按 `period_key` 自然重置,无需定时任务清零 |
| `bl_quota_rule` | `quota_key`, `period_type`, `base_limit`, `task_bonus_max`, `hard_cap` | ✅ | 配额规则配置化(`FR-BILL-04`),改配置不发版 |
| `bl_quota` | `user_id`, `quota_key`, `period_key`, `used`, **`bonus_granted`**, `updated_at`;UNIQUE(`user_id`,`quota_key`,`period_key`) | ✅ | **V1 扣减判定的唯一依据**。周期配额计量(日/月/终身),按 `period_key` 自然重置,**不要写定时清零任务**。`bonus_granted` 物化本周期已获任务奖励,避免每次判定都聚合流水 |
| `bl_quota_rule` | `quota_key`, `period_type`, `base_limit`, `task_bonus_max`, `hard_cap` | ✅ | 配额规则配置化(`FR-BILL-04`),改配置不发版。数值来源:产品方案 §3.2 配额表(唯一事实源) |
| `bl_ledger` | `tx_id`, `user_id`, `delta`, `tx_type`, `status`, `reason`, `ref_type`, `ref_id`, `balance_after`, `idempotency_key` UNIQUE, `created_at` | ✅ | **流水是真相**,但**不参与 V1 的判定**(判定看 `bl_quota`)。`tx_type` V1 用 `system_grant`/`consume`/`refund` **三种**,V2 增加 `purchase`/`gift` —— 这是 V1→V2 免迁移的关键 |
| `bl_wallet` | `user_id` PK, `balance`, `version`(乐观锁) | ✅ | ⚠️ **V1 仅用于「跨周期不清零」的额度**(如首次终身报告);周期类额度一律走 `bl_quota`。V2 语义扩展为点数余额,**同一张表,不改结构** |
> 🔑 **四张表的职责边界见 §7.4 开头**。这是最容易实现成"两套并行扣减逻辑"的地方,务必先读那一段。
| `bl_membership` | `user_id`, `level`, `valid_from`, `valid_to`, `source_order_id`, `auto_renew` | ❌ V2 | 会员 |
| `bl_order` | `order_id`, `user_id`, `product_id`, `product_type`, `price_cents`, `platform`('huawei_iap'), `purchase_token` UNIQUE, `status`, `verified_at`, `raw_receipt` JSONB | ❌ V2 | `purchase_token` 唯一约束防重复发货(`TD-16`) |
@@ -351,12 +368,12 @@ kb ← agent / report(读取)
| 表 | 关键字段 | 说明 |
| --- | --- | --- |
| `ob_ai_call` | `call_id`, `user_id`, `scene`, `provider`, `model`, `tokens_in/out/cached`, `latency_ms`, `cost_cents`, `success`, `error_code`, `ref_type`, `ref_id`, `created_at` | **成本归因的唯一数据源**,按月分区 |
| `ob_ai_call` | `call_id`, `user_id`, `scene`, `provider`, `model`, `prompt_id`, `prompt_version`, `tokens_in/out/cached`, `tokens_reasoning`, `latency_ms`, `cost_cents`, `rate_tier`, `success`, `error_code`, `ref_type`, `ref_id`, `created_at` | **成本归因的唯一数据源**,按月分区。`prompt_id`/`prompt_version` 使每次调用可复现(§8.1 要求 Prompt 版本化,缺这两列就无法回答「上周的报告是哪版 Prompt 生成的」);`tokens_reasoning` 单独记思考模式 token(按输出价计费,混进 `tokens_out` 会掩盖开关思考模式的成本差异);`rate_tier` 记录该次调用适用的峰谷档位,否则事后无法复核 `cost_cents` |
| `ob_event` | `event_id`, `user_id`, `event_name`, `props` JSONB, `client_ts`, `server_ts` | 埋点事件,按月分区(`TD-23`) |
### 6.3 诊断维度的存储形态
34 个诊断维度用 `pf_diagnosis.dimensions` JSONB 存储,但**每个维度都是结构化三元组**,不是自由文本:
**32 个**诊断维度(字典见《美学报告-需求文档》§4,为唯一事实源)用 `pf_diagnosis.dimensions` JSONB 存储,但**每个维度都是结构化三元组**,不是自由文本:
```json
{
@@ -435,7 +452,7 @@ kb ← agent / report(读取)
| **分步可恢复** | 第 7~13 步的视觉分析结果落库为中间态,LLM 或渲染失败时**不需要重跑视觉分析**(也就不需要用户重新上传) |
| **生成后校验不可跳过** | 校验失败最多重试 2 次(提高约束强度),仍失败则降级为「规则直出模板文案」,绝不放行违规内容 |
| **等待体验** | 阶段文案随进度更新;预估时间基于历史 p50;超过阈值转为「后台生成 + 完成推送」,允许用户离开页面 |
| **幂等** | 相同 `idempotency_key` 重复提交返回同一 `task_id`,不重复扣点 |
| **幂等** | 相同 `idempotency_key` 重复提交返回同一 `task_id`,**不重复扣减配额** |
### 7.2 Agent 对话(含检索与画像注入)
@@ -475,17 +492,28 @@ kb ← agent / report(读取)
⑨ 异步:偏好抽取 → 写入 L2 记忆(显式)/ 生成待确认候选(隐式)
```
**Token 预算分配(必须硬性限制,否则成本会失控)**:
**Token 预算分配(必须硬性限制,否则成本会失控)**
| 部分 | 预算 | 说明 |
| --- | --- | --- |
| 系统提示词 | ≤ 800 | 固定不变,放在最前以最大化缓存命中 |
| 画像摘要 | ≤ 500 | 从记忆快照裁剪,按置信度与相关性排序 |
| 会话摘要 | ≤ 400 | 滚动压缩 |
| 检索知识 | ≤ 1500 | 重排后取前 N 条,超出则截断而非全塞 |
| 用户当前输入 | ≤ 500 | 超长提示精简 |
| **输入合计** | **≤ 3700** | |
| 输出 | ≤ 800 | 超长回答不利于阅读,也贵 |
> 🔒 **本表是 token 预算的唯一定义位置**。`FR-MEM-04`、`FR-AGT-03`、产品方案 §2.4.2、用户系统 §3.8、穿搭 Agent §3.5.2/§10.2、`T-P3-17` **一律引用本表,不得复述数值**。
>
> ⚠️ **此前存在两套互相矛盾的口径,本轮已查明原因并修正**。原架构表写「会话摘要 ≤ 400、总输入 ≤ 3700」,用户系统 §3.8 写「会话上下文 ≤ 800、记忆注入合计 ≤ 1400」。**错在架构表**:用户系统的 800 是「**最近 6 轮原文 + 更早轮次的滚动摘要**」,而架构表只算了滚动摘要、漏掉了近轮原文。因此**以用户系统 §3.8 的分项为准,并修正总输入合计**。
| 层级 | 部分 | 预算 | 说明 |
| --- | --- | --- | --- |
| 总输入 | 系统提示词 | ≤ 800 | 固定不变,放最前以最大化缓存命中 |
| 总输入 | **记忆注入(小计)** | **≤ 1400** | 展开见下四行;这是 `FR-MEM-04` 的验收口径 |
| └ 记忆注入 | 画像摘要 L0/L1 | ≤ 250 | 模板化压缩,非 LLM 生成 |
| └ 记忆注入 | 偏好记忆 L2 | ≤ 150 | `confidence ≥ 0.5` 按置信度取 Top 15,**硬禁忌优先保留** |
| └ 记忆注入 | 事件记忆 L3(向量召回) | ≤ 200 | **V2 能力,V1 预算预留但不使用** |
| └ 记忆注入 | 会话上下文 L4 | ≤ 800 | = 最近 6 轮原文 ≤ 400 + 更早轮次滚动摘要 ≤ 400 |
| 总输入 | 检索知识 | ≤ 1500 | 重排后取前 N 条,超出则截断而非全塞 |
| 总输入 | 用户当前输入 | ≤ 500 | 超长提示精简 |
| **总输入合计** | | **≤ 4200** | `FR-AGT-03` 的验收口径;原 3700 因漏算近轮原文而偏低 |
| 输出 | | ≤ 800 | 超长回答不利于阅读,也贵 |
**V1 实际用量**:L3 不启用,故记忆注入 ≤ 1200、总输入 ≤ 4000,留有余量。
**裁剪优先级(预算超限时按此顺序丢弃,不得报错)**:事件记忆 L3 → 滚动摘要尾部 → 检索知识低分项 → 偏好记忆低置信项 → 近轮原文最早的几轮。**硬禁忌与安全约束永不裁剪** —— 裁掉它们会导致推荐用户明确拒绝的品类,属 P0 缺陷(产品方案 §8.3.3 要求禁忌违反率为 0)。
**缓存前缀纪律**:系统提示词与稳定知识片段必须放在 prompt 头部且**不含时间戳、随机 ID、用户昵称等变量**,否则缓存永远不命中,输入成本翻数倍(`TD-04`)。
@@ -499,29 +527,63 @@ kb ← agent / report(读取)
⑤ 结果图审核(IMS)
⑥ 合规出口:水印「AI 生成」+ 写入元数据与内容编号 → 存 COS 用户内容桶
⑦ commit 配额、记录成本、Push 通知
⑧ 失败:区分可重试(网络/排队超时)与不可重试(输入非法/审核拒绝),后者立即退点
⑧ 失败:区分可重试(网络/排队超时)与不可重试(输入非法/审核拒绝),后者立即退还配额
```
**队列即容量**:`gpu` 队列的并发上限直接由 GPU 容量决定,并把「当前排队位次 + 预估等待」返回给用户。**不要让请求无限积压**——超过阈值直接拒绝新任务并提示稍后再试,这比让用户等 10 分钟体验更好。
### 7.4 配额消耗(V1)与 IAP 支付(V2)
#### V1:配额三段式
#### V1:四张表的职责边界(**必须先看这段,否则会实现成两套并行的扣减逻辑**)
`billing` 域有四张表,此前文档没写清它们的关系,导致 `bl_quota.used` 与 `bl_wallet.balance` 看起来像两套重复机制。实际分工是:
| 表 | 在 V1 的角色 | 是否参与 V1 的扣减判定 |
| --- | --- | --- |
| `bl_quota_rule` | **规则**:每个 `quota_key` 的周期类型、基础额度、任务奖励上限、防刷硬上限 | 提供判定用的阈值,配置化,改配置不发版 |
| `bl_quota` | **周期计数器**:`(user_id, quota_key, period_key)` 上的 `used` 与本周期已获得的 `bonus_granted` | ✅ **V1 的判定就在这张表上** |
| `bl_ledger` | **流水(真相)**:每一次发放、消耗、退还都留痕,供对账与 V2 定价分析 | ❌ 不参与判定,但**每次判定通过后必须写** |
| `bl_wallet` | **余额缓存**:V1 语义为「跨周期不清零的可累积额度」 | ⚠️ **V1 仅"首次终身"类额度用它**(如首次报告),周期类额度不用 |
**一句话概括**:**V1 的判定看 `bl_quota`(周期计数器),V2 的判定才看 `bl_wallet`(点数余额)。`bl_ledger` 在两个版本里都只是流水。** 之所以 V1 就把 `bl_wallet`/`bl_ledger` 建起来,是为了 V2 上支付时不迁移数据(§6.2),不是因为 V1 需要它们做判定。
**判定公式中 `task_bonus` 的来源已明确**:不是从 `bl_ledger` 实时聚合(那样每次判定都要扫流水,且并发下不可靠),而是**在 `bl_quota` 上物化为 `bonus_granted` 字段**,发放任务奖励时同时递增它。判定公式因此是纯单行读:
```
校验:GET 配额 → 判定 bl_quota.used < limit_value + task_bonus
↓ (服务端硬校验,客户端提示仅为体验,FR-BILL-06)
预扣:reserve → bl_ledger 写 delta=-N, tx_type=consume, status=reserved
↓
任务执行(Celery,长耗时)
↓
成功:commit → 确认扣除,status=committed
失败:refund → 写反向流水 delta=+N, tx_type=refund(带 idempotency_key)
有效上限 = MIN(
bl_quota_rule.base_limit + bl_quota.bonus_granted, -- 基础额度 + 本周期已获奖励
bl_quota_rule.hard_cap -- 防刷硬上限,任何情况不可超越
)
放行条件 = bl_quota.used < 有效上限
```
发放:任务奖励 → bl_ledger 写 delta=+N, tx_type=system_grant
#### V1:配额消耗三段式
```
① 熔断检查:全局日成本水位是否已达 100%?(NFR-COST-02)
↓ 已熔断 → 直接拒绝,且【不得预扣】,返回"今日服务繁忙"
② 校验:SELECT ... FOR UPDATE 读 bl_quota 单行 → 按上方公式判定
↓ (服务端硬校验,客户端提示仅为体验,FR-BILL-06)
③ 预扣 reserve → bl_quota.used += N(同一事务内)
bl_ledger 写 delta=-N, tx_type=consume, status=reserved
↓
④ 任务执行(Celery,长耗时)
↓
成功:commit → 流水 status=committed(bl_quota.used 不变,已在③计入)
失败:refund → bl_quota.used -= N
bl_ledger 写反向流水 delta=+N, tx_type=refund(带 idempotency_key)
发放:任务奖励 → bl_quota.bonus_granted += N(受 task_bonus_max 约束)
bl_ledger 写 delta=+N, tx_type=system_grant
↓ (同一任务同一周期幂等,FR-BILL-03)
```
**三处最容易写错的地方**:
1. **熔断必须在预扣之前**(步骤①在③之前)。顺序颠倒会导致平台自身限流却扣掉用户额度 —— 这是 `NFR-COST-02` 里「配额净扣减 = 0」那条验收标准要防的情况。
2. **`bl_quota.used` 的增减必须与 `bl_ledger` 写入在同一个数据库事务里**,否则对账永远不平。
3. **周期重置靠 `period_key` 自然切换,不要写定时清零任务**。`period_key` 形如 `2026-08-13`(日)/ `2026-08`(月)/ `lifetime`(终身),新周期首次访问时插入新行即可。定时清零在跨时区、补偿执行、任务漏跑时都会出错。
**为什么预扣而不是直接扣**:长耗时任务可能失败,直接扣会产生「扣了额度没拿到结果」的客诉。先冻结再确认,失败自动释放。V1 虽然不涉及金钱,这个问题依然存在 —— **用户对"次数被白扣"的敏感度不比扣钱低**,而且 V1 没有客服退款兜底手段,只能靠机制保证。
**对账**:所有变更都写流水,余额可由流水重算校验(每日定时,不一致则告警)。V1 就要做,否则 V2 上支付时对账逻辑得从零开发且无历史数据验证。
@@ -646,12 +708,14 @@ get_memory_snapshot(user_id, budget_tokens)
→ 裁剪到预算内 → 缓存(hash 变更才重建)
```
**写**:
**写**(置信度初值的唯一事实源是《用户系统与记忆系统-需求文档》§3.5,本处原写 0.95 与之冲突,已更正为 0.90):
```
显式(用户明确说的) → confidence=0.95,立即生效,回执告知用户「已记住」
报告产出(机器诊断) → confidence=诊断置信度,写 L1
行为推断(隐式) → 累积证据,达阈值生成 status='pending_confirm'
→ 在合适时机征询确认,确认后才转 active
用户确认 / 设置页配置 → confidence=1.00
对话中明确陈述 → confidence=0.90,立即生效,回执告知用户「已记住」
报告产出(机器诊断) → confidence=诊断置信度(高置信维度 0.85 / 争议维度 0.65),写 L1
自评问卷 → confidence=0.60
行为推断(隐式) → confidence=0.50,累积证据达阈值生成 status='pending_confirm'
→ 在合适时机征询确认,确认后才转 active(且 confidence 置 1.0)
```
**衰减与冲突**:定时任务按 `decay_after` 降低未被强化的记忆置信度;同 key 冲突时**显式 > 报告 > 隐式**,时间新 > 时间旧,并写 `pf_memory_event` 留痕。用户在「我的记忆」页面的编辑拥有最高优先级且不可被自动覆盖。
@@ -664,7 +728,7 @@ get_memory_snapshot(user_id, budget_tokens)
| 项 | 约定 |
| --- | --- |
| 基础路径 | `/api/v1`,大版本进路径 |
| 基础路径 | **规范写法为 `/api/v1`**,大版本进路径。`/v1/*` 作为等价别名由中间件重写到 `/api/v1/*`,两种写法返回完全一致的响应;文档、客户端与新代码一律写 `/api/v1`(别名只为兼容早期任务描述中的 `/v1`,不要在新代码里使用) |
| 风格 | 面向资源的 REST;流式对话用 SSE(`text/event-stream`) |
| 认证 | `Authorization: Bearer <access_token>`(JWT,15 分钟)+ 刷新令牌(服务端可撤销) |
| 幂等 | 写操作支持 `Idempotency-Key` 头,服务端 24 小时内保证同键同结果 |
@@ -776,7 +840,11 @@ get_memory_snapshot(user_id, budget_tokens)
### 12.2 容量假设与压力点
以 1 万 DAU 为规划基线(V1 实际远低于此,用于确认架构不会在早期就撞墙):
**基线取 1 万 DAU**。与产品方案 §8.1.1 的口径关系需说明清楚,否则会被误读为架构容量低于业务目标:
- 产品方案的 V1 业务目标是 **MAU 15 万**,且**明确不以 DAU 作为考核指标**(形象诊断是低频决策型工具,改用季度回访率)。
- 低频工具的 DAU/MAU 通常在 5~10%,因此 MAU 15 万对应 DAU 约 **0.75~1.5 万** —— **1 万 DAU 基线与业务目标是吻合的**,不是保守估计。
- 本基线的作用是确认架构不会在早期撞墙;实际压测值由 `T-P5-06` 确定,Agent 并发上限亦由此推导(穿搭 Agent §7.1)。
| 资源 | 估算 | 压力点 |
| --- | --- | --- |
@@ -794,13 +862,32 @@ get_memory_snapshot(user_id, budget_tokens)
> ⚠️ **V1 不收费让这张表的地位更高,不是更低**(`TD-28`)。V1 没有收入,因此成本是唯一的财务变量;同时 `ob_ai_call` 是 **V2 定价的唯一数据来源** —— 单次真实成本与人均使用频次都从这里出。埋点缺失的功能,在 V2 定价时就是一个必须靠猜的空洞。**这一项不允许"先上线后补埋点"。**
**V1 的成本上限是可以事前算准的确定值**(这是不收费换来的好处):
### 13.1 三个必须分开的成本口径
```
月度 AI 成本上限 = MAU × Σ(各功能日配额上限 × 单次成本 × 30)
```
> ⚠️ **本节原有一处错误论证,已更正**。原文称「月度成本上限 = MAU × 单用户配额上限 × 单次成本,是可事前算准的确定值」。这不成立:**按人头发配额约束的是「单用户能用多少」,而总成本 = MAU × 人均,MAU 本身没有上限。** 配额防的是单用户刷爆,防不了总量涨爆。
因为配额是服务端硬限制(`FR-BILL-06`),不存在超额路径,所以这是上限而非估计值。按 MAU 15 万、免费用户月成本上限 ¥8 计算,月度上限约 ¥12 万(产品方案 §5.3.1)。
| 口径 | 公式 | MAU 15 万时 | 用途 |
| --- | --- | --- | --- |
| **理论最坏** | `MAU × 防刷硬上限成本(¥236/月)` | ≈ ¥3,540 万/月 | 只说明"配额不是预算工具",无实际预算意义 |
| **预期成本** | `Σ(各功能月用量推算 × 单次成本)` | **¥7~12 万/月** | **唯一的预算口径**,含使用率假设 |
| **真实封顶** | 全局日成本熔断阈值 × 30 | **¥15 万/月** | **唯一与用户规模无关的硬闸门** |
预期成本的完整算式与其隐含的使用率假设见产品方案 §5.3.1;单用户三档成本见产品方案 §5.2.1。**单一事实源在产品方案,本节不重复数值。**
### 13.2 单位成本建模
> ⚠️ **LLM 单价自 2026-08-17 起分峰谷两档,本节的所有 `C_llm` 必须按调用时刻取价。**
> DeepSeek 高峰时段(北京时间 09:00-12:00、14:00-18:00)单价为空闲时段的 **2 倍**,
> 且新价目相对旧价目**输出侧涨约 2.25~4.5 倍**。两个直接后果:
>
> 1. **`ob_ai_call.cost_cents` 必须在调用当时算好落库**,不能事后按「当前单价」回算 ——
> 否则同一条记录在不同时间重算会得到不同结果,V2 定价的依据就不可复现。
> 实现上由两套价目表 + 时段判定完成(`T-P1-25c`),并用 `rate_tier` 记录当次档位。
> 2. **§13.1 的预期成本与熔断阈值需按新单价重算一次**。原数值基于旧单价推得,
> 而报告生成集中在白天高峰 —— 不重算会让熔断阈值落在错误的位置。
>
> **可控杠杆有两个**:一是缓存前缀纪律(固定系统前缀命中缓存后输入侧大幅降价,已实测
> 单次命中 128/240 输入 token);二是把非实时的文案生成挪到空闲时段,直接省一半。
单位成本核算公式(数值需在 Phase 0 验证任务中实测填入,此处为建模框架):
@@ -808,15 +895,37 @@ get_memory_snapshot(user_id, budget_tokens)
| --- | --- | --- |
| 初级报告 | 人脸 API + 分割 API + 自建推理 + LLM + 审核 + 渲染 | `≈ C_face + C_seg + C_llm(in≈8k, out≈3k) + C_mod×n + C_render` |
| 全面报告 | 初级 + 稠密关键点 + 更长文案 | `≈ 初级 × 3~4` |
| Agent 单轮 | LLM(缓存命中后输入成本大幅下降)+ 检索 + 双向审核 | `≈ C_llm(in≈3.7k 多数缓存, out≈0.8k) + C_mod×2` |
| Agent 单轮 | LLM(缓存命中后输入成本大幅下降)+ 检索 + 双向审核 | `≈ C_llm(in≈4.2k 多数缓存, out≈0.8k) + C_mod×2`。**思考模式须显式关闭**(`T-P1-24a`):开启后思考 token 按输出价计费,实测同一问答耗时从 1.8s 涨到 13.5s |
| 换装单次 | GPU 秒数 + 审核 + 存储 + CDN | `≈ gpu_seconds × 单价 + C_mod + C_storage` |
| 抠图单件 | GPU 秒数(离线批量摊薄) | 批量跑,按批次成本 / 件数 |
| **短信验证码** | 单条约 ¥0.03~0.045(`TD-32`) | `发送条数 × 单价`。**量级上可忽略,但必须计量并告警** —— 被刷时它是直接的现金损失,且不像 GPU 成本那样有配额兜着 |
**必须设置的三道成本闸门**:
### 13.3 三道成本闸门(按拦截范围从大到小)
1. **场景级日预算**:某功能超预算自动降级(切轻量模型/暂停非核心功能),并告警。
2. **用户级频控**:配额硬上限(服务端校验,`FR-BILL-06`),防单用户异常消耗。
3. **日成本预警**:日成本环比突增超过阈值立即告警——通常意味着有 bug(重试风暴、缓存失效)或被刷。
| # | 闸门 | 拦截范围 | 触发后行为 | 需求编号 |
| --- | --- | --- | --- | --- |
| 1 | **全局日成本熔断** | 平台总量 | **自动降级,不是仅告警** | `NFR-COST-02` |
| 2 | 场景级日预算 | 单个功能 | 该功能切轻量模型或暂停 | `NFR-COST-02` |
| 3 | 用户级配额 | 单个用户 | 服务端拒绝 | `FR-BILL-06` |
**第 1 道是新增的,也是唯一真正封顶的一道**(原设计只有"预警",见 §13.1 的更正)。实现要求:
- **计数**:Redis 日累计成本键(`cost:daily:{yyyymmdd}`),由 LLM 网关与 GPU 任务在**成本产生后立即累加**,日切自动过期。落库对账以 `ob_ai_call` 为准,Redis 只做实时判定。
- **分级降级顺序**(按"用户感知损失最小"排序,阈值百分比可配置):
| 日成本水位 | 动作 | 用户可感知的变化 |
| --- | --- | --- |
| 60% | 告警,不影响用户 | 无 |
| 80% | 停高清导出、停报告重新生成 | 两个低频次要功能不可用 |
| 90% | 停发型试戴;换装强制降画质 + 排队 | 次要生成能力不可用 |
| 100% | 仅保留**在途任务**与纯文本对话;新的生成类请求全部拒绝 | 「今日服务繁忙,请明天再试」 |
- **在途任务必须放行**:成本已经付出,中断只会浪费掉且让用户白等。熔断只拦**新请求**。
- **关键场景需显式豁免**:少数调用一旦失败会让整个链路无法继续(例如意图识别 —— 拿不到意图就无法路由,
用户看到的是功能完全坏掉而不是降级)。这类场景在场景配置上标记为 `critical`,**不受熔断拒绝**,
但仍照常计入日成本。豁免清单必须极短且逐个论证,否则熔断会被绕空。
- **必须与配额解耦**:熔断拒绝的请求**不得扣减用户配额**(走 `refund` 流水,`FR-BILL-05`),否则用户会因平台原因损失额度。这是最容易写错的一处。
- **必须可人工干预**:运营后台需有熔断状态查看与手动解除开关(`FR-ADM-*`),不能只能等日切。
---
@@ -876,7 +985,7 @@ get_memory_snapshot(user_id, budget_tokens)
**必须能一键回答的三个问题**(这是可观测性的验收标准):
1. 「某用户说他的报告生成失败了」→ 用 `user_id` 找到 task,看到失败在哪一步、错误码、是否已退点。
1. 「某用户说他的报告生成失败了」→ 用 `user_id` 找到 task,看到失败在哪一步、错误码、**配额是否已退还**。
2. 「昨天成本涨了 3 倍」→ 按功能/模型分组的成本对比,定位到具体场景。
3. 「换装最近变慢了」→ 区分是排队变长(扩容)还是推理变慢(模型/资源)。
@@ -897,13 +1006,24 @@ meida/
│ ├─ matting/ # 抠图批处理
│ └─ tryon/ # 换装服务(改造纳管)
├─ packages/
│ ├─ domain/ # 领域模型与业务规则(纯逻辑,无 IO,可纯单测)
│ ├─ domain/ # 领域模型与业务规则(纯逻辑,无 IO、无框架,可纯单测)
│ ├─ shared/ # 配置、日志、错误、DB、COS、可观测
│ ├─ llm/ # LLM 网关
│ │ # ── 以下为 §4.1 的 11 个业务域,每域一个包 ──
│ ├─ identity/ # 账号与会话
│ ├─ billing/ # 配额与计量
│ ├─ task/ # 任务中心
│ ├─ profile/ # 用户画像
│ ├─ memory/ # 记忆系统
│ ├─ kb/ # 知识库
│ ├─ retrieval/ # 混合检索
│ ├─ rules/ # 规则引擎
│ ├─ memory/ # 记忆系统
│ ├─ compliance/ # 审核 / 水印 / 元数据标识
│ └─ shared/ # 配置、日志、错误、DB、COS、可观测
│ ├─ report/ # 美学报告
│ ├─ agent/ # 穿搭 Agent
│ ├─ tryon/ # 换装与发型编排
│ ├─ wardrobe/ # 衣橱
│ ├─ notify/ # 通知推送
│ └─ compliance/ # 审核 / 水印 / 元数据标识
├─ prompts/ # Prompt 模板(版本化,随代码走)
├─ migrations/ # Alembic
├─ tests/
@@ -915,6 +1035,20 @@ meida/
└─ client-harmony/ # 鸿蒙 ArkTS 工程
```
**包内约定**:每个业务域包统一为 `service.py`(用例编排,对外唯一入口)+ `repo.py`(该域自己的表访问)
+ 可选 `schemas.py`。§4.2 的「禁止直接查对方的表」因此落为一条可机检规则:
**跨域只允许 import 对方的 `service`,不允许 import 对方的 `repo`。**
**三层的判别标准**(决定新代码该放哪一层):
| 层 | 判别标准 | 反例 |
| --- | --- | --- |
| `packages/domain` | 纯函数与不变量,**不 import 任何框架、不做 IO** | 把 SQLAlchemy 模型放进来 |
| `packages/<域>` | 该域的用例编排与自己的表访问 | 在 `agent/` 里直接读 `bl_quota` 表 |
| `apps/` | HTTP / Celery 的适配,**不含业务判断** | 在路由函数里写配额判定公式 |
> 这三条边界由 import-linter 契约强制,不靠 code review。分层规则一旦只靠人守,第一次赶工就会破。
### 16.2 AI 辅助开发下的质量防线
既然主要由 AI 编码,人工 review 的带宽会成为瓶颈,因此**必须把规范变成机器可执行的检查**:
@@ -1015,5 +1149,7 @@ meida/
| 日期 | 版本 | 变更 |
| --- | --- | --- |
| 2026-08-13 | v1.3 | **脚手架落地后按实现回写,五处修正**。① **§16.1 仓库结构补全 11 个业务域包** —— 原树只列了 7 个 `packages/`,§4.1 定义的 11 个业务域在树里无处安放,实现时只能猜;现补全并加「包内约定」(每域 `service.py` + `repo.py`,跨域只许 import 对方 `service`)与三层判别标准表。② **§6.2 `ob_ai_call` 补 `prompt_id`/`prompt_version`/`tokens_reasoning`/`rate_tier` 四列** —— §8.1 要求 Prompt 版本化可复现,但表里没有版本列,等于要求做不到的事;思考 token 混进 `tokens_out` 会掩盖开关思考模式的成本差异。③ **§9.1 澄清基础路径** —— 正文写 `/api/v1`、任务描述写 `/v1`,实现两者都要能用;现明确 `/api/v1` 为规范写法、`/v1/*` 仅作兼容别名。④ **§13.2 新增峰谷计价约束** —— DeepSeek 自 8-17 起高峰单价翻倍且新价目输出侧涨 2.25~4.5 倍,成本须在调用当时算好落库(不可事后回算),§13.1 的预算与熔断阈值需按新价重算;Agent 单轮成本注明思考模式须显式关闭。⑤ **§13.3 补「关键场景显式豁免」** —— 意图识别这类调用失败会让链路整体不可用,须标记 `critical` 不受熔断拒绝,但仍计入成本;豁免清单必须极短,否则熔断被绕空 |
| 2026-08-13 | v1.2 | **文档一致性评审修订,其中三处是会直接卡住开发的实质缺陷**。① **§7.4 补齐 `billing` 域四张表的职责边界** —— `bl_quota` 与 `bl_wallet` 此前看起来像两套重复机制,`task_bonus` 的来源也没定义;现明确「V1 判定看 `bl_quota`、V2 判定看 `bl_wallet`、`bl_ledger` 两版都只是流水」,并把 `task_bonus` 物化为 `bl_quota.bonus_granted` 字段(避免每次判定聚合流水),补充三处最易写错的地方(熔断须在预扣之前、`used` 增减须与流水同事务、周期重置靠 `period_key` 而非定时清零)。② **§13 成本模型重写** —— 推翻「月度成本上限 = MAU × 单用户配额上限」这个错误论证(配额约束单用户,总成本 = MAU × 人均,MAU 无上限),改为「理论最坏 / 预期成本 / 真实封顶」三口径,并新增 **§13.3 四档熔断降级**(含在途任务放行、被熔断请求不得扣配额、可人工解除)。③ **§7.2 token 预算改为嵌套表**,合计 3700→**4200**(原表漏算会话近轮原文 400),并明确硬禁忌永不裁剪。另:`pf_memory.status`/`source`、`id_consent.consent_type` 枚举与用户系统对齐(`personalization` 不可省略,否则记忆写入无合规依据);手机号存储位置明确为 `id_user_identity`;诊断维度 34→**32**;`points_reserved`→**`quota_reserved`**;外部依赖表补入腾讯云短信并说明登录双通道不得互相依赖;§12.2 说明 1 万 DAU 基线与 MAU 15 万目标的口径关系 |
| 2026-08-13 | v1.1 | 按业务决策收口调整。**billing 域降级为「配额与计量」**:V1 移除支付、订单、会员、IAP;`bl_point_tx` 改名 `bl_ledger` 并新增 `tx_type` 字段,使 V1 配额账本与 V2 点数账本共用同一张表(V2 免数据迁移);新增 `bl_quota_rule` 配额规则表。§7.4 拆为「V1 配额三段式」与「V2 IAP 支付」。§13 成本模型补充「V1 成本上限可事前算准」的公式,并强调 `ob_ai_call` 是 V2 定价的唯一数据来源、不允许后补。§19 风险登记:备案风险关闭,新增备案范围一致性、素材自采产能、顾问到岗三项风险 |
| 2026-08-13 | v1.0 | 首次输出。确立「模块化单体 + 独立 AI 服务」架构、十个业务域数据模型、五条关键链路、统一合规出口、V1 单机 Docker Compose 部署与成本模型 |