Files
cmhub/docs/api.md
T

37 KiB
Raw Blame History

API / 模块合约

本文定义 cmhub 对外 HTTP 接口、支付回调合约与 AI 调用模块合约的目标形状。 实现前可细化,但不要在代码里另起一套不兼容接口。schema 变化须同步 04-architecture.md。

通用约定

  • 传输:JSON over HTTPS。
  • 对外鉴权:请求头 Authorization: Bearer <API_KEY>(用户在用户端自助生成的 Key)。
  • 后台鉴权:django-admin 用 Django Session 登录。
  • 编码:UTF-8 JSON。时间:ISO 8601。
  • 未携带有效 API Key 调用生成/余额接口:返回 401。
  • 用户或 Key 被禁用/吊销(disabled/revoked):返回 403。
  • 对外 API 只接受 API Key 认证,不接受 Web session(浏览器带 cookie 也不能调 API,防绕过计费归属)。
  • 例外:客户端下载版本检查接口只返回公开发布元数据,设计为匿名只读接口,不需要 API Key,不读取用户、不扣点。
  • 图片生成提供两条并存路径:旧 POST /api/v1/generate/image 为同步接口,可能耗时较长;T-614 起新增异步任务接口 POST /api/v1/generate/image/tasks + GET /api/v1/generate/image/tasks/{task_id},新版桌面端优先使用异步提交 / 轮询,旧客户端继续走同步接口。
  • 用户端注册使用同一个 User 账本主体;注册邮箱必填且唯一(ACCOUNT_EMAIL_VERIFICATION="none",不做邮箱验证、注册即可用),唯一约束避免同邮箱对应多个点数账户。
  • T-608 起新用户注册成功一次性赠送 100 点试用点数;赠点必须经计费层写入钱包和 PointsLedger(change_type=signup_bonus),不得直接改余额字段。历史用户是否补发不属于默认注册流程。
  • API Key 库内只存 key_hash(SHA-256)与 key_prefix,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。
  • 每次生成调用写 CallRecord;只允许保存 result_ref / result_summary 这类引用或摘要,不保存 provider raw、base64 图片或敏感上游字段。

T-301 已实现对外 API 鉴权基线:apps.api.authentication.ApiKeyAuthentication 只解析 Authorization: Bearer <API_KEY>;生成、余额等外部 API 视图应继承 apps.api.views.ExternalApiView,不接受 Web session。

T-302 已实现生成接口基线:POST /api/v1/generate/title 与 POST /api/v1/generate/image 已接入 API Key 鉴权、别名解析、计费规则、预扣点、Provider 调用、成功确认和失败退点;图片结果当前以本地 MEDIA_ROOT 保存并返回 image_url,后续可替换为对象存储。T-613 起内部实现已抽为生成核心 service,旧同步接口仍保持原字段、状态码和错误语义。T-614 起新增异步生图任务接口,提交阶段同步审核 prompt 和预扣点,worker 复用同一套已预扣执行 / 成功确认 / 失败退点阶段,不复制第二套资金逻辑。

T-614 已实现生图异步任务化:POST /api/v1/generate/image/tasks 返回 202 与公开 UUID task_id,GET /api/v1/generate/image/tasks/{task_id} 轮询任务状态和结果。任务表 ImageGenerationTask 关联 user、api_key 与已预扣 CallRecord,支持 Idempotency-Key 去重、payload 冲突 409 idempotency_conflict、租约 / 心跳 / reaper 处理僵尸 running 任务并幂等退点。异步结果 URL 由 MEDIA_PUBLIC_BASE_URL 或 PUBLIC_BASE_URL 生成,不透传上游临时链接。

T-615 已实现生图接口用量遥测:旧同步 POST /api/v1/generate/image 与新异步提交 POST /api/v1/generate/image/tasks 都会写 cmhub.api.generation_usage 结构化日志,事件名 generation_route_usage。调用方可选带 X-Client-Version 请求头,便于按客户端版本观察旧同步路迁移进度;该请求头不参与鉴权、计费或幂等判断。日志只记录 route/user/key 前缀/客户端版本/别名/状态/耗时/错误码等白名单字段,不记录 API Key 明文、prompt、图片 base64 或 provider raw。

T-303 已实现余额查询基线:GET /api/v1/balance 已接入 API Key 鉴权,返回当前 UserWallet.points_balance,并保留旧字段同时新增不含邮箱的 account 账号展示对象;测试覆盖响应余额与 PointsLedger.points_delta 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。

T-601 已实现可用别名发现:GET /api/v1/models 已接入 API Key 鉴权,只返回当前 active 且具备对应能力的公开别名、能力、是否需要原图和点数单价;接口不调用 AiModel.to_resolved_model(),不解密 provider key,不返回底层 SKU、模型 URL、api_key_encrypted、extra_body 等内部配置,且成功请求不占用生成接口限流额度。

