2026-07-01 17:42:10 +08:00
# 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,防绕过计费归属)。
2026-07-07 08:33:49 +08:00
- 例外:客户端下载版本检查接口只返回公开发布元数据,设计为匿名只读接口,不需要 API Key,不读取用户、不扣点。
2026-07-01 17:42:10 +08:00
- 图片生成为**同步**接口,可能耗时较长,调用方与网关需设置足够超时(≥ 300s)。
2026-07-06 10:44:13 +08:00
- 用户端注册使用同一个 `User` 账本主体;注册邮箱**必填且唯一**(`ACCOUNT_EMAIL_VERIFICATION="none"` ,**不做邮箱验证**、注册即可用),唯一约束避免同邮箱对应多个点数账户。
2026-07-08 14:19:00 +08:00
- T-608 起新用户注册成功一次性赠送 **100 点**试用点数;赠点必须经计费层写入钱包和 `PointsLedger(change_type=signup_bonus)` ,不得直接改余额字段。历史用户是否补发不属于默认注册流程。
2026-07-02 15:06:00 +08:00
- API Key 库内只存 `key_hash` ( SHA-256)与 `key_prefix` ,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。
- 每次生成调用写 `CallRecord` ;只允许保存 `result_ref` / `result_summary` 这类引用或摘要,不保存 provider `raw` 、base64 图片或敏感上游字段。
2026-07-01 17:42:10 +08:00
2026-07-02 17:40:26 +08:00
T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthentication` 只解析 `Authorization: Bearer <API_KEY>` ;生成、余额等外部 API 视图应继承 `apps.api.views.ExternalApiView` ,不接受 Web session。
2026-07-02 22:41:37 +08:00
T-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api/v1/generate/image` 已接入 API Key 鉴权、别名解析、计费规则、预扣点、Provider 调用、成功确认和失败退点;图片结果当前以本地 `MEDIA_ROOT` 保存并返回 `image_url` ,后续可替换为对象存储。
2026-07-06 15:45:48 +08:00
T-303 已实现余额查询基线:`GET /api/v1/balance` 已接入 API Key 鉴权,返回当前 `UserWallet.points_balance` ,并保留旧字段同时新增不含邮箱的 `account` 账号展示对象;测试覆盖响应余额与 `PointsLedger.points_delta` 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。
2026-07-03 08:36:30 +08:00
2026-07-04 10:14:16 +08:00
T-601 已实现可用别名发现:`GET /api/v1/models` 已接入 API Key 鉴权,只返回当前 active 且具备对应能力的公开别名、能力、是否需要原图和点数单价;接口不调用 `AiModel.to_resolved_model()` ,不解密 provider key,不返回底层 SKU、模型 URL、`api_key_encrypted` 、`extra_body` 等内部配置,且成功请求不占用生成接口限流额度。
2026-07-03 09:07:21 +08:00
T-304 已实现充值回调基线:`POST /api/v1/recharge/callback/wechat` 与 `/alipay` 已 `@csrf_exempt` ,回调先验签(开发/测试可用明确 HMAC mock,生产 `PAYMENT_CALLBACK_MODE=sdk` 走支付 SDK),再按 `order_no` 锁定 `RechargeOrder` 幂等入账;金额或支付方式不一致不加点,重复回调不重复写充值流水。
2026-07-03 09:34:07 +08:00
T-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create` 与 `GET /api/v1/recharge/status` 走用户端 `SessionAuthentication + CSRF` ,不接受 API Key;下单创建 pending 订单并锁定汇率/点数,再返回微信 `code_url` 或支付宝 `qr_code` ;状态查询只允许订单所属用户访问,并在 pending 时尝试主动查单补入账,查单不可用时保持 pending 等回调。
2026-07-03 10:34:37 +08:00
T-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `https` 公网地址,拒绝私有/回环/链路本地/保留等地址,重定向后重新校验并限制响应大小;全局 DRF 默认认证为空且默认权限为 `IsAuthenticated` ,外部 API 与用户端 API 必须显式 opt-in 认证类;生成接口按 API Key / 用户限流,认证失败按 IP 限流;充值创建有 `RECHARGE_MAX_AMOUNT_CNY` 单笔上限。
2026-07-06 10:44:13 +08:00
T-604 目标口径:生成接口在 serializer 基础校验后先执行 prompt 本地敏感词检查;命中返回 `400 content_blocked` ,且不得下载 `image_url` 、不得预扣点、不得写 `CallRecord` / `PointsLedger` 、不得调用上游。T-604 不启用输出审核和图片审核;详细规则见 [`moderation.md` ](moderation.md )。
2026-07-08 15:13:23 +08:00
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 决策)
2026-07-03 11:27:45 +08:00
2026-07-03 11:51:46 +08:00
T-502 已实现用户端 API Key 自助管理基线:`/apikeys` 走 Django session + CSRF;登录用户可生成和删除自己的 Key,生成后的明文只在重定向后的首个页面显示一次,库内只保存 `key_hash` 与 `key_prefix` 。用户端“删除”落库为 `revoked` ,保留历史记录关联;吊销后的 Key 调用生成 / 余额接口返回 `403 account_disabled` ,缺失、无效或不存在的 Key 仍返回 `401 unauthorized` 。
2026-07-08 15:13:23 +08:00
T-503/T-505/T-608 已实现用户端个人中心 / 记录页基线:`/dashboard` 走 Django session,展示当前用户剩余点数、充值总额、获得点数、净消耗点数和最近记录;`/records/recharge` 分页展示当前登录用户的充值订单;`/records/usage` 分页展示当前登录用户的 `signup_bonus` / `consume` / `refund` 点数流水并关联调用信息。页面只读,不写 `UserWallet.points_balance` 。
2026-07-03 14:00:00 +08:00
2026-07-06 08:56:30 +08:00
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 路径仍保留支付宝。
2026-07-03 14:49:45 +08:00
2026-07-06 23:18:08 +08:00
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。
2026-07-08 15:38:59 +08:00
T-607/T-609 已实现 `GET /api/v1/client/releases/latest?platform=windows` ,给桌面端自动检查更新使用。该接口公开匿名可访问,不需要 API Key,不读取用户、不扣点、不占用生成接口限流;只返回 `DownloadRelease` 的公开发布元数据。T-609 起响应的 `release.force_update` 表示该版本是否必须升级,来源于后台 `DownloadRelease.force_update` ,默认 `false` 。
2026-07-07 08:33:49 +08:00
2026-07-01 17:42:10 +08:00
通用错误响应:
```json
{
"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 |
2026-07-06 10:44:13 +08:00
| `content_blocked` | 输入内容未通过安全审核;T-604 中表示 prompt 命中本地敏感词,未扣点 | 400 |
2026-07-01 17:42:10 +08:00
| `upstream_error` | 上游 AI 失败(已退点) | 502 |
| `signature_invalid` | 支付回调验签失败 | 400 |
| `amount_mismatch` | 支付回调金额与本地订单金额不一致 | 400 |
2026-07-03 09:34:07 +08:00
| `no_exchange_rate` | 未配置当前币种汇率,无法创建充值订单 | 400 |
| `payment_order_create_failed` | 支付平台下单失败 | 502 |
| `order_not_found` | 充值订单不存在或不属于当前用户 | 404 |
2026-07-03 10:34:37 +08:00
| `rate_limited` | 请求过于频繁,请稍后再试 | 429 |
2026-07-01 17:42:10 +08:00
2026-07-06 16:51:18 +08:00
`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` ](deployment.md ) 的 AI 模型配置排查步骤执行。
2026-07-01 17:42:10 +08:00
## 对外接口
2026-07-02 14:47:22 +08:00
> **重要约定**:`model` 字段传的是**能力别名**(如 `title-standard` / `image-hd`),不是具体供应商模型名。后台把别名映射到当前的具体模型,换供应商时调用方零改动。供应商特有参数放 `parameters`,但只允许 Provider 白名单内的安全参数透传;`model`、`n`、`size`、`resolution`、`messages`、`image*` 等核心/计费字段由服务端固定,调用方传入时忽略。
2026-07-01 17:42:10 +08:00
2026-07-07 08:33:49 +08:00
### `GET /api/v1/client/releases/latest`
桌面端检查最新客户端版本。该接口为公开只读接口,不需要 API Key,不关联用户账本。
2026-07-07 09:34:20 +08:00
对接请求 demo、客户端下载处理建议和排查清单已迁入 Obsidian:`ila/项目文档/cmhub/cmhub-桌面端版本检查接口对接文档.md` 。
2026-07-07 08:38:07 +08:00
2026-07-07 08:33:49 +08:00
请求:
```http
GET /api/v1/client/releases/latest?platform=windows
```
参数:
| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `platform` | 否 | `windows` / `macos` / `linux` ;缺省按 `windows` 。非法值返回 `400 bad_request` |
有当前版本时响应:
```json
{
"platform" : "windows" ,
"release" : {
2026-07-08 15:38:59 +08:00
"version" : "0.1.1" ,
"download_url" : "https://cm.833729.com/media/downloads/cmhub-desktop-0.1.1.zip" ,
2026-07-07 08:33:49 +08:00
"sha256" : "64位sha256" ,
2026-07-08 15:38:59 +08:00
"release_notes" : "优化了ai模块的生图的功能" ,
"force_update" : true ,
2026-07-07 08:33:49 +08:00
"published_at" : "2026-07-06T18:00:00+08:00"
}
}
```
无当前版本或当前版本没有下载地址时响应:
```json
{
"platform" : "windows" ,
"release" : null ,
"message" : "暂未发布"
}
```
2026-07-08 15:38:59 +08:00
字段说明:`force_update` 为布尔值,表示桌面端是否必须升级到该版本;未勾选时返回 `false` 。客户端可先做版本号比较,再按 `force_update=true` 阻止继续使用旧版本或进入强制升级流程。
要点:接口只查 `DownloadRelease(platform, is_current=True)` ; `download_url` 优先使用 `external_url` ,否则用 `file.url` 生成绝对 HTTPS URL; `published_at` MVP 可使用 `DownloadRelease.updated_at` ; `force_update` 使用后台发布版本上的布尔配置,默认 `false` 。响应不得包含本地 `MEDIA_ROOT` 、文件系统路径、后台 ID、`is_current` 、用户信息、API Key、模型配置或任何密钥字段。成功和“暂未发布”均返回 HTTP 200,方便桌面端静默检查;非法平台返回 `400 bad_request` 。无当前版本或当前版本没有下载地址时返回 `release:null` ,不返回独立的 `force_update` 顶层字段。
2026-07-07 08:33:49 +08:00
2026-07-01 17:42:10 +08:00
### `POST /api/v1/generate/title`
生成标题。请求:
```json
{
"prompt" : "为这件女装生成5个吸睛标题" ,
"model" : "title-standard" , // 能力别名,非具体模型名;可选,缺省用该操作的默认别名
"image_url" : "https://..." , // 可选,看图生成时传商品图(或 image_base64)
"resolution" : "1K" , // 可选,影响计费规则匹配
2026-07-02 14:47:22 +08:00
"parameters" : {} // 可选,安全供应商参数;未知字段/核心字段忽略
2026-07-01 17:42:10 +08:00
}
```
成功响应:
```json
{
"titles" : [ "标题一" , "标题二" , "标题三" ],
"alias" : "title-standard" ,
"model_used" : "gpt-5.5" , // 实际服务的具体模型,便于排障;不构成稳定契约
"points_cost" : 2 ,
"points_balance" : 98 ,
"call_id" : 12345
}
```
2026-07-06 10:44:13 +08:00
要点:别名必须映射到声明 `text` 能力的模型;映射到图片模型时返回 `model_not_allowed` ;别名无计费规则返回 `no_pricing_rule` 。T-604 后 prompt 命中本地敏感词时返回 `content_blocked` ,不扣点、不写调用记录、不调上游。若传 `image_url` ,服务端只会在 prompt 审核通过后下载公网 `http` / `https` 图片,并在扣点前拒绝内网、回环、链路本地、元数据地址、跳转到内网的地址和超过大小上限的响应;被拒绝时返回 `bad_request` 且不预扣点。
2026-07-01 17:42:10 +08:00
### `POST /api/v1/generate/image`
生成图片(同步等待)。请求:
```json
{
"prompt" : "把这件衣服换成模特上身的穿搭图" ,
"model" : "image-hd" , // 能力别名,非具体模型名
"image_base64" : "data:image/png;base64,..." , // 或 image_url;改图类模型(如映射到 gpt-image-2)原图必传
"resolution" : "1K" ,
"aspect_ratio" : "1:1" ,
2026-07-02 14:47:22 +08:00
"parameters" : {} // 可选,安全供应商参数;未知字段/核心字段忽略
2026-07-01 17:42:10 +08:00
}
```
成功响应:
```json
{
"image_url" : "https://.../result.jpg" , // 默认返回 URL(结果存对象存储);大 base64 不进同步响应体
"alias" : "image-hd" ,
"model_used" : "nano_banana_2" ,
"points_cost" : 10 ,
"points_balance" : 88 ,
"call_id" : 12346
}
```
2026-07-06 10:44:13 +08:00
要点:改图类模型缺原图返回 `bad_request` ,不落 500;上游失败返回 `upstream_error` 且**不扣点**(已预扣则退回)。T-604 后 prompt 命中本地敏感词时返回 `content_blocked` ,并且不得下载 `image_url` 或解码大图后再拦截。结果默认存对象存储返回 `image_url` ,避免同步响应体过大。请求里的 `image_url` 适用同标题接口相同的 SSRF 防护和大小上限;内网图片请用 `image_base64` 。
2026-07-01 17:42:10 +08:00
### `GET /api/v1/balance`
查询当前账号点数余额。成功响应:
```json
{
"user" : "demo-client" ,
2026-07-06 15:45:48 +08:00
"points_balance" : 88 ,
"account" : {
"username" : "demo-client" ,
"display_name" : "主账号"
}
2026-07-01 17:42:10 +08:00
}
```
2026-07-06 15:45:48 +08:00
要点:`user` 与 `points_balance` 是旧客户端兼容字段;`account` 供桌面端测试连接时展示账号信息。`account.display_name` 取用户姓名,未设置时回退用户名;不返回邮箱、数据库用户 ID 或其他个人敏感字段。
2026-07-04 10:14:16 +08:00
### `GET /api/v1/models`
查询当前可调用的能力别名。成功响应:
```json
{
"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 所属用户被授权的别名。
2026-07-02 16:33:31 +08:00
## 计费模块合约(`apps.billing`)
2026-07-02 15:28:19 +08:00
2026-07-02 16:33:31 +08:00
T-202 后,计费计算已有独立模块;T-203 后,扣点/退点也收敛到 billing 层,供后续生成接口和充值下单调用:
2026-07-02 15:28:19 +08:00
```python
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
2026-07-03 09:34:07 +08:00
create_recharge_order ( ... , user , amount , pay_method : str , currency : str = "CNY" ) -> RechargeOrder
2026-07-08 14:19:00 +08:00
grant_signup_bonus ( user , points : int = 100 ) -> SignupBonusGrantResult
2026-07-02 16:33:31 +08:00
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
2026-07-03 09:07:21 +08:00
apply_recharge_payment ( payment : RechargePayment ) -> RechargeResult
query_and_apply_recharge_payment ( order_no : str , query_func ) -> RechargeResult
2026-07-02 15:28:19 +08:00
```
要点:
- `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)` ,点数为整数。
2026-07-08 14:19:00 +08:00
- `grant_signup_bonus()` 是注册赠点唯一入口:只对当前用户首次成功发放 100 点,锁定 / 创建 `UserWallet` ,写 `PointsLedger(change_type=signup_bonus, points_delta=+100)` ,并用 MySQL 兼容的唯一幂等标记防重复发放。
2026-07-03 09:34:07 +08:00
- `create_recharge_order()` 创建 `RechargeOrder(status=pending)` 并绑定发起用户;写入订单创建时的金额、币种、汇率、预计点数,再调用支付网关下单取二维码票据并回填 `code_url` / `expires_at` ;支付平台下单失败时订单标记 `failed` 。
2026-07-02 16:33:31 +08:00
- `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` 流水,重复调用不会重复加点;成功调用不能走失败退点。
2026-07-03 09:07:21 +08:00
- 已验签支付回调调用 `apply_recharge_payment()` :按 `order_no` 锁定 `RechargeOrder` ,校验金额和通道,锁 `UserWallet` 加点并写 `PointsLedger(change_type=recharge, ref_order_id=order.id)` ;重复回调直接返回已处理结果,不重复加点。
2026-07-02 15:28:19 +08:00
2026-07-01 17:42:10 +08:00
## 支付充值(自助扫码:微信 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)` 时锁定当前汇率并计算预计到账点数,再向支付平台下单,返回**支付二维码**。请求:
```json
{
"amount" : "100.00" ,
"pay_method" : "weixin" // weixin | alipay
}
```
成功响应:
```json
{
"order_no" : "T202606291230001234" ,
"amount" : "100.00" ,
2026-07-03 09:34:07 +08:00
"currency" : "CNY" ,
"exchange_rate" : "10.0000" ,
2026-07-01 17:42:10 +08:00
"points_granted" : 1000 ,
"pay_method" : "weixin" ,
2026-07-03 09:34:07 +08:00
"status" : "pending" ,
2026-07-01 17:42:10 +08:00
"code_url" : "weixin://wxpay/bizpayurl?pr=abc123" , // 微信 native 返回;支付宝为 qr_code(https://qr.alipay.com/...)
2026-07-03 09:34:07 +08:00
"expires_at" : "2026-06-29T12:10:00Z" , // 二维码有效期
"paid_at" : null ,
"is_expired" : false
2026-07-01 17:42:10 +08:00
}
```
- 下单字段:`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;订单绑定发起用户,防充错账户。
2026-07-03 14:49:45 +08:00
- T-504 的 `/recharge` 页面使用同一 `create_recharge_order()` 计费层入口创建订单;JSON API 仍保留给用户端脚本或后续前端调用。
2026-07-06 08:56:30 +08:00
- 开发 / 测试 `PAYMENT_CALLBACK_MODE=mock` 时返回 mock 二维码票据(如 `weixin://wxpay/cmhub-mock...` / `https://qr.alipay.com/cmhub-mock...` ),只用于联调订单创建、页面渲染和轮询,不会跳转到真实微信/支付宝收银台;用户端充值页应展示醒目 mock 提示。生产应使用 `sdk` 模式与真实商户配置。
2026-07-03 10:34:37 +08:00
- 单笔金额超过 `RECHARGE_MAX_AMOUNT_CNY` 会返回 `bad_request` ,不创建 `RechargeOrder` 。
2026-07-03 09:34:07 +08:00
### `GET /api/v1/recharge/status?order_no=...`
由**已登录用户**轮询充值订单状态。只返回当前登录用户自己的订单;不属于当前用户的订单按不存在处理。
成功响应:
```json
{
"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` 仅是二维码本地有效期提示,不自动阻断延迟到达的真实支付回调。
2026-07-01 17:42:10 +08:00
## AI 调用模块合约(`apps/ai`)
分两层:**别名解析** + **Provider 适配器** 。API 层只传别名,由本模块解析到具体模型并选适配器。
### 别名解析
```python
# alias -> 具体 AiModel(含 capabilities、解密后的 key);找不到/能力不符抛业务异常
resolve_alias ( operation_type : str , alias : str | None ) -> ResolvedModel
```
2026-07-02 11:07:44 +08:00
T-102 后,别名解析已接入数据库表 `AiModel` / `ModelAlias` :每次调用读取当前 active 记录,缺省 `alias=None` 时取该 `operation_type` 的默认别名;标题要求 `text` 能力,图片要求 `image` 能力。密钥以 `AiModel.api_key_encrypted` 存储,使用 Fernet 解密后进入 `ResolvedModel` 。
从 `cmbot` 形状导入配置的管理命令:
```bash
py -3.12 manage.py import_ai_models path\t o\a i_models.json --create-default-aliases
```
导入前必须配置有效 `AI_KEY_ENCRYPTION_KEY` 。不要把真实 `ai_models.json` 或真实上游 key 提交到 git。
2026-07-01 17:42:10 +08:00
### Provider 适配器接口
每个供应商一个适配器,按 `api_type` 注册;移植自 cmbot 的调用逻辑落到各适配器内:
```python
class Provider ( Protocol ):
def capabilities ( self ) -> set [ str ]: ... # {"text","image","vision"}
def generate_text ( self , prompt : str , model : ResolvedModel ,
image : bytes | None = None ,
2026-07-02 10:33:15 +08:00
image_mime_type : str = "image/png" ,
2026-07-01 17:42:10 +08:00
resolution : str = "1K" ,
2026-07-02 10:33:15 +08:00
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" ,
2026-07-01 17:42:10 +08:00
resolution : str = "1K" , aspect_ratio : str = "1:1" ,
2026-07-02 10:33:15 +08:00
parameters : dict | None = None ) -> ImageGenerationResult : ...
2026-07-01 17:42:10 +08:00
```
要点:
2026-07-02 11:07:44 +08:00
- `ResolvedModel` 来自数据库 AiModel(形状同 `cmbot/config/ai_models.json` ,外加 `capabilities` ; `api_key` 在库中以 Fernet 加密,使用时解密,不落明文)。
2026-07-02 14:47:22 +08:00
- `TextGenerationResult` 包含 `text` 、清洗后的 `titles` 、`model_used` 、`raw` ; `ImageGenerationResult` 包含图片 bytes、`model_used` 、`raw` 。`raw` 仅供本次解析/排障摘要使用,调用记录不得整包保存或打印,避免 base64 大图和上游敏感字段入库;图片落对象存储并返回 URL 属 T-302 之后的 API 编排职责。
2026-07-01 17:42:10 +08:00
- 适配器按 `api_type` ( `chat` /`gemini` /`images` /`images_edits` /`auto` )从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。
2026-07-02 14:47:22 +08:00
- `parameters` 为供应商特有参数的安全子集;适配器负责白名单过滤并把统一入参翻译成各家上游格式,核心字段不可被 `parameters` 或 `extra_body` 覆盖。
2026-07-01 17:42:10 +08:00
- 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。
2026-07-02 11:42:39 +08:00
- 配置审计:后台保存/删除 AiModel、ModelAlias 时写 `AiConfigAuditLog` ;记录 actor/action/target/changed_fields/changes/created_at, admin 只读查看;密钥变更只记录 empty/set 状态。
2026-07-01 17:42:10 +08:00
- 区分异常:别名/能力不匹配 → 业务错误(400 类,如 `model_not_allowed` );上游网络/超时/服务错误 → `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 轮换机制。