Files
cmhub/docs/04-architecture.md
T

572 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构设计
> 本文讲“怎么把技术栈搭起来”:系统结构、职责划分、数据模型、计费时序、技术难点、开发顺序。
> 具体用了哪些框架 / 库 / 数据库 / 部署方式,见 [技术栈](03-tech-stack.md)。
## 一、系统结构
```text
终端用户 ──浏览器/session──> [用户端 Web · Django模板SSR] 注册/登录/扫码充值/管API Key/看记录
用户的程序 ──API Key──────> [对外 API · DRF] 鉴权 → 计费 → 扣点 → 调上游 → 记录
│
[cmhub · Django 单体] ──下单/回调(验签)──> 外部支付系统 <──扫码付款── 终端用户
├──> 上游 AI(vectorengine.ai:标题/图片)
└──> MySQL(用户/钱包/APIKey/规则/订单/流水/调用记录)
│
[django-admin] <──── 运营人员(管理用户、点数、规则、记录)
```
组件落位:
- **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/点数记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501/T-608 已落地 `/signup`、`/login`、`/logout` 与 `/dashboard`;注册成功后经计费层一次性发放 100 点试用点数,并写 `signup_bonus` 点数流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503/T-608 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单、注册赠点与消费/退款流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/点数记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明;T-607/T-609/T-617 已新增公开 JSON 版本检查接口给桌面端自动更新使用,并返回强制更新标记和安装包文件大小字节数。T-610 已在公开首页下载区增加导入模板下载入口,由后台 `ImportTemplate` 配置当前模板。
- **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。
- **API 层(DRF)**:对外生成接口、异步图片任务接口、余额查询、支付回调接收、扫码下单、公开版本检查接口。入口 `apps/api/`。生成/余额/模型目录这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签;T-607/T-609/T-617 的客户端下载版本检查接口为公开只读例外,不需要 API Key,不读取用户、不扣点,`release.force_update` 仅表示当前发布版本是否强制升级,`release.size_bytes` 仅表示下载文件大小字节数。
- **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。
- **AI 调用层(Provider Adapter 架构)**:对外只暴露稳定能力,内部用「能力别名 → 具体供应商适配器」解耦。入口 `apps/ai/`,适配器在 `apps/ai/providers/`。
- **内容安全层(本地敏感词 / 后续云审核)**:入口 `apps/moderation/`。T-604 只做 prompt 文本本地敏感词快筛,命中在扣点和调上游前返回 `content_blocked`;云内容安全、图片审核和输出审核保留扩展点,不在 T-604 范围。
- **运营后台**:django-admin,注册各模型的 Admin。入口各 app 的 `admin.py`。T-401 已补齐用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录的管理/检索;点数手工调整只能从钱包专用入口提交原因和非 0 变动,经计费层写 `adjust` 流水。
- **外部服务**:上游 AI(vectorengine)、外部支付系统(跳转 + 回调)。
- **数据库**:MySQL 8.4 LTS(cmhub **专用独立实例**,不复用 VPS 已有的 MySQL 5.7);引擎必须 InnoDB、字符集 utf8mb4。开发与生产同用 MySQL,勿用 SQLite(SQLite 会静默忽略 `select_for_update`,测不出并发扣点)。
## 二、职责划分
**API 层(`apps/api`)**
- API Key 鉴权(自定义 DRF Authentication,哈希比对),把请求绑定到 Key 所属的注册用户。
- 参数校验、请求/响应序列化、错误码统一。
- 编排单次调用:通过生成核心 service 组织「审核 → 图片输入处理 → 别名 / 计费 → 预扣 → 调 AI 层 → 成功确认 / 失败退点 → 写调用记录」。
- 不直接写点数余额字段,必须走计费层提供的方法。
T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 `Authorization: Bearer <API_KEY>`,通过 SHA-256 hash 定位 `ApiKey -> User`,成功后 `request.user` 为所属用户、`request.auth` 为本次 API Key;缺失/无效 Key 返回 `401 unauthorized`,用户或 Key 禁用返回 `403 account_disabled`。生成/余额等外部 API 应继承 `ExternalApiView`,不要挂 `SessionAuthentication`。
T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层只做鉴权、参数校验和编排;别名解析、Provider 选择、计费计算、预扣、成功确认、失败退点分别调用 `apps.ai` / `apps.billing` 既有模块。T-613 已把生成链路抽为 `apps.api.generation` 的核心阶段:`prepare_generation()` 负责审核、图片输入、别名、Provider 与计费准备;`precharge_generation()` 只调用 billing 预扣;`execute_precharged_generation()` 复用已预扣 `CallRecord` 调上游并成功确认或失败退点,供旧同步接口和后续异步 worker 共用。T-620 将图生图输入扩展为有序集合:兼容旧单图字段,新 `images[0]` 为主商品图、`images[1:]` 为参考图;服务端在审核用户 prompt 后固定追加角色规则,再将完整顺序交给 Provider。图片结果 MVP 先用本地 `default_storage` 保存到 `MEDIA_ROOT/generated/images/...` 并返回 `image_url`;核心阶段通过 URL 构建器生成外部 URL,不依赖 DRF `Request`;`CallRecord` 只写 URL / 摘要,不保存 provider `raw` 或 base64。
T-619 已在同一生成核心增加 `vision` 分支和 `/api/v1/analyze/images`:API 接收有序的 `images[]`,每项二选一提供 `image_url` / `image_base64`;prompt 审核通过后才下载或解码图片,随后校验同时具备 `text + vision` 的模型与 Provider、按 `vision + alias` 默认价格预扣一次、同步调用 Chat Completions / Gemini 多模态接口并返回完整文字。输入图片只在请求内存中使用,不落库;`CallRecord` 只保存最多 500 字结果摘要。
T-614 已实现 `/api/v1/generate/image/tasks` 与 `/api/v1/generate/image/tasks/{task_id}`:提交接口同步审核 prompt、解析图片输入和预扣点,创建 `ImageGenerationTask(status=queued)` 后立即返回公开 UUID `task_id`;后台 worker 通过 `select_for_update(skip_locked)` 抢任务,复用 T-613 `execute_precharged_generation()` 对已预扣 `CallRecord` 调上游、保存结果、成功确认或失败退点。T-620 新增 `ImageGenerationTaskInput` 子表,按序号保存多张任务输入文件和 MIME / 文件名;旧 `input_image` 仍只为历史单图任务读取兼容。任务表记录 worker 租约、心跳和尝试次数;reaper 识别僵尸 `running` 任务后默认判失败并幂等退点,不默认重排队。异步 worker 没有 DRF request,结果 URL 由 `MEDIA_PUBLIC_BASE_URL` / `PUBLIC_BASE_URL` 生成。当前实现中 worker 会基于任务快照再次运行 `prepare_generation()`,因此会复审 prompt、重解析别名 / Provider / 定价;账务仍使用已预扣 `CallRecord.points_cost`,不会重复扣点。这个取舍偏安全(排队期间敏感词库更新后仍能拦截并退款),但如果未来队列积压明显,应单独实现“提交时模型配置快照”,避免执行时别名映射变化导致按旧价预扣、按新模型执行。
T-616 起异步生图 worker 对临时性上游失败增加自动重试:`upstream_timeout` / `upstream_error` 在未达到最大次数前回到 `queued` 并设置 `next_attempt_at`,`CallRecord` 保持 `pending` 且不退点;不可重试错误或最后一次失败才置 `failed` 并幂等退款。默认 `IMAGE_TASK_MAX_RETRIES=2`,因此 `attempt_count` 最多为 3。
T-615 已给旧同步生图接口和新异步提交接口接入 `cmhub.api.generation_usage` 结构化日志,事件名为 `generation_route_usage`。日志字段只包含 `route_type(sync/async)`、`api_key_id`、`api_key_prefix`、`user_id`、`client_version`、`alias`、`status`、`latency_ms`、`error_code`、`http_status` 等白名单信息;不得记录 API Key 明文、prompt 全文、`image_base64`、provider raw 或上游密钥。第一版用日志查询完成用量观察,不新增报表表结构;如后续要在 admin 做统计报表,需单独评估数据量、索引和保留周期。
T-303 已实现 `/api/v1/balance`:外部 API 继续只认 API Key,API 层调用 `apps.billing.services.get_balance_snapshot()` 读取当前钱包余额;测试覆盖余额响应与流水累加一致的场景。
T-304 已实现 `/api/v1/recharge/callback/wechat` 与 `/api/v1/recharge/callback/alipay`:两个回调端点均 `@csrf_exempt` 且不挂登录态;回调验签后调用 `apps.billing.services.apply_recharge_payment()`,按 `order_no` 锁定 `RechargeOrder` 幂等入账,金额或通道不一致不加点。缺真实商户配置时仅允许使用明确的 HMAC mock 模式联调,生产应切换 `PAYMENT_CALLBACK_MODE=sdk`。
T-305 已实现 `/api/v1/recharge/create` 与 `/api/v1/recharge/status`:两个端点走 `SessionAuthentication + IsAuthenticated`,属于用户端 session + CSRF 流程,不接受 API Key;创建订单时调用 `apps.billing.services.create_recharge_order()` 锁定当前汇率和预计到账点数,再经 `apps.billing.payment_gateways.create_payment_order()` 获取微信 native `code_url` 或支付宝当面付 `qr_code`;状态查询只允许订单所属用户访问,并在 pending 时尝试 `query_payment_order()` 主动查单补入账,查单不可用时保持 pending 等回调。
T-504 已实现用户端 `/recharge` 页面:GET 展示当前余额、充值表单、当前订单和最近充值;POST 经 `RechargeCreateForm` 校验金额与支付方式后复用 `create_recharge_order()` 创建 pending 订单并重定向到当前订单页,避免刷新重复下单;页面用本地 static 自托管的 qrcode.js 渲染 `code_url`,同时保留可复制支付票据兜底;浏览器每秒轮询 `/api/v1/recharge/status`,订单 paid 后刷新页面重新读取余额。页面不直接写 `UserWallet.points_balance` 或 `PointsLedger`。当 `PAYMENT_CALLBACK_MODE=mock` 时,页面必须醒目提示二维码为测试票据,不能用于微信/支付宝真实支付。当前因支付宝可信 IP 未配置,用户端表单暂只开放微信支付,底层支付宝 API / 回调 / SDK 路径保留。
T-306 已实现对外 API 安全加固:`download_image_input()` 在请求前校验 `image_url` 协议与解析后的 IP,只允许公网 `http` / `https`,拒绝私有、回环、链路本地、保留、组播、未指定地址;重定向由服务端手动跟随并逐跳重新校验,响应按 `IMAGE_URL_MAX_BYTES` 流式限长读取。`REST_FRAMEWORK` 全局默认认证为空、默认权限为 `IsAuthenticated`,外部 API 和用户端 session API 必须显式声明认证类;生成接口挂 `GenerateRateThrottle`,认证失败挂 IP 限流;充值下单通过 `RECHARGE_MAX_AMOUNT_CNY` 控制单笔上限。T-607/T-609/T-617 版本检查接口已显式使用 `AllowAny` / 空认证,且只返回公开发布元数据、强制更新标记和文件大小字节数。
T-619 多图理解继续复用上述 URL 下载器和逐跳 SSRF 校验,并额外用 `VISION_MAX_IMAGES`、`VISION_MAX_IMAGE_BYTES`、`VISION_MAX_TOTAL_BYTES` 控制图片数量、单图解码后大小和请求总大小。T-620 图生图同样复用 URL 校验,并用 `IMAGE_MAX_INPUT_IMAGES`、`IMAGE_MAX_INPUT_IMAGE_BYTES`、`IMAGE_MAX_INPUT_TOTAL_BYTES` 控制输入;新旧输入字段不可混用,校验、审核和图片下载 / 解码均在预扣前完成。第一版只审核文字 prompt,不包含图片内容审核。
**计费层(`apps/billing`)**
- 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。
- 点数原子扣减与退回:数据库事务 + 行锁,保证并发不超扣、不为负。
- 充值入账:接收已验签的支付回调数据,按汇率换算点数,幂等入账,写流水。
- 注册赠点:新用户注册成功后一次性发放 100 点试用点数,必须幂等、防并发重复,并写注册赠点流水。
- 手工调点:运营后台只收集点数变动和原因,必须调用计费层 `adjust_wallet_points()`;服务在事务内锁 `UserWallet`,拒绝扣成负数,并写 `PointsLedger(change_type=adjust)`。
- 点数流水记账:所有点数变动(充值/消费/调整/冲正/注册赠点)都生成一条流水。
- 是点数余额的唯一写入方。
**AI 调用层(`apps/ai`)**
- **别名解析**:把对外的能力别名(如 `title-standard` / `image-hd`)解析成当前映射的具体 `AiModel`,调用方不感知供应商 SKU。
- **Provider 适配器**:每个供应商一个适配器(`apps/ai/providers/`),实现统一接口 `generate_text()` / `generate_image()` / `capabilities()`;用注册表按 `api_type` 选适配器,取代一长串 `if api_type == ...`。移植 `cmbot/src/services/ai_text_service.py`、`ai_image_service.py` 的上游调用与解析逻辑到对应适配器内。**注意当前 3 个模型调用机制不同(见 3.1):文本走标准 chat,`nano-banana2` 走 chat 多模态返图、`gpt-image-2` 走 images/edits 改图,两图片模型非标准生成、需分别解析返回。**
- **能力校验前置**:按目标模型声明的 `capabilities` 校验请求(如图片模型不能生成文字),在扣点和调上游**之前**拒掉非法组合。
- **配置热生效**:模型配置从数据库读取,后台修改后运行时及时生效(每次查库或带缓存失效),不在进程内缓存到失效。
- 输入:prompt、可选图片、能力别名、分辨率、`parameters` 安全子集;输出:文字或图片,或明确错误。`parameters` / `extra_body` 不得覆盖服务端固定的核心/计费字段(如 `model`、`n`、`size`、`resolution`、`messages`、`image*`)。
- 处理超时、错误,向上层抛出可分类的异常(区分「上游失败」与「参数错误」)。不在本层写点数逻辑。
**内容安全层(`apps/moderation`)**
- T-604 的范围只包含输入 prompt 敏感词过滤;具体实施规则见 [`moderation.md`](moderation.md)。
- 生成接口时序必须是 serializer 后先审 prompt,再读取 `image_base64` / 下载 `image_url`,最后才进入别名解析、计费和预扣。命中敏感词时不得下载图片、不得扣点、不得写调用/流水、不得调上游。
- 本地词表只做免费快筛和运营自定义黑名单,不替代合规内容审核;云厂商内容安全、图片审核和输出审核后续用 provider 接口扩展。
- 敏感词 matcher 必须缓存,禁止每请求重建;生产多 worker 下用共享 cache 的词库版本号触发各 worker 懒重建,不能只依赖当前进程的 Django signal。
- 留痕只记录分类、命中词 ID、用户/API Key 关联和处理结果,不保存违规 prompt 原文。
**数据库 / 存储**
- 持久化:账号、API Key、点数余额、计费规则、汇率、充值订单、点数流水、调用记录、模型配置。
- 点数余额是权威账本;不持久化上游返回的大图原文,调用记录只存引用/路径/摘要,不整包保存 provider `raw` 或 base64 图片(大对象存储策略 MVP 待定)。
- 点数余额可变但只能经计费层修改;流水与调用记录只追加、不可改。
## 三、数据模型
> 以下为目标 schema 形状;以 Django models 实现,字段名以英文为准。schema 变化必须同步本节与 `api.md`。
### 3.1 配置 / 规则数据(运营在后台维护)
| 数据 | 来源 | 说明 |
| --- | --- | --- |
| AiModel | 迁移自 `cmbot/config/ai_models.json` | name、url、model、`api_type`、`capabilities`、`timeout_seconds`、`connect_timeout_seconds`、`extra_body`、`is_active`、**api_key_encrypted(Fernet 加密存储)** |
| ModelAlias | 运营配置 | `operation_type + alias` → 映射到一个具体 AiModel;支持每个操作一个默认别名;调用方只见别名,不见 SKU |
| AiConfigAuditLog | 后台自动写入 | AiModel / ModelAlias / api_key 变更审计;记录 actor、action、target、changed_fields、changes、created_at;只读 |
| PricingRule | 运营配置 | 操作类型 × **能力别名字符串**(+ 可选分辨率)→ 点数单价;不外键到具体 AiModel |
| ExchangeRate | 运营配置 | 金额 → 点数 的汇率(如 1 元 = N 点),按 `currency + effective_from` 取当前有效值 |
| SensitiveWord | 运营配置 | 本地敏感词快筛词库;word、normalized_word、category、action、is_active;T-604 MVP 仅支持 action=block |
| DownloadRelease | 运营配置 | 客户端下载版本;platform、version、file、external_url、size_bytes、sha256、is_current、force_update、release_notes、created_at、updated_at;每平台当前版本由应用层事务保存时互斥 |
| ImportTemplate | 运营配置 | 导入商品数据模板;name、file、external_url、sha256、is_current、notes、created_at、updated_at;当前模板由应用层事务保存时互斥 |
关键事实:
- **对外契约绑能力别名,不绑具体模型名**:调用方传 `model = "title-standard"` 这类别名;后台把别名映射到具体 `AiModel`,换供应商只改后台映射,调用方零改动。具体模型名只在后台可见。
- `api_type` 取值沿用 cmbot:`chat` / `gemini` / `images` / `images_edits` / `auto`,用于选择 Provider 适配器。**当前启用 3 个模型(中转站 `api.vectorengine.ai`),调用机制各不相同,适配器须分别实现**:
- **GPT-5.5 文本**(`api_type=chat`,model `gpt-5.5`):标准 `chat/completions`。
- **Nano Banana 2**(`api_type=auto`,model `gemini-3.1-flash-image-preview`):走 `chat/completions` **多模态返图**,图片在返回消息里(URL 或 base64),需**自定义解析**;可纯文生图,也可带原图图生图。
- **GPT Image 2**(`api_type=images_edits`,model `gpt-image-2`):走 `images/edits` **改图**,**必须传入原图 + prompt**(契合「传商品图→生成主图」)。
- ⚠️ 两个图片模型**都不是**标准 `images/generations`,不可套用通用图片生成 SDK 写法;每个模型**独立 url + key**,不共享 base_url。
- ⚠️ 图片返回结构(URL / base64、在返回中的位置)**首次对接须抓一次真实响应,再定适配器解析代码**。
- `capabilities` 显式声明每个模型能做什么;标题接口只允许声明 `text` 的模型,图片接口只允许声明 `image` 的模型。
- `api_key` **使用 Fernet 加密存储到 `api_key_encrypted`**,admin 只提供写入型 `api_key` 字段、脱敏显示、不回显明文;模型/密钥/别名映射的变更需审计留痕。
- `AiConfigAuditLog` 只追加、不在后台编辑删除;密钥变更只记录 `api_key: empty/set` 状态变化,不记录明文 key 或 Fernet 密文。
- 汇率在**充值下单创建订单时**锁定并记录到订单,同时计算预计到账点数;后续改汇率不影响已创建订单。支付回调入账时使用订单上的 `exchange_rate` / `points_granted`,不重新读取当前汇率。
- Provider 适配器只允许白名单安全参数透传;外部调用方或后台 `extra_body` 传入核心字段时忽略,由服务端按别名解析和计费口径固定。
- `SensitiveWord` 的 matcher 由 active 词库构建;词库变更后更新共享 cache 版本号,各 Gunicorn worker 在下一次请求检查版本并懒重建。
#### 配置表目标 schema
```sql
-- 上游模型(具体 SKU,后台维护,密钥加密)
CREATE TABLE ai_model (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL UNIQUE, -- 后台展示名
url VARCHAR(500) NOT NULL,
model VARCHAR(128) NOT NULL, -- 供应商模型标识
api_key_encrypted LONGTEXT NOT NULL, -- Fernet 密文,不存明文
api_type VARCHAR(32) NOT NULL, -- chat/gemini/images/images_edits/auto
capabilities JSON NOT NULL, -- ["text","image","vision"] 或等价对象
timeout_seconds INTEGER NOT NULL DEFAULT 0,
connect_timeout_seconds INTEGER NOT NULL DEFAULT 30,
extra_body JSON NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL
);
-- 能力别名(对外稳定标识 → 当前映射的具体模型)
CREATE TABLE model_alias (
id BIGINT PRIMARY KEY,
alias VARCHAR(64) NOT NULL, -- 如 title-standard / image-hd / vision-standard,对外用
operation_type VARCHAR(32) NOT NULL, -- title / image / vision
ai_model_id BIGINT NOT NULL REFERENCES ai_model(id), -- 可随时改指向
is_default BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE(operation_type, alias)
);
-- AI 配置审计(只追加,后台只读)
CREATE TABLE ai_config_audit_log (
id BIGINT PRIMARY KEY,
actor_id BIGINT REFERENCES "user"(id) ON DELETE SET NULL,
action VARCHAR(16) NOT NULL, -- create / update / delete
target_type VARCHAR(32) NOT NULL, -- ai_model / model_alias
target_id BIGINT,
target_repr VARCHAR(255) NOT NULL,
changed_fields JSON NOT NULL, -- ["url", "api_key"]
changes JSON NOT NULL, -- 字段 old/new;api_key 仅 empty/set
created_at DATETIME(6) NOT NULL
);
-- 计费规则(按别名定价,换底层模型不影响计费)
CREATE TABLE pricing_rule (
id BIGINT PRIMARY KEY,
operation_type VARCHAR(32) NOT NULL, -- title / image / vision
alias VARCHAR(64) NOT NULL, -- 能力别名字符串,不绑具体模型
resolution VARCHAR(32) NOT NULL DEFAULT '', -- 空字符串表示该别名默认价
points_cost BIGINT NOT NULL, -- > 0
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE(operation_type, alias, resolution)
);
-- 金额换点数汇率(充值下单时锁定)
CREATE TABLE exchange_rate (
id BIGINT PRIMARY KEY,
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
points_per_unit DECIMAL(12,4) NOT NULL, -- 1 currency unit = N points
is_active BOOLEAN NOT NULL DEFAULT TRUE,
effective_from DATETIME(6) NOT NULL,
note VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL
);
-- 客户端下载版本(首页读取当前版本;安装包生产由 Nginx 直接服务 media)
CREATE TABLE download_release (
id BIGINT PRIMARY KEY,
platform VARCHAR(32) NOT NULL, -- windows / macos / linux
version VARCHAR(64) NOT NULL,
file VARCHAR(100) NOT NULL DEFAULT '', -- MEDIA_ROOT/downloads/...
external_url VARCHAR(200) NOT NULL DEFAULT '', -- 有则优先,后续切 CDN/对象存储
size_bytes BIGINT UNSIGNED NULL, -- 安装包文件大小字节数,客户端校验用
sha256 VARCHAR(64) NOT NULL DEFAULT '',
is_current BOOLEAN NOT NULL DEFAULT FALSE,
force_update BOOLEAN NOT NULL DEFAULT FALSE, -- 当前版本是否强制升级
release_notes LONGTEXT NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL
);
-- 导入模板(首页读取当前模板;模板文件生产由 Nginx 直接服务 media)
CREATE TABLE import_template (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
file VARCHAR(100) NOT NULL DEFAULT '', -- MEDIA_ROOT/import_templates/...
external_url VARCHAR(200) NOT NULL DEFAULT '', -- 有则优先,后续切 CDN/对象存储
sha256 VARCHAR(64) NOT NULL DEFAULT '',
is_current BOOLEAN NOT NULL DEFAULT FALSE,
notes LONGTEXT NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL
);
```
T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。T-203 已实现 `apps.billing.services`:`precharge_call()` 锁 `UserWallet` 行预扣并写 pending 调用与 consume 流水;`mark_call_success()` 确认成功不再改余额;`refund_call_points()` 锁调用记录并幂等退点,写 refund 流水。T-401 已在同一计费层新增 `adjust_wallet_points()`,供 admin 手工调点使用。T-606/T-609/T-617/T-618 已实现 `DownloadRelease`,admin 可上传安装包或填写 `external_url`、标记当前版本、配置是否强制更新和文件大小字节数;T-618 起 admin 新增 / 编辑发布版本时 `sha256` 与 `size_bytes` 必填,但数据库仍保留空值兼容历史记录;`external_url` 优先于 `file.url`,每平台仅一个当前版本由模型 `save()` 在事务内把同平台旧 current 置为 false。T-610 已新增 `ImportTemplate`,admin 可上传导入模板或填写 `external_url` 并标记当前模板;`external_url` 优先于 `file.url`,当前模板互斥同样由应用层事务处理,不使用 MySQL 不支持的条件唯一约束。T-607/T-609/T-617 版本检查接口只读取 `DownloadRelease(platform, is_current=True)`;响应 URL 是外部可访问 URL,不暴露本地 `MEDIA_ROOT`,并把 `force_update` 和 `size_bytes` 作为公开发布元数据返回。导入模板下载不新增对外 JSON API,首页直接渲染当前模板下载链接。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,若后续要强制数据库层默认别名唯一,可另评估触发器或约束表。
> 可选增强(接口预留、MVP 不实现):`account_alias_permission`(按账号授权可用别名,防止调用方点用未授权/昂贵模型);别名按比例分流到多个模型(灰度/AB/故障转移)。适配器接口需为此留口子。
### 3.2 业务动态数据
```sql
-- 注册用户(扩展 Django AbstractUser,登录态)
CREATE TABLE "user" (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT NOT NULL UNIQUE, -- 注册邮箱,必填且唯一;当前免邮箱验证
password TEXT NOT NULL, -- Django 哈希存储
payment_user_id TEXT, -- 对应外部支付系统的用户标识
status TEXT NOT NULL DEFAULT 'active', -- active / disabled
created_at TEXT NOT NULL
);
-- 点数钱包(点数余额为权威账本;与 auth 表解耦,扣点只锁本行)
CREATE TABLE user_wallet (
id INTEGER PRIMARY KEY,
user_id INTEGER UNIQUE NOT NULL REFERENCES "user"(id),
points_balance BIGINT NOT NULL DEFAULT 0, -- >=0,仅计费层可改
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 注册赠点幂等标记(每个用户最多一条;MySQL 兼容,不使用条件唯一约束)
CREATE TABLE signup_bonus_grant (
id INTEGER PRIMARY KEY,
user_id INTEGER UNIQUE NOT NULL REFERENCES "user"(id),
points_granted BIGINT NOT NULL DEFAULT 100,
created_at TEXT NOT NULL
);
-- API Key(一个用户可有多把;哈希存储,明文只在创建时显示一次)
CREATE TABLE api_key (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES "user"(id),
name TEXT, -- 用户自定义备注
key_hash TEXT UNIQUE NOT NULL, -- sha256(key),不存明文
key_prefix TEXT NOT NULL, -- 如 sk_live_abc…,列表展示/定位用
status TEXT NOT NULL DEFAULT 'active', -- active / revoked
last_used_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 充值订单(支付回调入账依据,订单号幂等)
CREATE TABLE recharge_order (
id INTEGER PRIMARY KEY,
order_no TEXT UNIQUE NOT NULL, -- 幂等键,重复回调只入账一次
user_id INTEGER NOT NULL REFERENCES "user"(id), -- 发起充值的用户,绑定防充错账户
amount_money DECIMAL NOT NULL, -- 付款金额
pay_method TEXT NOT NULL, -- weixin / alipay
exchange_rate DECIMAL NOT NULL, -- 下单时锁定的汇率
points_granted BIGINT NOT NULL, -- 下单时按汇率计算的预计/实际入账点数
status TEXT NOT NULL DEFAULT 'pending', -- pending / paid / failed / expired
code_url TEXT, -- 支付二维码票据(微信 code_url / 支付宝 qr_code)
expires_at TEXT, -- 二维码过期时间
payment_txn_no TEXT, -- 支付系统流水号
created_at TEXT NOT NULL,
paid_at TEXT
);
-- 点数流水(只追加,对账与审计核心)
CREATE TABLE points_ledger (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES "user"(id),
change_type TEXT NOT NULL, -- recharge / consume / adjust / refund / signup_bonus
points_delta BIGINT NOT NULL, -- 正为加、负为减
balance_after BIGINT NOT NULL, -- 变动后余额,便于对账
ref_order_id INTEGER, -- 数值引用 RechargeOrder.id;T-304 已加 ref_order_id+change_type 唯一兜底
ref_call_id INTEGER REFERENCES call_record(id),
reason TEXT, -- 运营手工调整必填原因
created_at TEXT NOT NULL
);
-- 调用记录(只追加)
CREATE TABLE call_record (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES "user"(id),
api_key_id INTEGER REFERENCES api_key(id), -- 本次调用所用的 Key
operation_type TEXT NOT NULL, -- title / image / vision
alias TEXT, -- 调用方请求的能力别名(对外稳定标识)
model_used TEXT, -- 实际服务该次请求的具体模型(解析后,便于排障/对账)
resolution TEXT,
prompt TEXT,
points_cost BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL, -- pending / success / failed
upstream_latency_ms INTEGER,
error_message TEXT,
result_ref TEXT, -- 结果引用(URL/路径/摘要),不存 raw/base64
result_summary TEXT, -- 可选短摘要,不存 provider raw
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 异步图片生成任务(对外只暴露 task_id,不暴露自增 id)
CREATE TABLE image_generation_task (
id INTEGER PRIMARY KEY,
task_id CHAR(32) UNIQUE NOT NULL, -- UUID
user_id INTEGER NOT NULL REFERENCES "user"(id),
api_key_id INTEGER NOT NULL REFERENCES api_key(id),
call_record_id INTEGER UNIQUE NOT NULL REFERENCES call_record(id),
status TEXT NOT NULL, -- queued / running / succeeded / failed / expired
idempotency_key VARCHAR(128) NOT NULL DEFAULT '',
idempotency_key_hash CHAR(64), -- api_key + hash 唯一;NULL 允许无幂等键任务重复存在
request_hash CHAR(64) NOT NULL,
request_payload JSON NOT NULL, -- prompt/model/resolution/parameters/输入文件引用,不存 base64 原文
input_image VARCHAR(100) NOT NULL DEFAULT '', -- 历史单图任务兼容读取
result_url TEXT NOT NULL DEFAULT '',
error_code VARCHAR(64) NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
points_balance_after_charge BIGINT NOT NULL DEFAULT 0,
started_at DATETIME(6),
finished_at DATETIME(6),
expires_at DATETIME(6),
next_attempt_at DATETIME(6),
locked_at DATETIME(6),
lease_expires_at DATETIME(6),
heartbeat_at DATETIME(6),
worker_id VARCHAR(128) NOT NULL DEFAULT '',
attempt_count INTEGER NOT NULL DEFAULT 0,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE(api_key_id, idempotency_key_hash)
);
-- 异步任务的有序输入图片(T-620)
CREATE TABLE image_generation_task_input (
id INTEGER PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES image_generation_task(id),
ordinal SMALLINT UNSIGNED NOT NULL,
image VARCHAR(100) NOT NULL,
mime_type VARCHAR(100) NOT NULL DEFAULT 'image/png',
filename VARCHAR(255) NOT NULL DEFAULT 'image.png',
created_at DATETIME(6) NOT NULL,
UNIQUE (task_id, ordinal)
);
```
需要说明:
- 主键自增;`api_key.key_hash`、`pricing_rule(operation_type, alias, resolution)`、`recharge_order.order_no`、`user.username` 唯一。
- 重要索引:`call_record(user_id, created_at)`、`points_ledger(user_id, created_at)`、`recharge_order(order_no)`、`api_key(key_hash)`、`signup_bonus_grant(user_id)`、`image_generation_task(status, created_at)`、`image_generation_task(status, next_attempt_at)`、`image_generation_task(status, lease_expires_at)`、`image_generation_task(user_id, created_at)`。
- 不软删除业务流水;用户/账号可标记 `disabled` 而非物理删;API Key 用 `revoked` 状态而非物理删。
- 服务端生成字段:`api_key.key_hash`/`key_prefix`、`points_balance`、`balance_after`、各 `created_at` / `updated_at`。
- `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。
- `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。
- `call_record.status` 状态机为 `pending -> success / failed`。异步任务临时性失败等待重试时仍保持 `pending`;最终上游失败退点后才变为 `failed`,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。
- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-401 已落地 `adjust_wallet_points()`,手工调整点数必须带原因并写 `adjust` 流水,后台钱包余额字段只读;T-608 已新增 `signup_bonus` 流水类型和 MySQL 兼容的注册赠点幂等标记 `SignupBonusGrant(user UNIQUE)`,并把 allauth 自助注册路径改为调用 `grant_signup_bonus()` 发放 100 点;T-614 已在 `apps.api` 落地 `ImageGenerationTask` 与 `api.0001_initial` 迁移,使用 MySQL 兼容的 `(api_key, idempotency_key_hash)` 唯一约束处理幂等键,不使用条件唯一约束;T-616 已在 `ImageGenerationTask` 增加 `next_attempt_at` 与 `(status, next_attempt_at)` 索引,用于 worker 避免立即反复抢占等待重试的任务;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503/T-608 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,充值 / 注册赠点 / 消费 / 退款按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。
## 四、计费时序(核心,务必照此实现)
### 4.1 调用扣点(同步生成)
```text
1. API Key 鉴权(对请求 Key 做哈希比对)→ 定位 ApiKey → 所属 User(user/key 任一 disabled 直接拒绝)
2. 别名解析:alias → ModelAlias → 具体 AiModel;按 capabilities 校验请求合法(非法返回 model_not_allowed)
3. 查 PricingRule(operation_type, alias, resolution) → 本次点数 N(缺规则返回 no_pricing_rule)
4. 事务 A:select_for_update 锁该用户的 user_wallet 行
若 points_balance < N → 回滚,返回「点数不足,请先充值」(不调上游、不扣点)
否则 points_balance -= N,写一条 points_ledger(consume, -N, user),建 call_record(status=pending, user, api_key, alias, model_name)
提交事务 A(点数已预扣)
5. 调 AI 上游生成(同步等待,经 Provider 适配器)
成功 → 更新 call_record(success, result_ref, latency)
失败 → 事务 B:退点 points_balance += N,写 points_ledger(refund, +N),
更新 call_record(failed, error)
6. 返回结果或错误
```
要点:
- 扣点锁 `user_wallet` 行(`select_for_update()`)或 `F('points_balance') - N` 原子更新 + DB 约束 `points_balance >= 0`(MySQL 的 CHECK 需 ≥8.0.16),**杜绝并发超扣 / 扣成负数**;钱包与 auth `user` 表分离,扣点不与登录/资料更新抢锁。
- **先扣后调、失败必退**:保证不会“调用成功但没扣到”或“失败还扣钱”。
- 余额不足在调上游**之前**拦截。
- `vision` 同步调用在别名解析前先审核 prompt,再按顺序读取 1–8 张图片;输入失败不进入预扣。计费规则使用空 `resolution` 默认价,一次请求固定预扣一次,不随图片数量重复扣费。
### 4.1.1 调用扣点(异步图片任务)
```text
1. API Key 鉴权 + serializer 参数校验。
2. prompt 内容安全审核;命中 content_blocked → 400,不建任务、不扣点、不调上游。
3. 图片输入校验 / 解码:image_base64 解码后保存为输入文件引用,任务快照不保存 base64 原文;image_url 在 submit 阶段执行公网/协议/大小校验并下载为输入文件引用。
4. 别名、Provider 能力、PricingRule 校验;缺规则或能力不符 → 不扣点。
5. 事务:锁钱包预扣点,写 CallRecord(pending) + PointsLedger(consume),创建 ImageGenerationTask(queued, call_record=已预扣调用)。
6. 立即返回 202 + task_id。
7. worker 抢 queued → running,写 worker_id / locked_at / lease_expires_at / heartbeat_at / attempt_count。
8. worker 复用已预扣 CallRecord 调上游:
成功 → mark_call_success,任务置 succeeded,写稳定 result_url。
可重试失败且 attempt_count < max_attempts → 任务回 queued,写 next_attempt_at,CallRecord 保持 pending,不退点。
不可重试失败或最终失败 → refund_call_points 幂等退点,任务置 failed。
9. reaper 扫描 lease/heartbeat 过期的 running:
默认置 failed + refund_call_points;不默认重排队。
若 call_record 已 success,则只把任务补成 succeeded,不退款。
```
要点:
- 提交即预扣,避免余额只够 1 张却排入大量任务;余额不足仍返回 `402 insufficient_points`。
- `Idempotency-Key` 按当前 API Key 去重,同 key 同 payload 返回同一任务且不重复扣点;同 key 不同 payload 返回 `409 idempotency_conflict`。
- 桌面端主链路推荐传 `image_base64`,submit 阶段只做解码和落盘,通常是毫秒级;`image_url` 输入会在 submit 阶段下载并可能阻塞,属于边缘路径,换来的是任务自包含和 worker 不再访问调用方外部 URL。
- 默认只重试 `upstream_timeout` / `upstream_error` 这类临时性上游失败;敏感词、余额不足、未定价、别名 / 模型不可用、参数错误和 Provider 配置错误不重试。
- worker 是至少一次执行模型,但账务终态和结果终态必须 exactly-once:不得重复扣/退,不得覆盖已成功结果,也不得把 reaper 已失败退款的任务改回成功。
- 查询接口只按 `task_id` + 当前 API Key 所属用户查任务,防止 IDOR;成功任务重复查询返回同一 `result_url`。
### 4.2 充值入账(自助扫码 + 支付回调)
```text
0. 用户在用户端发起充值(选金额与支付方式)→ 后端读取当前 ExchangeRate,
计算 points_granted = floor(amount × rate),建 recharge_order(status=pending, user, amount, pay_method, exchange_rate, points_granted)
→ 调支付系统下单接口取支付二维码 → 前端展示二维码 + 轮询订单状态
(二维码有临时有效期,过期可重新下单;订单绑定发起的 user,防充错账户;页面可展示预计到账点数)
1. 用户扫码付款成功 → 支付系统服务端回调(含 order_no、amount、txn_no、签名)
2. 验签失败 → 拒绝,不入账(记录可疑回调)
3. 事务:按 order_no 查 recharge_order
若该订单已是 paid → 幂等返回成功,不重复加点
否则:校验回调金额与订单 amount_money 一致
使用订单已锁定的 points_granted 入账
锁 user_wallet 行,points_balance += points_granted(计费层方法)
写 points_ledger(recharge, +points_granted, ref_order)
order.status = paid,记 txn_no、paid_at
4. 返回支付系统约定的成功应答
```
要点:
- **幂等**:同一 `order_no` 重复回调只入账一次。
- **验签**:回调必须验签,防伪造刷点;微信 V3 用 SDK 验签解密,支付宝用 SDK `verify`。
- 汇率下单时锁定写入订单,后续改汇率不影响 `pending` 或已支付订单;回调不重新读取当前汇率。
- 订单状态机 `pending / paid / failed / expired`;二维码有有效期,过期重新下单。发起充值的 `user` 必须绑定到订单,回调按 `order_no` 定位订单→其 `user` 入账,防充错账户。
- **通道协议**(对齐同支付系统 PHP 实现):微信 V3 `native`(库 `wechatpayv3`,金额**分**,回调 SDK 验签解密、`TRANSACTION.SUCCESS`、应答 `{code:SUCCESS}`);支付宝当面付 `trade.precreate`(库 `python-alipay-sdk`,金额**元**,回调 `verify` 验签、`TRADE_SUCCESS/FINISHED`、应答纯文本 `success`)。两个回调端点必须 `@csrf_exempt`。
- **兜底**:回调可能丢失,须提供按 `order_no` 主动查单补入账;前端每 ~1s 轮询订单状态仅作 UX。
- 仅商户密钥/证书为真实值待提供(微信 appid/mchid/apiv3_key/证书、支付宝 appid/公私钥/RSA2、notify_url 域名),协议已明确,可先 mock。
### 4.3 注册赠点(新用户 100 点试用额度)
```text
1. allauth 注册成功并保存 User(邮箱必填且唯一,当前免邮箱验证)
2. 调用 billing.grant_signup_bonus(user, points=100)
3. 事务:创建或锁定该用户的 UserWallet;创建 SignupBonusGrant(user UNIQUE) 幂等标记
若标记已存在 → 直接返回已发放,不改余额、不写新流水
否则 points_balance += 100
写 points_ledger(signup_bonus, +100, balance_after, reason="new_user_registration")
4. 注册后 dashboard / balance API 读取到包含 100 点赠点后的余额
```
要点:
- 注册赠点不是充值收入,但属于可消费点数,必须纳入点数账本和后台审计。
- 不允许在 adapter / view / signal 中直接 `points_balance=100`;所有余额变动必须走计费层。
- 幂等要有数据库级兜底。MySQL 不支持条件唯一约束,不要用 partial unique;也不要用 `PointsLedger(user, change_type)` 这类会误伤其他流水类型的全局唯一约束。推荐独立 `SignupBonusGrant(user UNIQUE)` 标记表。
- 只对 T-608 上线后的新注册用户自动发放。历史用户是否补发不在本流程内,需单独审批、单独任务、单独批处理流水。
- 由于当前注册免邮箱验证,注册送点会提高刷号动机。T-608 已显式配置 `ACCOUNT_SIGNUP_RATE_LIMIT`(默认 `20/m/ip`)并复用 allauth signup rate limit;图形验证码 / 人机验证可作为上线前风控项继续增强。
## 五、关键技术难点
| 难点 | 说明 | 应对 |
| --- | --- | --- |
| 并发扣点超扣 | 多请求同时扣同一账号 | 行锁 / 原子更新 + DB 约束 `>=0`(MySQL 的 CHECK 需 ≥8.0.16 才生效,不能只靠它;主防线是 `select_for_update` 或 `UPDATE ... WHERE balance>=N`),并发测试覆盖 |
| 充值重复入账 | 回调可能重发 | `order_no` 唯一 + 状态机幂等 |
| 回调伪造 | 不验签会被刷点 | 强制验签,验签失败不入账并留痕 |
| 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | T-612 后生图 Provider 读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`,撞硬截止走失败退点;T-614 已提供异步提交 / 轮询路径,新客户端优先使用异步任务,旧同步接口保留兼容 |
| 异步生图临时性上游失败 | 上游偶发 `Read timed out`、502/503/504 直接失败会降低批量任务成功率 | T-616 起异步 worker 对 `upstream_timeout` / `upstream_error` 默认最多重试 2 次,重试等待用 `next_attempt_at` 防止立即打爆上游;最终失败才退款 |
| 异步任务僵死 / 迟到 worker | worker 抢到任务后进程崩溃会留下 running;迟到 worker 可能在 reaper 退款后返回成功 | `ImageGenerationTask` 记录租约和心跳;reaper 按 `lease_expires_at` / `heartbeat_at` 幂等失败退款;成功/失败终态写入前重新锁任务,禁止覆盖已成功或已退款失败的任务 |
| 异步执行时配置漂移 | worker 当前会复跑审核、别名解析和定价;账务用已预扣点数,不重复扣费,但排队期间后台改别名可能导致按提交时价格、执行时模型运行 | 当前接受该取舍并视为短队列安全优先;队列积压或多模型价差扩大后,单独实现提交时 `ai_model_id` / `model_used` 快照,worker 只复审 prompt、不重选模型 |
| 上游错误分类 | 区分参数错误与上游故障 | AI 层抛分类异常;上游故障退点 |
| 凭证安全 | 上游 api_key、支付密钥 | 应用层加密存储,admin 脱敏不回显;其余密钥走环境变量,不入代码与样例 |
| 抽象泄漏 / 参数越权 | 各供应商入参出参不一致;若调用方 `parameters` 可覆盖 `model`/`n`/`size` 会击穿别名计费 | Provider 适配器 + capabilities 声明 + `parameters` 白名单过滤;核心/计费字段服务端固定,不取交集、不允许覆盖 |
| 供应商耦合 | 调用方绑具体 SKU 则换模型要通知所有接入方 | 对外绑能力别名,后台改别名→模型映射即可换供应商 |
| 配置热生效 | 后台改模型/密钥后运行时仍用旧值 | 不在进程内长缓存;每次查库或保存时失效缓存 |
| 配置变更审计 | 改密钥/模型/别名映射无痕 | `AiConfigAuditLog` 自动记录后台 create/update/delete;密钥只记录 empty/set 变化,日志只读 |
| API Key 泄露 | 明文存库一旦泄露全泄 | Key **哈希存储**(sha256),明文只在创建时显示一次,库内存 `key_hash`+`key_prefix`,鉴权做哈希比对 |
| 注册滥用 | 自助注册被批量刷以获取 100 点试用额度 | **免邮箱验证**(`ACCOUNT_EMAIL_VERIFICATION="none"`)下已显式配置 `ACCOUNT_SIGNUP_RATE_LIMIT` 基础注册限流;图形验证码 / 人机验证仍是上线前风控增强项;注册赠点走 `SignupBonusGrant(user UNIQUE)` 幂等标记 + `PointsLedger(signup_bonus)` 留痕,防重复发放 |
| Web/API 抢 worker | 图片同步长请求占满 worker、拖慢用户端页面 | 单体下按路径把 `/api/v1/generate/*` 与用户端页面**分流到不同 gunicorn/worker 池**(见 5.1) |
| 充错账户 | 扫码订单未绑定发起用户 | 订单创建即绑定 `user`;回调按 `order_no` 定位订单→其 user 入账 |
| SSRF / 任意 URL 下载 | `image_url` 让服务端请求调用方指定地址 | 仅允许公网 http/https;请求前解析 IP 并拒绝内网/回环/链路本地/保留地址;重定向逐跳校验;流式读取并限制大小 |
高风险功能(计费扣点、充值回调)应先做最小原型并写并发/幂等测试,再接入完整流程。
### 5.1 同步方案可用性结论(桌面端不改、全同步接入)
桌面端 cmbot 可以**不改交互骨架**,继续用同步方式调 cmhub、cmhub 再同步转中转站,正常使用——决定成败的不是同步/异步本身,而是下面两件事有没有配对好;配好就稳,配不好会「小量正常、上量假死」:
1. **超时链路层层放大且对齐**(外层 ≥ 内层):桌面端读超时 ≥ Nginx `proxy_read_timeout` ≥ Gunicorn `--timeout` ≥ cmhub→中转站实际读取超时。T-612 后生图读取超时为 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`;默认硬截止约 180s,生产按真实图片 smoke 校准。三个默认值仍是雷:`AiModel.timeout_seconds`(`0` 表示按 Provider 分辨率默认值,而不是无限等待)、Gunicorn 默认 `30s`(会直接杀掉跑图片的 worker)、Nginx 默认 `60s`。
2. **worker/线程数按峰值总并发预留**:同步下每个在飞请求占住一个 worker 整个生成周期(几十秒~分钟)。用 Gunicorn `gthread`(`--threads`)或多 sync worker,数量 ≥ 预期峰值总并发 + 余量,避免慢图片把快的生文接口和后台挤死。
适用边界:**旧客户端 / 小并发批量**(桌面端 `concurrency` 1~4)仍可继续同步使用,点数一致性简单。T-614 起新版客户端优先走异步任务接口:提交后拿 `task_id`,后台 worker 慢慢生成,客户端轮询状态和结果,适合图片耗时长、窗口重启后继续查询、以及避免 HTTP 长连接占满生成池的场景。T-615 起通过 `generation_route_usage` 日志观察旧同步路与新异步路的调用量、客户端版本、错误率和耗时;旧同步接口下线前必须先保持成功响应兼容,待新版客户端默认异步、旧路调用归零或连续一段时间低于阈值后,再宣布 deprecate 并单独立任务下线。
## 六、推荐开发顺序
1. Django + DRF 骨架可运行,django-admin 可登录(Phase 0)。
2. 移植并跑通一次 AI 调用(标题 / 图片)原型(Phase 1)。
3. 计费:点数扣减(并发安全)+ 计费规则 + 调用记录(Phase 2,T-201~T-203 已完成)。
4. 对外 API 鉴权 + 余额查询 + 充值下单/回调 + 安全加固(Phase 3,已完成到 T-306)。
5. 用户端注册登录、API Key 管理、个人中心、充值页(Phase 4;T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页与 T-504 充值页已完成)。
6. 运营后台完善(T-401 已完成)、完整验收、部署(Phase 5)。
## 七、项目结构建议
```text
cmhub/
├── docs/
├── manage.py
├── config/ # Django 工程:settings / urls / wsgi
├── apps/
│ ├── users/ # 注册用户 User、UserWallet 钱包、ApiKey(模型 + admin)
│ ├── portal/ # 用户端页面(Django 模板 SSR):公开首页/注册/登录/个人中心/充值/API Key/下载版本
│ ├── billing/ # 计费规则、汇率、充值订单、点数流水、扣点/入账逻辑(点数唯一写入方)
│ ├── ai/ # 别名解析 + Provider 适配器层
│ │ └── providers/ # 各供应商适配器(chat/gemini/images_edits…),移植自 cmbot
│ └── api/ # DRF 对外接口:生成、余额、支付回调、扫码下单
├── tests/ # 计费/回调/接口测试(或各 app 内 tests.py)
├── requirements.txt # 或 pyproject.toml(uv)
└── README.md
```
- `apps/ai/`:别名解析与适配器注册表;`apps/ai/providers/` 放移植自 `cmbot/src/services/` 的供应商实现,仅依赖 `requests`。
- `apps/billing/`:点数与资金的唯一权威逻辑,其他层只能调它的方法。
- 路径为模板建议,骨架落地后按真实结构同步 `current-state.md`。
## 八、架构纪律
- 业务事实和 schema 变化必须同步本文与 `api.md`。
- 点数余额只能经 `apps/billing` 修改,其他层不得直接写 `points_balance`。
- **对外契约只暴露能力别名,不暴露具体模型名**;新增供应商 = 加一个适配器,不改对外接口。
- 计费按别名定价;后台换别名→模型映射不影响对外契约与计费。
- 供应商密钥加密存储、不回显;模型/别名/密钥变更留痕。
- 不在代码里发明文档没有的接口、字段、状态码、错误码。
- 充值入账必须幂等,扣点必须并发安全,上游失败必须退点——这三条是硬约束。
- 不把配置数据、点数余额、流水混进同一个模型。
- 点数余额存 `UserWallet`(与 auth `User` 表分离);扣点锁 wallet 行,不与登录/资料更新抢锁。
- **API Key 哈希存储**(sha256),明文只在创建时返回一次;库内存 `key_hash`+`key_prefix`,任何页面不回显明文。
- **对外 API 只接受 API Key 认证,不挂 SessionAuthentication**,防止浏览器带 cookie 绕过 Key 与计费归属。
- 用户端页面用 session + CSRF;自助注册**免邮箱验证**(邮箱仍必填且唯一),生成接口须限流。
- **用户端与后台共用同一个 Django 会话(设计事实 + 账号分离约定)**:`/`(portal)、`/admin/`、`/api/`(session 部分)挂在同一域名/同一 Django 工程,用**同一个 `sessionid` cookie**;`/admin/` 不是「另一套登录」,只是在同一登录态上加一道 `is_staff=True` 门槛。因此:
- 普通用户 `is_staff=False`,登 portal **进不了** `/admin/`(无越权、无泄露);观察到的「用户端登录、后台跟着登」是因为用了 **staff/superuser 账号**同时登了 portal。
- **解耦方案(采用方案 A·账号分离,零代码)**:终端用户一律 `is_staff=False`;运营用**专用 admin superuser** 只登 `/admin/`,不与 portal 账号混用。这样两个入口天然分开。
- 不采用「同账号双会话」(需 admin 独立子域名/独立 cookie 或自定义中间件,成本高、MVP 不值)。
- 相关加固(上线前,见 `deployment.md`):`/admin/` 加访问保护(改路径 / IP 白名单 / 反代 basic auth / 2FA);**永不给终端用户 `is_staff`**。