T-304 已实现充值回调基线:POST /api/v1/recharge/callback/wechat 与 /alipay 已 @csrf_exempt,回调先验签(开发/测试可用明确 HMAC mock,生产 PAYMENT_CALLBACK_MODE=sdk 走支付 SDK),再按 order_no 锁定 RechargeOrder 幂等入账;金额或支付方式不一致不加点,重复回调不重复写充值流水。

T-305 已实现扫码充值下单与轮询基线:POST /api/v1/recharge/create 与 GET /api/v1/recharge/status 走用户端 SessionAuthentication + CSRF,不接受 API Key;下单创建 pending 订单并锁定汇率/点数,再返回微信 code_url 或支付宝 qr_code;状态查询只允许订单所属用户访问,并在 pending 时尝试主动查单补入账,查单不可用时保持 pending 等回调。

T-306 已实现对外 API 安全加固:image_url 下载只允许 http / https 公网地址,拒绝私有/回环/链路本地/保留等地址,重定向后重新校验并限制响应大小;全局 DRF 默认认证为空且默认权限为 IsAuthenticated,外部 API 与用户端 API 必须显式 opt-in 认证类;生成接口按 API Key / 用户限流,认证失败按 IP 限流;充值创建有 RECHARGE_MAX_AMOUNT_CNY 单笔上限。

T-604 目标口径:生成接口在 serializer 基础校验后先执行 prompt 本地敏感词检查;命中返回 400 content_blocked,且不得下载 image_url、不得预扣点、不得写 CallRecord / PointsLedger、不得调用上游。T-604 不启用输出审核和图片审核;详细规则见 moderation.md。

T-501/T-608 已实现用户端注册 / 登录基线:/signup /login /logout 走 django-allauth + Django session + CSRF;免邮箱验证、注册即可用(ACCOUNT_EMAIL_VERIFICATION="none",邮箱仍必填且唯一)。注册成功后 allauth adapter 调用 grant_signup_bonus() 一次性发放 100 点注册试用额度,并创建 signup_bonus 流水;注册限流由 allauth signup rate limit 执行,项目显式配置 ACCOUNT_SIGNUP_RATE_LIMIT(默认 20/m/ip)。对外 API 仍只认 API Key,不接受 Web session。(免验证策略见 T-605 与 2026-07-06 决策)

T-502 已实现用户端 API Key 自助管理基线:/apikeys 走 Django session + CSRF;登录用户可生成和删除自己的 Key,生成后的明文只在重定向后的首个页面显示一次,库内只保存 key_hash 与 key_prefix。用户端“删除”落库为 revoked,保留历史记录关联;吊销后的 Key 调用生成 / 余额接口返回 403 account_disabled,缺失、无效或不存在的 Key 仍返回 401 unauthorized。

T-503/T-505/T-608 已实现用户端个人中心 / 记录页基线:/dashboard 走 Django session,展示当前用户剩余点数、充值总额、获得点数、净消耗点数和最近记录;/records/recharge 分页展示当前登录用户的充值订单;/records/usage 分页展示当前登录用户的 signup_bonus / consume / refund 点数流水并关联调用信息。页面只读,不写 UserWallet.points_balance。

T-504/T-505 已实现用户端充值页基线:/recharge 走 Django session + CSRF,GET 展示余额、充值表单、当前订单和最近充值;POST 创建 pending 充值订单并用本地 static 自托管的 qrcode.js 展示支付二维码票据;浏览器轮询 GET /api/v1/recharge/status,订单 paid 后刷新页面重新读取余额。页面不直接加点,到账仍以支付回调或主动查单入账后的本地订单状态为准。若当前 PAYMENT_CALLBACK_MODE=mock,页面必须明确提示二维码为测试票据、不能用微信/支付宝真实支付,避免用户把 mock 二维码当成生产收款码。当前因支付宝可信 IP 未配置,用户端 /recharge 表单暂只开放微信支付,底层 JSON API / 回调 / SDK 路径仍保留支付宝。

T-606 已实现公开首页与客户端下载入口:GET / 匿名返回 200,不再重定向到 /dashboard;匿名用户看到注册 / 登录 / 下载入口,登录用户看到「进入控制台」。下载区读取 DownloadRelease(platform=windows, is_current=True),展示版本、下载按钮、SHA256 和发布说明;external_url 优先于后台上传文件的 file.url。DownloadRelease 不新增对外 JSON API,只由 SSR 首页和 django-admin 使用;生产本地安装包由 Nginx 直接服务 MEDIA_ROOT/downloads/,避免大文件占用 Gunicorn worker。

