2026-07-01 17:42:10 +08:00
# 架构设计
> 本文讲“怎么把技术栈搭起来”:系统结构、职责划分、数据模型、计费时序、技术难点、开发顺序。
> 具体用了哪些框架 / 库 / 数据库 / 部署方式,见 [技术栈](03-tech-stack.md)。
## 一、系统结构
```text
终端用户 ──浏览器/session──> [用户端 Web · Django模板SSR] 注册/登录/扫码充值/管API Key/看记录
用户的程序 ──API Key──────> [对外 API · DRF] 鉴权 → 计费 → 扣点 → 调上游 → 记录
│
[cmhub · Django 单体] ──下单/回调(验签)──> 外部支付系统 <──扫码付款── 终端用户
├──> 上游 AI( vectorengine.ai:标题/图片)
└──> MySQL(用户/钱包/APIKey/规则/订单/流水/调用记录)
│
[django-admin] <──── 运营人员(管理用户、点数、规则、记录)
```
组件落位:
2026-07-08 16:29:48 +08:00
- **用户端层(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 已新增公开 JSON 版本检查接口给桌面端自动更新使用,并返回强制更新标记。T-610 已在公开首页下载区增加导入模板下载入口,由后台 `ImportTemplate` 配置当前模板。
2026-07-01 17:42:10 +08:00
- **用户与账号层**:注册用户 `User` 、点数钱包 `UserWallet` 、`ApiKey` (一用户多把、哈希存储)。入口 `apps/users/` 。
2026-07-08 15:38:59 +08:00
- **API 层(DRF)**:对外生成接口、余额查询、支付回调接收、扫码下单、公开版本检查接口。入口 `apps/api/` 。生成/余额/模型目录这类对外业务 API **只认 API Key,不接受 Web session** ;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签;T-607/T-609 的客户端下载版本检查接口为公开只读例外,不需要 API Key,不读取用户、不扣点,`release.force_update` 仅表示当前发布版本是否强制升级。
2026-07-01 17:42:10 +08:00
- **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/` 。
- **AI 调用层(Provider Adapter 架构)**:对外只暴露稳定能力,内部用「能力别名 → 具体供应商适配器」解耦。入口 `apps/ai/` ,适配器在 `apps/ai/providers/` 。
2026-07-06 10:44:13 +08:00
- **内容安全层(本地敏感词 / 后续云审核)**:入口 `apps/moderation/` 。T-604 只做 prompt 文本本地敏感词快筛,命中在扣点和调上游前返回 `content_blocked` ;云内容安全、图片审核和输出审核保留扩展点,不在 T-604 范围。
2026-07-03 16:36:06 +08:00
- **运营后台**: django-admin,注册各模型的 Admin。入口各 app 的 `admin.py` 。T-401 已补齐用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录的管理/检索;点数手工调整只能从钱包专用入口提交原因和非 0 变动,经计费层写 `adjust` 流水。
2026-07-01 17:42:10 +08:00
- **外部服务**:上游 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 所属的注册用户。
- 参数校验、请求/响应序列化、错误码统一。
- 编排单次调用:调用计费层预扣 → 调 AI 层 → 成功确认 / 失败退点 → 写调用记录。
- 不直接写点数余额字段,必须走计费层提供的方法。
2026-07-02 17:40:26 +08:00
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` 。
2026-07-02 22:41:37 +08:00
T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image` :API 层只做鉴权、参数校验和编排;别名解析、Provider 选择、计费计算、预扣、成功确认、失败退点分别调用 `apps.ai` / `apps.billing` 既有模块。图片结果 MVP 先用本地 `default_storage` 保存到 `MEDIA_ROOT/generated/images/...` 并返回 `image_url` ; `CallRecord` 只写 URL / 摘要,不保存 provider `raw` 或 base64。
2026-07-03 08:36:30 +08:00
T-303 已实现 `/api/v1/balance` :外部 API 继续只认 API Key, API 层调用 `apps.billing.services.get_balance_snapshot()` 读取当前钱包余额;测试覆盖余额响应与流水累加一致的场景。
2026-07-03 09:07:21 +08:00
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` 。
2026-07-03 09:34:07 +08:00
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 等回调。
2026-07-06 08:56:30 +08:00
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 路径保留。
2026-07-03 14:49:45 +08:00
2026-07-08 15:38:59 +08:00
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 版本检查接口已显式使用 `AllowAny` / 空认证,且只返回公开发布元数据和强制更新标记。
2026-07-03 10:34:37 +08:00
2026-07-01 17:42:10 +08:00
**计费层(`apps/billing`) **
- 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。
- 点数原子扣减与退回:数据库事务 + 行锁,保证并发不超扣、不为负。
- 充值入账:接收已验签的支付回调数据,按汇率换算点数,幂等入账,写流水。
2026-07-08 14:19:00 +08:00
- 注册赠点:新用户注册成功后一次性发放 100 点试用点数,必须幂等、防并发重复,并写注册赠点流水。
2026-07-03 16:36:06 +08:00
- 手工调点:运营后台只收集点数变动和原因,必须调用计费层 `adjust_wallet_points()` ;服务在事务内锁 `UserWallet` ,拒绝扣成负数,并写 `PointsLedger(change_type=adjust)` 。
2026-07-08 15:13:23 +08:00
- 点数流水记账:所有点数变动(充值/消费/调整/冲正/注册赠点)都生成一条流水。
2026-07-01 17:42:10 +08:00
- 是点数余额的唯一写入方。
**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` 校验请求(如图片模型不能生成文字),在扣点和调上游**之前**拒掉非法组合。
- **配置热生效**:模型配置从数据库读取,后台修改后运行时及时生效(每次查库或带缓存失效),不在进程内缓存到失效。
2026-07-02 14:47:22 +08:00
- 输入:prompt、可选图片、能力别名、分辨率、`parameters` 安全子集;输出:文字或图片,或明确错误。`parameters` / `extra_body` 不得覆盖服务端固定的核心/计费字段(如 `model` 、`n` 、`size` 、`resolution` 、`messages` 、`image*` )。
2026-07-01 17:42:10 +08:00
- 处理超时、错误,向上层抛出可分类的异常(区分「上游失败」与「参数错误」)。不在本层写点数逻辑。
2026-07-06 10:44:13 +08:00
**内容安全层(`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 原文。
2026-07-01 17:42:10 +08:00
**数据库 / 存储**
- 持久化:账号、API Key、点数余额、计费规则、汇率、充值订单、点数流水、调用记录、模型配置。
2026-07-02 14:47:22 +08:00
- 点数余额是权威账本;不持久化上游返回的大图原文,调用记录只存引用/路径/摘要,不整包保存 provider `raw` 或 base64 图片(大对象存储策略 MVP 待定)。
2026-07-01 17:42:10 +08:00
- 点数余额可变但只能经计费层修改;流水与调用记录只追加、不可改。
## 三、数据模型
> 以下为目标 schema 形状;以 Django models 实现,字段名以英文为准。schema 变化必须同步本节与 `api.md`。
### 3.1 配置 / 规则数据(运营在后台维护)
| 数据 | 来源 | 说明 |
| --- | --- | --- |
2026-07-02 11:07:44 +08:00
| 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 |
2026-07-02 11:42:39 +08:00
| AiConfigAuditLog | 后台自动写入 | AiModel / ModelAlias / api_key 变更审计;记录 actor、action、target、changed_fields、changes、created_at;只读 |
2026-07-02 15:28:19 +08:00
| PricingRule | 运营配置 | 操作类型 × **能力别名字符串** (+ 可选分辨率)→ 点数单价;不外键到具体 AiModel |
| ExchangeRate | 运营配置 | 金额 → 点数 的汇率(如 1 元 = N 点),按 `currency + effective_from` 取当前有效值 |
2026-07-06 10:44:13 +08:00
| SensitiveWord | 运营配置 | 本地敏感词快筛词库;word、normalized_word、category、action、is_active; T-604 MVP 仅支持 action=block |
2026-07-08 15:38:59 +08:00
| DownloadRelease | 运营配置 | 客户端下载版本;platform、version、file、external_url、sha256、is_current、force_update、release_notes、created_at、updated_at;每平台当前版本由应用层事务保存时互斥 |
2026-07-08 16:01:31 +08:00
| ImportTemplate | 运营配置 | 导入商品数据模板;name、file、external_url、sha256、is_current、notes、created_at、updated_at;当前模板由应用层事务保存时互斥 |
2026-07-01 17:42:10 +08:00
关键事实:
- **对外契约绑能力别名,不绑具体模型名**:调用方传 `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` 的模型。
2026-07-02 11:07:44 +08:00
- `api_key` **使用 Fernet 加密存储到 `api_key_encrypted`** , admin 只提供写入型 `api_key` 字段、脱敏显示、不回显明文;模型/密钥/别名映射的变更需审计留痕。
2026-07-02 11:42:39 +08:00
- `AiConfigAuditLog` 只追加、不在后台编辑删除;密钥变更只记录 `api_key: empty/set` 状态变化,不记录明文 key 或 Fernet 密文。
2026-07-01 17:42:10 +08:00
- 汇率在**充值下单创建订单时**锁定并记录到订单,同时计算预计到账点数;后续改汇率不影响已创建订单。支付回调入账时使用订单上的 `exchange_rate` / `points_granted` ,不重新读取当前汇率。
2026-07-02 14:47:22 +08:00
- Provider 适配器只允许白名单安全参数透传;外部调用方或后台 `extra_body` 传入核心字段时忽略,由服务端按别名解析和计费口径固定。
2026-07-06 10:44:13 +08:00
- `SensitiveWord` 的 matcher 由 active 词库构建;词库变更后更新共享 cache 版本号,各 Gunicorn worker 在下一次请求检查版本并懒重建。
2026-07-01 17:42:10 +08:00
#### 配置表目标 schema
```sql
-- 上游模型(具体 SKU,后台维护,密钥加密)
CREATE TABLE ai_model (
2026-07-02 11:07:44 +08:00
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
2026-07-01 17:42:10 +08:00
);
-- 能力别名(对外稳定标识 → 当前映射的具体模型)
CREATE TABLE model_alias (
2026-07-02 11:07:44 +08:00
id BIGINT PRIMARY KEY ,
alias VARCHAR ( 64 ) NOT NULL , -- 如 title-standard / image-hd,对外用
operation_type VARCHAR ( 32 ) NOT NULL , -- title / image
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 )
2026-07-01 17:42:10 +08:00
);
2026-07-02 11:42:39 +08:00
-- 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
);
2026-07-01 17:42:10 +08:00
-- 计费规则(按别名定价,换底层模型不影响计费)
CREATE TABLE pricing_rule (
2026-07-02 15:28:19 +08:00
id BIGINT PRIMARY KEY ,
operation_type VARCHAR ( 32 ) NOT NULL , -- title / image
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
2026-07-01 17:42:10 +08:00
);
2026-07-06 23:18:08 +08:00
-- 客户端下载版本(首页读取当前版本;安装包生产由 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/对象存储
sha256 VARCHAR ( 64 ) NOT NULL DEFAULT '' ,
is_current BOOLEAN NOT NULL DEFAULT FALSE ,
2026-07-08 15:38:59 +08:00
force_update BOOLEAN NOT NULL DEFAULT FALSE , -- 当前版本是否强制升级
2026-07-06 23:18:08 +08:00
release_notes LONGTEXT NOT NULL ,
created_at DATETIME ( 6 ) NOT NULL ,
updated_at DATETIME ( 6 ) NOT NULL
);
2026-07-08 16:01:31 +08:00
-- 导入模板(首页读取当前模板;模板文件生产由 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
);
2026-07-01 17:42:10 +08:00
```
2026-07-08 16:29:48 +08:00
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 已实现 `DownloadRelease` , admin 可上传安装包或填写 `external_url` 、标记当前版本、配置是否强制更新;`external_url` 优先于 `file.url` ,每平台仅一个当前版本由模型 `save()` 在事务内把同平台旧 current 置为 false。T-610 已新增 `ImportTemplate` , admin 可上传导入模板或填写 `external_url` 并标记当前模板;`external_url` 优先于 `file.url` ,当前模板互斥同样由应用层事务处理,不使用 MySQL 不支持的条件唯一约束。T-607/T-609 版本检查接口只读取 `DownloadRelease(platform, is_current=True)` ;响应 URL 是外部可访问 URL,不暴露本地 `MEDIA_ROOT` ,并把 `force_update` 作为公开发布元数据返回。导入模板下载不新增对外 JSON API,首页直接渲染当前模板下载链接。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,若后续要强制数据库层默认别名唯一,可另评估触发器或约束表。
2026-07-02 11:07:44 +08:00
2026-07-01 17:42:10 +08:00
> 可选增强(接口预留、MVP 不实现):`account_alias_permission`(按账号授权可用别名,防止调用方点用未授权/昂贵模型);别名按比例分流到多个模型(灰度/AB/故障转移)。适配器接口需为此留口子。
### 3.2 业务动态数据
```sql
-- 注册用户(扩展 Django AbstractUser,登录态)
CREATE TABLE "user" (
id INTEGER PRIMARY KEY ,
username TEXT UNIQUE NOT NULL ,
2026-07-08 14:19:00 +08:00
email TEXT NOT NULL UNIQUE , -- 注册邮箱,必填且唯一;当前免邮箱验证
2026-07-01 17:42:10 +08:00
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,仅计费层可改
2026-07-02 15:06:00 +08:00
created_at TEXT NOT NULL ,
2026-07-01 17:42:10 +08:00
updated_at TEXT NOT NULL
);
2026-07-08 14:19:00 +08:00
-- 注册赠点幂等标记(每个用户最多一条;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
);
2026-07-01 17:42:10 +08:00
-- 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 ,
2026-07-02 15:06:00 +08:00
created_at TEXT NOT NULL ,
updated_at TEXT NOT NULL
2026-07-01 17:42:10 +08:00
);
-- 充值订单(支付回调入账依据,订单号幂等)
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 ),
2026-07-08 14:19:00 +08:00
change_type TEXT NOT NULL , -- recharge / consume / adjust / refund / signup_bonus
2026-07-01 17:42:10 +08:00
points_delta BIGINT NOT NULL , -- 正为加、负为减
balance_after BIGINT NOT NULL , -- 变动后余额,便于对账
2026-07-03 09:07:21 +08:00
ref_order_id INTEGER , -- 数值引用 RechargeOrder.id; T-304 已加 ref_order_id+change_type 唯一兜底
2026-07-01 17:42:10 +08:00
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
alias TEXT , -- 调用方请求的能力别名(对外稳定标识)
2026-07-02 15:06:00 +08:00
model_used TEXT , -- 实际服务该次请求的具体模型(解析后,便于排障/对账)
2026-07-01 17:42:10 +08:00
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 ,
2026-07-02 14:47:22 +08:00
result_ref TEXT , -- 结果引用(URL/路径/摘要),不存 raw/base64
2026-07-02 15:06:00 +08:00
result_summary TEXT , -- 可选短摘要,不存 provider raw
created_at TEXT NOT NULL ,
updated_at TEXT NOT NULL
2026-07-01 17:42:10 +08:00
);
```
需要说明:
2026-07-02 15:28:19 +08:00
- 主键自增;`api_key.key_hash` 、`pricing_rule(operation_type, alias, resolution)` 、`recharge_order.order_no` 、`user.username` 唯一。
2026-07-08 14:19:00 +08:00
- 重要索引:`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)` 。
2026-07-01 17:42:10 +08:00
- 不软删除业务流水;用户/账号可标记 `disabled` 而非物理删;API Key 用 `revoked` 状态而非物理删。
2026-07-02 15:06:00 +08:00
- 服务端生成字段:`api_key.key_hash` /`key_prefix` 、`points_balance` 、`balance_after` 、各 `created_at` / `updated_at` 。
2026-07-01 17:42:10 +08:00
- `payment_user_id` 、`payment_txn_no` 为对账预留,字段先建。
- `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。
- `call_record.status` 状态机为 `pending -> success / failed` 。上游失败退点后仍保持 `failed` ,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。
2026-07-08 15:13:23 +08:00
- 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-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` 。
2026-07-01 17:42:10 +08:00
## 四、计费时序(核心,务必照此实现)
### 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` 表分离,扣点不与登录/资料更新抢锁。
- **先扣后调、失败必退**:保证不会“调用成功但没扣到”或“失败还扣钱”。
- 余额不足在调上游**之前**拦截。
### 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。
2026-07-08 14:19:00 +08:00
### 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 上线后的新注册用户自动发放。历史用户是否补发不在本流程内,需单独审批、单独任务、单独批处理流水。
2026-07-08 15:13:23 +08:00
- 由于当前注册免邮箱验证,注册送点会提高刷号动机。T-608 已显式配置 `ACCOUNT_SIGNUP_RATE_LIMIT` (默认 `20/m/ip` )并复用 allauth signup rate limit;图形验证码 / 人机验证可作为上线前风控项继续增强。
2026-07-08 14:19:00 +08:00
2026-07-01 17:42:10 +08:00
## 五、关键技术难点
| 难点 | 说明 | 应对 |
| --- | --- | --- |
| 并发扣点超扣 | 多请求同时扣同一账号 | 行锁 / 原子更新 + DB 约束 `>=0` ( MySQL 的 CHECK 需 ≥8.0.16 才生效,不能只靠它;主防线是 `select_for_update` 或 `UPDATE ... WHERE balance>=N` ),并发测试覆盖 |
| 充值重复入账 | 回调可能重发 | `order_no` 唯一 + 状态机幂等 |
| 回调伪造 | 不验签会被刷点 | 强制验签,验签失败不入账并留痕 |
| 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | 每模型 timeout 必须生效并设上限;worker 数/网关超时按最慢模型预留;V2 异步化 |
| 上游错误分类 | 区分参数错误与上游故障 | AI 层抛分类异常;上游故障退点 |
| 凭证安全 | 上游 api_key、支付密钥 | 应用层加密存储,admin 脱敏不回显;其余密钥走环境变量,不入代码与样例 |
2026-07-02 14:47:22 +08:00
| 抽象泄漏 / 参数越权 | 各供应商入参出参不一致;若调用方 `parameters` 可覆盖 `model` /`n` /`size` 会击穿别名计费 | Provider 适配器 + capabilities 声明 + `parameters` 白名单过滤;核心/计费字段服务端固定,不取交集、不允许覆盖 |
2026-07-01 17:42:10 +08:00
| 供应商耦合 | 调用方绑具体 SKU 则换模型要通知所有接入方 | 对外绑能力别名,后台改别名→模型映射即可换供应商 |
| 配置热生效 | 后台改模型/密钥后运行时仍用旧值 | 不在进程内长缓存;每次查库或保存时失效缓存 |
2026-07-02 11:42:39 +08:00
| 配置变更审计 | 改密钥/模型/别名映射无痕 | `AiConfigAuditLog` 自动记录后台 create/update/delete;密钥只记录 empty/set 变化,日志只读 |
2026-07-03 11:51:46 +08:00
| API Key 泄露 | 明文存库一旦泄露全泄 | Key **哈希存储** (sha256),明文只在创建时显示一次,库内存 `key_hash` +`key_prefix` ,鉴权做哈希比对 |
2026-07-08 15:13:23 +08:00
| 注册滥用 | 自助注册被批量刷以获取 100 点试用额度 | **免邮箱验证** ( `ACCOUNT_EMAIL_VERIFICATION="none"` )下已显式配置 `ACCOUNT_SIGNUP_RATE_LIMIT` 基础注册限流;图形验证码 / 人机验证仍是上线前风控增强项;注册赠点走 `SignupBonusGrant(user UNIQUE)` 幂等标记 + `PointsLedger(signup_bonus)` 留痕,防重复发放 |
2026-07-03 17:53:49 +08:00
| Web/API 抢 worker | 图片同步长请求占满 worker、拖慢用户端页面 | 单体下按路径把 `/api/v1/generate/*` 与用户端页面**分流到不同 gunicorn/worker 池**(见 5.1) |
2026-07-01 17:42:10 +08:00
| 充错账户 | 扫码订单未绑定发起用户 | 订单创建即绑定 `user` ;回调按 `order_no` 定位订单→其 user 入账 |
2026-07-03 10:34:37 +08:00
| SSRF / 任意 URL 下载 | `image_url` 让服务端请求调用方指定地址 | 仅允许公网 http/https;请求前解析 IP 并拒绝内网/回环/链路本地/保留地址;重定向逐跳校验;流式读取并限制大小 |
2026-07-01 17:42:10 +08:00
高风险功能(计费扣点、充值回调)应先做最小原型并写并发/幂等测试,再接入完整流程。
### 5.1 同步方案可用性结论(桌面端不改、全同步接入)
桌面端 cmbot 可以**不改交互骨架**,继续用同步方式调 cmhub、cmhub 再同步转中转站,正常使用——决定成败的不是同步/异步本身,而是下面两件事有没有配对好;配好就稳,配不好会「小量正常、上量假死」:
2026-07-02 14:47:22 +08:00
1. **超时链路层层放大且对齐** (外层 ≥ 内层):桌面端读超时 ≥ Gunicorn `--timeout` / 网关 `proxy_read_timeout` ≥ cmhub→中转站读超时。三个默认值是雷:`AiModel.timeout_seconds` ( `0` 表示按 Provider 分辨率默认值,而不是无限等待)、Gunicorn 默认 `30s` (会直接杀掉跑图片的 worker)、Nginx 默认 `60s` 。图片链路建议统一放到 `300s` 量级,并在 T-302/T-403 前用一次真实图片生成耗时校准。
2026-07-01 17:42:10 +08:00
2. **worker/线程数按峰值总并发预留** :同步下每个在飞请求占住一个 worker 整个生成周期(几十秒~分钟)。用 Gunicorn `gthread` ( `--threads` )或多 sync worker,数量 ≥ 预期峰值总并发 + 余量,避免慢图片把快的生文接口和后台挤死。
适用边界:**单接入方、小并发批量**(桌面端 `concurrency` 1~4)完全够用,点数一致性也更简单。当出现「worker 被长连接占满拖慢快接口/后台」「接入方总并发明显上涨」「需要关窗重连/任务持久化」任一信号时,才转 V2 异步(队列),在此之前保持同步;但适配器接口与 `call_record` 的 `pending/success/failed` 三态需为异步预留口子(见 4.1、七、八)。
## 六、推荐开发顺序
1. Django + DRF 骨架可运行,django-admin 可登录(Phase 0)。
2. 移植并跑通一次 AI 调用(标题 / 图片)原型(Phase 1)。
2026-07-02 16:33:31 +08:00
3. 计费:点数扣减(并发安全)+ 计费规则 + 调用记录(Phase 2, T-201~T-203 已完成)。
2026-07-03 10:34:37 +08:00
4. 对外 API 鉴权 + 余额查询 + 充值下单/回调 + 安全加固(Phase 3,已完成到 T-306)。
2026-07-03 14:49:45 +08:00
5. 用户端注册登录、API Key 管理、个人中心、充值页(Phase 4;T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页与 T-504 充值页已完成)。
2026-07-03 16:36:06 +08:00
6. 运营后台完善(T-401 已完成)、完整验收、部署(Phase 5)。
2026-07-01 17:42:10 +08:00
## 七、项目结构建议
```text
cmhub/
├── docs/
├── manage.py
├── config/ # Django 工程:settings / urls / wsgi
├── apps/
│ ├── users/ # 注册用户 User、UserWallet 钱包、ApiKey(模型 + admin)
2026-07-06 23:18:08 +08:00
│ ├── portal/ # 用户端页面(Django 模板 SSR):公开首页/注册/登录/个人中心/充值/API Key/下载版本
2026-07-01 17:42:10 +08:00
│ ├── 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 与计费归属。
2026-07-06 10:44:13 +08:00
- 用户端页面用 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` **。