Files
cmhub/docs/04-architecture.md
T

53 KiB
Raw Blame History

架构设计

本文讲“怎么把技术栈搭起来”:系统结构、职责划分、数据模型、计费时序、技术难点、开发顺序。 具体用了哪些框架 / 库 / 数据库 / 部署方式,见 技术栈。

一、系统结构

终端用户 ──浏览器/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 共用。图片结果 MVP 先用本地 default_storage 保存到 MEDIA_ROOT/generated/images/... 并返回 image_url;核心阶段通过 URL 构建器生成外部 URL,不依赖 DRF Request;CallRecord 只写 URL / 摘要,不保存 provider raw 或 base64。

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 调上游、保存结果、成功确认或失败退点。任务表记录 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 / 空认证,且只返回公开发布元数据、强制更新标记和文件大小字节数。

计费层(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。
  • 生成接口时序必须是 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

-- 上游模型(具体 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,对外用
  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)
);

-- 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
  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 已实现 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/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 业务动态数据

-- 注册用户(扩展 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
  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)
);

需要说明:

  • 主键自增;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 调用扣点(同步生成)

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.1.1 调用扣点(异步图片任务)

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 充值入账(自助扫码 + 支付回调)

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 点试用额度)

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)。

七、项目结构建议

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。