T-610 已在公开首页下载区新增“下载导入模板”链接:模板由 django-admin 维护 ImportTemplate 当前记录,首页直接渲染公开下载链接;该能力不新增对外 JSON API,不需要 API Key、不读取用户、不扣点。生产本地模板文件复用 Nginx 直接服务 MEDIA_ROOT/import_templates/,避免文件下载占用 Gunicorn worker;external_url 优先于本地文件 URL。

T-607/T-609/T-617 已实现 GET /api/v1/client/releases/latest?platform=windows,给桌面端自动检查更新使用。该接口公开匿名可访问,不需要 API Key,不读取用户、不扣点、不占用生成接口限流;只返回 DownloadRelease 的公开发布元数据。release.force_update 表示该版本是否必须升级,来源于后台 DownloadRelease.force_update,默认 false;release.size_bytes 表示安装包文件大小字节数,来源于后台 DownloadRelease.size_bytes,用于桌面端下载后校验文件大小。

通用错误响应:

{
  "error": {
    "code": "insufficient_points",
    "message": "点数不足,请先充值"
  }
}

错误码枚举(MVP):

code 含义 HTTP
unauthorized 缺失/无效 API Key 401
account_disabled 账号被禁用 403
bad_request 参数错误 400
insufficient_points 点数不足,请先充值 402
no_pricing_rule 未配置对应计费规则 400
model_not_allowed 该模型不支持此操作(如图片模型生成标题) 400
content_blocked 输入内容未通过安全审核;T-604 中表示 prompt 命中本地敏感词,未扣点 400
idempotency_conflict 同一 Idempotency-Key 已用于不同请求 payload 409
task_not_found 图片生成任务不存在或不属于当前 API Key 所属用户 404
task_timeout 异步图片任务租约超时,reaper 已判失败并退点 200(轮询响应内 status=failed)
upstream_timeout 上游 AI 调用超时(已退点) 502
upstream_error 上游 AI 失败(已退点) 502
signature_invalid 支付回调验签失败 400
amount_mismatch 支付回调金额与本地订单金额不一致 400
no_exchange_rate 未配置当前币种汇率,无法创建充值订单 400
payment_order_create_failed 支付平台下单失败 502
order_not_found 充值订单不存在或不属于当前用户 404
rate_limited 请求过于频繁,请稍后再试 429

upstream_timeout 与 upstream_error 都表示本次生成失败且已退点,客户端可按失败 / 重试处理。upstream_timeout 通常来自 T-612 的生图上游硬截止或底层 HTTP 超时。upstream_error 需要按错误消息继续区分:如果 /api/v1/balance 成功、/api/v1/models 中目标别名存在且 pricing_status="priced",但生成接口返回 502 upstream_error 且消息为「上游模型配置不可用」,优先判定为服务端上游模型运行配置问题,不是客户端 payload 问题。常见原因是 AI_KEY_ENCRYPTION_KEY 与入库时不一致、AiModel.api_key_encrypted 无法解密、别名指向的 AiModel 缺 url / model / api_type / API Key,或 api_type 无可用 Provider。该错误路径不应最终扣点;修复按 deployment.md 的 AI 模型配置排查步骤执行。

对外接口

重要约定:model 字段传的是能力别名(如 title-standard / image-hd),不是具体供应商模型名。后台把别名映射到当前的具体模型,换供应商时调用方零改动。供应商特有参数放 parameters,但只允许 Provider 白名单内的安全参数透传;model、n、size、resolution、messages、image* 等核心/计费字段由服务端固定,调用方传入时忽略。

GET /api/v1/client/releases/latest

桌面端检查最新客户端版本。该接口为公开只读接口,不需要 API Key,不关联用户账本。

对接请求 demo、客户端下载处理建议和排查清单已迁入 Obsidian:ila/项目文档/cmhub/cmhub-桌面端版本检查接口对接文档.md。

请求:

GET /api/v1/client/releases/latest?platform=windows

参数:

参数 必填 说明
platform 否 windows / macos / linux;缺省按 windows。非法值返回 400 bad_request

有当前版本时响应:

{
  "platform": "windows",
  "release": {
    "version": "0.1.1",
    "download_url": "https://cm.833729.com/media/downloads/cmhub-desktop-0.1.1.zip",
    "sha256": "64位sha256",
    "release_notes": "优化了ai模块的生图的功能",
    "force_update": true,
    "size_bytes": 45678901,
    "published_at": "2026-07-06T18:00:00+08:00"
  }
}

无当前版本或当前版本没有下载地址时响应:

{
  "platform": "windows",
  "release": null,
  "message": "暂未发布"
}

字段说明:force_update 为布尔值,表示桌面端是否必须升级到该版本;未勾选时返回 false。size_bytes 为整数或 null,单位字节;T-618 起 django-admin 新增 / 编辑客户端发布版本时要求填写 sha256 和 size_bytes,但历史记录或非 admin 导入数据仍可能为空,客户端应只在该值为正整数时校验下载后的本地文件大小。客户端可先做版本号比较,再按 force_update=true 阻止继续使用旧版本或进入强制升级流程,下载完成后同时校验 size_bytes 和 sha256。

要点:接口只查 DownloadRelease(platform, is_current=True);download_url 优先使用 external_url,否则用 file.url 生成绝对 HTTPS URL;published_at MVP 可使用 DownloadRelease.updated_at;force_update 使用后台发布版本上的布尔配置,默认 false;size_bytes 使用后台填写的安装包字节数,可为空以兼容历史记录。响应不得包含本地 MEDIA_ROOT、文件系统路径、后台 ID、is_current、用户信息、API Key、模型配置或任何密钥字段。成功和“暂未发布”均返回 HTTP 200,方便桌面端静默检查;非法平台返回 400 bad_request。无当前版本或当前版本没有下载地址时返回 release:null,不返回独立的 force_update / size_bytes 顶层字段。

POST /api/v1/generate/title

生成标题。请求:

{
  "prompt": "为这件女装生成5个吸睛标题",
  "model": "title-standard",      // 能力别名,非具体模型名;可选,缺省用该操作的默认别名
  "image_url": "https://...",     // 可选,看图生成时传商品图(或 image_base64)
  "resolution": "1K",             // 可选,影响计费规则匹配
  "parameters": {}                // 可选,安全供应商参数;未知字段/核心字段忽略
}

成功响应:

{
  "titles": ["标题一", "标题二", "标题三"],
  "alias": "title-standard",
  "model_used": "gpt-5.5",        // 实际服务的具体模型,便于排障;不构成稳定契约
  "points_cost": 2,
  "points_balance": 98,
  "call_id": 12345
}

要点:别名必须映射到声明 text 能力的模型;映射到图片模型时返回 model_not_allowed;别名无计费规则返回 no_pricing_rule。T-604 后 prompt 命中本地敏感词时返回 content_blocked,不扣点、不写调用记录、不调上游。若传 image_url,服务端只会在 prompt 审核通过后下载公网 http / https 图片,并在扣点前拒绝内网、回环、链路本地、元数据地址、跳转到内网的地址和超过大小上限的响应;被拒绝时返回 bad_request 且不预扣点。

POST /api/v1/generate/image

生成图片(同步等待)。请求:

可选请求头:

X-Client-Version: 0.1.1

该字段仅用于 T-615 的用量遥测,帮助服务端区分旧同步接口由哪些客户端版本调用;不影响响应结构。

{
  "prompt": "把这件衣服换成模特上身的穿搭图",
  "model": "image-hd",            // 能力别名,非具体模型名
  "image_base64": "data:image/png;base64,...",  // 或 image_url;改图类模型(如映射到 gpt-image-2)原图必传
  "resolution": "1K",
  "aspect_ratio": "1:1",
  "parameters": {}                // 可选,安全供应商参数;未知字段/核心字段忽略
}

成功响应:

{
  "image_url": "https://.../result.jpg",   // 默认返回 URL(结果存对象存储);大 base64 不进同步响应体
  "alias": "image-hd",
  "model_used": "nano_banana_2",
  "points_cost": 10,
  "points_balance": 88,
  "call_id": 12346
}

要点:改图类模型缺原图返回 bad_request,不落 500;上游超时返回 upstream_timeout,其他上游失败返回 upstream_error,两者都不最终扣点(已预扣则退回)。T-604 后 prompt 命中本地敏感词时返回 content_blocked,并且不得下载 image_url 或解码大图后再拦截。T-612 后生图上游读取超时会受 AI_IMAGE_UPSTREAM_DEADLINE_SECONDS 硬截止保护,避免旧同步接口长期占用生成池线程。结果默认存对象存储返回 image_url,避免同步响应体过大。请求里的 image_url 适用同标题接口相同的 SSRF 防护和大小上限;内网图片请用 image_base64。

POST /api/v1/generate/image/tasks

提交异步图片生成任务。该接口与旧同步接口共存,参数与 POST /api/v1/generate/image 一致,额外支持请求头 Idempotency-Key。

请求:

POST /api/v1/generate/image/tasks
Authorization: Bearer sk_cmhub_xxx
Idempotency-Key: desktop-job-20260708-0001
X-Client-Version: 0.1.1
Content-Type: application/json
{
  "prompt": "把这件衣服换成模特上身的穿搭图",
  "model": "image-hd",
  "image_base64": "data:image/png;base64,...",
  "resolution": "1K",
  "aspect_ratio": "1:1",
  "parameters": {}
}

成功响应:

{
  "task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9",
  "status": "queued",
  "call_id": 12346,
  "points_cost": 10,
  "points_balance": 88,
  "attempt_count": 0,
  "max_attempts": 3,
  "next_attempt_at": null,
  "created_at": "2026-07-08T21:30:00+08:00",
  "expires_at": "2026-07-09T21:30:00+08:00"
}

要点:

  • 提交阶段同步执行 prompt 审核;命中敏感词返回 400 content_blocked,不建任务、不扣点、不调上游。
  • 提交阶段预扣点数;余额不足返回 402 insufficient_points,不建任务。
  • Idempotency-Key 按当前 API Key 去重。同 key + 同 payload 返回同一 task_id,不重复扣点;同 key + 不同 payload 返回 409 idempotency_conflict。
  • 不保存 provider raw、上游密钥或 image_base64 原文;base64 会解码后作为输入文件引用保存,任务请求快照只保存必要字段和文件引用。
  • 桌面端主链路推荐传 image_base64。该路径在 submit 阶段只做解码和输入文件落盘,不发生外部网络请求,提交请求应保持短耗时。
  • image_url 仍按同步接口的 SSRF 与大小规则处理,并在 submit 阶段下载成输入文件引用;失败不扣点。这个路径可能因远程图片下载变慢而让 submit 阻塞,适合作为边缘兼容能力,不建议桌面端批量生图主流程使用。
  • worker 执行时会复审 prompt 并重解析当前别名 / Provider 配置,但账务使用提交阶段已预扣的 CallRecord.points_cost,不会重复扣点。若排队时间较长且后台切换别名,可能出现按提交时价格预扣、按执行时模型运行;未来如需强一致模型选择,应单独实现提交时模型配置快照。
  • T-616 起异步 worker 对临时性上游失败自动重试,默认最多 3 次上游调用(第 1 次执行 + 2 次重试)。提交阶段仍只预扣一次;重试等待期间任务状态回到 queued,点数暂不退回,最终失败才退款。

GET /api/v1/generate/image/tasks/{task_id}

轮询异步图片生成任务。请求:

GET /api/v1/generate/image/tasks/2bff8217-47a9-44f1-9bd9-82a375e79dc9
Authorization: Bearer sk_cmhub_xxx

排队或执行中:

{
  "task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9",
  "status": "running",
  "call_id": 12346,
  "points_cost": 10,
  "attempt_count": 1,
  "max_attempts": 3,
  "next_attempt_at": null,
  "created_at": "2026-07-08T21:30:00+08:00",
  "updated_at": "2026-07-08T21:30:05+08:00",
  "expires_at": "2026-07-09T21:30:00+08:00"
}

临时性上游失败等待重试时仍返回 queued:

{
  "task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9",
  "status": "queued",
  "call_id": 12346,
  "points_cost": 10,
  "attempt_count": 1,
  "max_attempts": 3,
  "next_attempt_at": "2026-07-08T21:30:20+08:00",
  "created_at": "2026-07-08T21:30:00+08:00",
  "updated_at": "2026-07-08T21:30:10+08:00",
  "expires_at": "2026-07-09T21:30:00+08:00"
}

成功:

{
  "task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9",
  "status": "succeeded",
  "call_id": 12346,
  "points_cost": 10,
  "attempt_count": 3,
  "max_attempts": 3,
  "next_attempt_at": null,
  "created_at": "2026-07-08T21:30:00+08:00",
  "updated_at": "2026-07-08T21:31:40+08:00",
  "expires_at": "2026-07-09T21:30:00+08:00",
  "result": {
    "image_url": "https://cm.833729.com/media/generated/images/2026/07/08/result.png"
  }
}

失败:

{
  "task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9",
  "status": "failed",
  "call_id": 12346,
  "points_cost": 10,
  "attempt_count": 3,
  "max_attempts": 3,
  "next_attempt_at": null,
  "created_at": "2026-07-08T21:30:00+08:00",
  "updated_at": "2026-07-08T21:33:00+08:00",
  "expires_at": "2026-07-09T21:30:00+08:00",
  "error": {
    "code": "task_timeout",
    "message": "图片生成任务超时,已退回点数"
  }
}

要点:查询只允许任务所属 user 与当前 API Key 所属 user 一致,跨用户返回 404 task_not_found;succeeded 重复查询必须返回同一个 image_url;queued 且 next_attempt_at 非空表示正在等待自动重试,客户端继续轮询即可;failed 表示最终失败且已退款或未扣款,不需要客户端再请求退款。任务元数据默认保留 IMAGE_TASK_RETENTION_HOURS(默认 24h),结果图片保留窗口按 GENERATED_IMAGE_RETENTION_HOURS(默认 72h)管理;本任务暂不暴露 cancel 路由。

GET /api/v1/balance

查询当前账号点数余额。成功响应:

{
  "user": "demo-client",
  "points_balance": 88,
  "account": {
    "username": "demo-client",
    "display_name": "主账号"
  }
}

要点:user 与 points_balance 是旧客户端兼容字段;account 供桌面端测试连接时展示账号信息。account.display_name 取用户姓名,未设置时回退用户名;不返回邮箱、数据库用户 ID 或其他个人敏感字段。

GET /api/v1/models

查询当前可调用的能力别名。成功响应:

{
  "models": [
    {
      "alias": "title-standard",
      "operation_type": "title",
      "capabilities": ["text"],
      "requires_image": false,
      "pricing_status": "priced",
      "prices": [
        {"resolution": "default", "points_cost": 2}
      ]
    },
    {
      "alias": "image-edit",
      "operation_type": "image",
      "capabilities": ["image", "vision"],
      "requires_image": true,
      "pricing_status": "unpriced",
      "prices": []
    }
  ]
}

要点:只列 ModelAlias.is_active=True 且 ai_model.is_active=True,并按操作类型校验模型声明能力。缺定价规则不报错,pricing_status="unpriced" 且 prices=[],用户端页面显示「暂未定价」。该接口返回的是公开能力别名,不返回底层供应商模型名、模型 URL、provider key、api_key_encrypted、extra_body 或其他原始配置;实现不得依赖 AI_KEY_ENCRYPTION_KEY。未来接入 account_alias_permission 后,本接口应只列当前 API Key 所属用户被授权的别名。

计费模块合约(apps.billing)

T-202 后,计费计算已有独立模块;T-203 后,扣点/退点也收敛到 billing 层,供后续生成接口和充值下单调用:

calculate_points_cost(operation_type: str, alias: str, resolution: str | None = None) -> int
quote_recharge_points(amount, currency: str = "CNY", at=None) -> RechargeQuote
calculate_points_granted(amount, currency: str = "CNY", at=None) -> int
create_recharge_order(..., user, amount, pay_method: str, currency: str = "CNY") -> RechargeOrder
grant_signup_bonus(user, points: int = 100) -> SignupBonusGrantResult
precharge_call(..., user, points_cost: int, operation_type: str, alias: str, ...) -> CallCharge
mark_call_success(call_record: CallRecord, ...) -> CallRecord
refund_call_points(call_record: CallRecord, ...) -> RefundResult
apply_recharge_payment(payment: RechargePayment) -> RechargeResult
query_and_apply_recharge_payment(order_no: str, query_func) -> RechargeResult

要点:

  • PricingRule 按 operation_type + alias + resolution 查 active 规则;resolution 先做大小写归一,优先匹配精确分辨率,再回退到空 resolution 的默认价。
  • 定价绑定能力别名字符串,不绑定 AiModel 或上游 SKU;后台切换 ModelAlias 指向的底层模型,不改变该别名的价格。
  • 缺计费规则抛 NoPricingRuleError(code="no_pricing_rule"),API 层应翻译为上方同名错误码。
  • ExchangeRate 按 currency + effective_from 取当前 active 汇率;充值下单时应锁定当时的 exchange_rate / points_granted 到订单,回调入账不得按新汇率重算。
  • 金额换点数采用 floor(amount * points_per_unit),点数为整数。
  • grant_signup_bonus() 是注册赠点唯一入口:只对当前用户首次成功发放 100 点,锁定 / 创建 UserWallet,写 PointsLedger(change_type=signup_bonus, points_delta=+100),并用 MySQL 兼容的唯一幂等标记防重复发放。
  • create_recharge_order() 创建 RechargeOrder(status=pending) 并绑定发起用户;写入订单创建时的金额、币种、汇率、预计点数,再调用支付网关下单取二维码票据并回填 code_url / expires_at;支付平台下单失败时订单标记 failed。
  • precharge_call() 使用事务 + select_for_update() 锁 UserWallet 行;余额不足抛 InsufficientPointsError(code="insufficient_points"),不创建 CallRecord、不写 PointsLedger、不调上游。
  • 预扣成功后写 CallRecord(status=pending) 与 PointsLedger(change_type=consume, points_delta=-N);上游成功只更新调用记录,余额不再变化。
  • 上游失败调用 refund_call_points():同一 CallRecord 只写一条 refund 流水,重复调用不会重复加点;成功调用不能走失败退点。
  • 已验签支付回调调用 apply_recharge_payment():按 order_no 锁定 RechargeOrder,校验金额和通道,锁 UserWallet 加点并写 PointsLedger(change_type=recharge, ref_order_id=order.id);重复回调直接返回已处理结果,不重复加点。

支付充值(自助扫码:微信 V3 native + 支付宝当面付)

协议对齐同支付系统的既有 PHP 实现(微信库 wechatpayv3 / 支付宝库 python-alipay-sdk)。二维码是平台不透明票据、不含业务数据,靠 out_trade_no(=本地 order_no) 在回调关联,付款人由平台识别。协议已明确,仅商户密钥/证书为真实值待提供。 金额单位陷阱:微信传「分」(元×100 取整),支付宝传「元」(两位小数字符串)。

轮询 / 主动查单

前端展示二维码后每 ~1s 轮询 GET /api/v1/recharge/status?order_no=...(session)返回订单 status;到账以异步回调为权威。回调可能丢失,服务端应提供主动查单兜底(按 order_no 向平台查单后补入账)。

POST /api/v1/recharge/callback/wechat

微信 V3 异步回调(支付网关服务端调,必须 @csrf_exempt)。处理合约:

  1. SDK callback(headers, body) 验签 + 解密;失败 → signature_invalid,不入账、记可疑回调。
  2. event_type == TRANSACTION.SUCCESS 且 resource.trade_state == SUCCESS:按 out_trade_no 定位订单。
  3. 幂等:select_for_update 锁订单,status != pending 直接返回成功、不重复加点。
  4. 入账:校验回调金额与本地订单金额一致;使用订单创建时锁定的 exchange_rate / points_granted,锁 user_wallet 加点、写 points_ledger(recharge)、订单置 paid(记 transaction_id、success_time)。
  5. 返回 {"code":"SUCCESS","message":"成功"}(微信要求 HTTP 200)。

POST /api/v1/recharge/callback/alipay

支付宝当面付异步回调(必须 @csrf_exempt;原 PHP 未实现,本项目须补全)。处理合约:

  1. SDK verify(data, sign) 验签;失败 → 不入账、记可疑回调。
  2. trade_status ∈ {TRADE_SUCCESS, TRADE_FINISHED}:按 out_trade_no 定位订单,同上幂等 + 入账(记 trade_no、gmt_payment)。
  3. 返回纯文本 success(支付宝要求)。

订单状态机 pending → paid(成功)/ failed / expired。待提供的仅是商户配置真实值:微信 appid/mchid/apiv3_key/cert_serial/private_key(证书)、支付宝 appid/app_private_key/alipay_public_key/RSA2、各 notify_url(公网)。到位前用 mock 联调并标注待替换。

POST /api/v1/recharge/create

由已登录用户在用户端发起充值(MVP 必做)。创建 recharge_order(pending, 绑定 user) 时锁定当前汇率并计算预计到账点数,再向支付平台下单,返回支付二维码。请求:

{
  "amount": "100.00",
  "pay_method": "weixin"          // weixin | alipay
}

成功响应:

{
  "order_no": "T202606291230001234",
  "amount": "100.00",
  "currency": "CNY",
  "exchange_rate": "10.0000",
  "points_granted": 1000,
  "pay_method": "weixin",
  "status": "pending",
  "code_url": "weixin://wxpay/bizpayurl?pr=abc123",  // 微信 native 返回;支付宝为 qr_code(https://qr.alipay.com/...)
  "expires_at": "2026-06-29T12:10:00Z",               // 二维码有效期
  "paid_at": null,
  "is_expired": false
}
  • 下单字段:out_trade_no(=order_no)、amount(微信分=元×100 取整 / 支付宝元字符串)、description、notify_url。
  • exchange_rate 与 points_granted 以订单创建时的配置为准;回调入账使用订单值,不因后台后续改汇率而变化。
  • 微信走 pay/transactions/native 取 code_url;支付宝走 trade.precreate 取 qr_code;前端用 qrcode.js 渲染。
  • 走 Web session 鉴权(用户端流程),不同于对外 API Key;订单绑定发起用户,防充错账户。
  • T-504 的 /recharge 页面使用同一 create_recharge_order() 计费层入口创建订单;JSON API 仍保留给用户端脚本或后续前端调用。
  • 开发 / 测试 PAYMENT_CALLBACK_MODE=mock 时返回 mock 二维码票据(如 weixin://wxpay/cmhub-mock... / https://qr.alipay.com/cmhub-mock...),只用于联调订单创建、页面渲染和轮询,不会跳转到真实微信/支付宝收银台;用户端充值页应展示醒目 mock 提示。生产应使用 sdk 模式与真实商户配置。
  • 单笔金额超过 RECHARGE_MAX_AMOUNT_CNY 会返回 bad_request,不创建 RechargeOrder。

GET /api/v1/recharge/status?order_no=...

由已登录用户轮询充值订单状态。只返回当前登录用户自己的订单;不属于当前用户的订单按不存在处理。

成功响应:

{
  "order_no": "T202606291230001234",
  "amount": "100.00",
  "currency": "CNY",
  "exchange_rate": "10.0000",
  "points_granted": 1000,
  "pay_method": "weixin",
  "status": "paid",
  "code_url": "weixin://wxpay/bizpayurl?pr=abc123",
  "expires_at": "2026-06-29T12:10:00Z",
  "paid_at": "2026-06-29T12:05:00Z",
  "is_expired": false
}
  • 若订单仍为 pending,服务端会尝试主动查单并复用 query_and_apply_recharge_payment() 补入账;查单不可用或尚未支付时仍返回当前本地状态。
  • 到账以服务端回调或主动查单后的本地订单状态为准;is_expired 仅是二维码本地有效期提示,不自动阻断延迟到达的真实支付回调。

AI 调用模块合约(apps/ai)

分两层:别名解析 + Provider 适配器。API 层只传别名,由本模块解析到具体模型并选适配器。

别名解析

# alias -> 具体 AiModel(含 capabilities、解密后的 key);找不到/能力不符抛业务异常
resolve_alias(operation_type: str, alias: str | None) -> ResolvedModel

T-102 后,别名解析已接入数据库表 AiModel / ModelAlias:每次调用读取当前 active 记录,缺省 alias=None 时取该 operation_type 的默认别名;标题要求 text 能力,图片要求 image 能力。密钥以 AiModel.api_key_encrypted 存储,使用 Fernet 解密后进入 ResolvedModel。

从 cmbot 形状导入配置的管理命令:

py -3.12 manage.py import_ai_models path\to\ai_models.json --create-default-aliases

导入前必须配置有效 AI_KEY_ENCRYPTION_KEY。不要把真实 ai_models.json 或真实上游 key 提交到 git。

Provider 适配器接口

每个供应商一个适配器,按 api_type 注册;移植自 cmbot 的调用逻辑落到各适配器内:

class Provider(Protocol):
    def capabilities(self) -> set[str]: ...           # {"text","image","vision"}
    def generate_text(self, prompt: str, model: ResolvedModel,
                      image: bytes | None = None,
                      image_mime_type: str = "image/png",
                      resolution: str = "1K",
                      parameters: dict | None = None) -> TextGenerationResult: ...
    def generate_image(self, prompt: str, model: ResolvedModel,
                       image: bytes | None = None,
                       image_mime_type: str = "image/png",
                       image_filename: str = "image.png",
                       resolution: str = "1K", aspect_ratio: str = "1:1",
                       parameters: dict | None = None) -> ImageGenerationResult: ...

要点:

  • ResolvedModel 来自数据库 AiModel(形状同 cmbot/config/ai_models.json,外加 capabilities;api_key 在库中以 Fernet 加密,使用时解密,不落明文)。
  • TextGenerationResult 包含 text、清洗后的 titles、model_used、raw;ImageGenerationResult 包含图片 bytes、model_used、raw。raw 仅供本次解析/排障摘要使用,调用记录不得整包保存或打印,避免 base64 大图和上游敏感字段入库;图片落对象存储并返回 URL 属 T-302 之后的 API 编排职责。
  • 适配器按 api_type(chat/gemini/images/images_edits/auto)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。
  • parameters 为供应商特有参数的安全子集;适配器负责白名单过滤并把统一入参翻译成各家上游格式,核心字段不可被 parameters 或 extra_body 覆盖。
  • 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。
  • 配置审计:后台保存/删除 AiModel、ModelAlias 时写 AiConfigAuditLog;记录 actor/action/target/changed_fields/changes/created_at,admin 只读查看;密钥变更只记录 empty/set 状态。
  • 区分异常:别名/能力不匹配 → 业务错误(400 类,如 model_not_allowed);上游超时 → upstream_timeout(502,触发退点);其他上游网络/服务错误 → upstream_error(502,触发退点)。
  • 不在本模块写点数逻辑,只负责解析、调上游与解析返回。

待实现时确认

  • 支付商户真实配置:微信 appid/mchid/apiv3_key/证书/notify_url,支付宝 appid/应用私钥/支付宝公钥/notify_url。协议字段、验签方式与成功应答已按同系统实现明确;缺真实配置时用 mock。
  • 汇率与各操作/别名(+ 分辨率档)的点数单价。
  • 图片结果存储:对象存储选型与 image_url 生成(默认走存储返回 URL)。
  • 图片模型调用机制:gpt-image-2 走 images/edits(改图,原图必传)、nano-banana2 走 chat/completions 多模态返图(需自定义解析),两者非标准 images/generations。图片返回结构(URL / base64 / 位置)首次对接抓真实响应确认后再定适配器解析。
  • 别名命名规范、默认别名、是否按账号授权可用别名(account_alias_permission)。
  • 参数校验细则、限流策略、API Key 轮换机制。