816 lines
53 KiB
Markdown
816 lines
53 KiB
Markdown
# 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 起新用户注册成功一次性赠送 **10 点**试用点数;赠点必须经计费层写入钱包和 `PointsLedger(change_type=signup_bonus)`,不得直接改余额字段。历史用户是否补发不属于默认注册流程。
|
||
- API Key 库内只存 `key_hash`(SHA-256)与 `key_prefix`,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。
|
||
- 每次生成调用写 `CallRecord`;只允许保存 `result_ref` / `result_summary` 这类引用或摘要,不保存 provider `raw`、base64 图片或敏感上游字段。
|
||
- T-624/T-625 起,蝦皮圈客户端可先用 API Key 登记设备并取得短期 `X-Device-Session`;标题、图片、异步图片提交和图片理解可选携带该头,服务端只把已验证的设备关联到本次 `CallRecord` 与白名单遥测。没有该头的旧客户端继续按原 API Key 路径调用,响应、计费、任务提交和轮询均不变;设备会话尚**不**参与订阅授权拦截。设备标识、公钥和会话令牌不写入调用日志或响应中的设备对象。
|
||
- T-627 起,存量迁移客户端可用旧 API Key 加当前 `X-Device-Session` 申请短时迁移请求;仍必须由同账号网页登录确认后才绑定席位并签发产品专用设备凭证。凭证明文只在申请响应出现一次,数据库仅存 hash;旧 API Key 本身不能绕过网页登录确认,也不改变既有生成接口。
|
||
|
||
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-620 起两个图生图接口都支持有序 `images`,服务端固定约定第 1 张为主商品图、后续图为参考图,旧单图字段保持兼容。
|
||
|
||
T-619 已实现 `POST /api/v1/analyze/images`:使用独立 `vision` 操作和能力别名,同步接收一张或多张有序图片并返回完整文字。该接口复用 T-613 的准备 / 预扣 / 执行 / 成功确认 / 失败退点核心,不改变标题和生图接口。
|
||
|
||
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-629 已完成:软件套餐订单使用独立 `SoftwareOrder`、`POST /api/v1/software-orders/callback/wechat` 与 `GET /api/v1/software-orders/status`,只发放/续订软件权益,绝不兑换点数或触碰 `RechargeOrder` / `PointsLedger`。订阅微信下单必须配置独立 `SOFTWARE_WECHAT_PAY_NOTIFY_URL`,不得复用充值回调地址。
|
||
|
||
T-630/T-632 已完成:蝦皮圈产品专属接口按 API Key 对应用户查询 `SoftwareEntitlement`,默认允许同一账号在多台电脑使用;设备登记、设备会话和历史凭证不再作为授权前置条件。`CMSHOPEE_SUBSCRIPTION_MODE` 支持 `open`(开发测试统一放行)、`shadow`(放行并记录真实权益)和 `enforce`(按真实权益拦截);通用生成接口不受影响。旧 `CMSHOPEE_SUBSCRIPTION_ENFORCEMENT` 仅在新配置缺失时作为兼容回退。
|
||
|
||
`GET /api/v1/cmshopee/subscription/status` 使用 API Key 鉴权。响应中的 `status` / `allowed` 表示当前模式下的最终访问结果,`entitlement_status` 表示数据库真实权益状态;二者在 `open` / `shadow` 下可能不同。T-635 新增的 `real_entitlement_allowed` 只由真实权益决定:仅 `active` / `grace` 为 `true`,不受当前运行模式影响。客户端强制门禁必须使用该字段,不得以兼容性 `allowed=true` 代替。示例:
|
||
|
||
```json
|
||
{
|
||
"product_code": "cmshopee",
|
||
"status": "active",
|
||
"allowed": true,
|
||
"code": null,
|
||
"account": {
|
||
"display_name": "cmhub_user",
|
||
"username": "cmhub_user"
|
||
},
|
||
"plan": {
|
||
"code": "development-open",
|
||
"display_name": "开发测试长期会员",
|
||
"name": "开发测试长期会员",
|
||
"expires_at": "2099-12-31T23:59:59+00:00",
|
||
"grace_expires_at": null
|
||
},
|
||
"expires_at": "2099-12-31T23:59:59+00:00",
|
||
"grace_expires_at": null,
|
||
"manage_url": "https://cmhub.example.com/subscription",
|
||
"notice_id": "cmshopee-subscription-open-required-v1",
|
||
"access_source": "open_mode",
|
||
"entitlement_status": "required",
|
||
"real_entitlement_allowed": false
|
||
}
|
||
```
|
||
|
||
`plan.name`、`plan.expires_at`、`plan.grace_expires_at` 是 T-630 旧客户端兼容字段;新客户端使用 `plan.code` / `plan.display_name` 和顶层有效期。`access_source` 取 `open_mode`、`shadow_fallback` 或 `entitlement`。真实权益状态当前只承诺 `active`、`grace`、`required`、`expired`:宽限期为 `grace`,无权益为 `required`,超过宽限期为 `expired`。禁用账号或 API Key 不返回此成功 JSON,而是在认证层返回 HTTP 403 `account_disabled`。产品 submit 在 `enforce` 模式对无权益 / 过期分别返回 `subscription_required` / `subscription_expired` 403。
|
||
|
||
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`](moderation.md)。
|
||
|
||
T-501/T-608 已实现用户端注册 / 登录基线:`/signup` `/login` `/logout` 走 django-allauth + Django session + CSRF;**免邮箱验证、注册即可用(`ACCOUNT_EMAIL_VERIFICATION="none"`,邮箱仍必填且唯一)**。注册成功后 allauth adapter 调用 `grant_signup_bonus()` 一次性发放 10 点注册试用额度,并创建 `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/T-635/T-636 已实现 `GET /api/v1/client/releases/latest?platform=windows`,给桌面端自动检查更新和读取启动订阅策略使用。该接口公开匿名可访问,不需要 API Key,不读取用户、套餐、权益、订单或点数,不扣点、不占用生成接口限流;只返回 `DownloadRelease` 的公开发布元数据和无账号上下文的 `client_policy`。策略优先读取 django-admin 的全局 `ClientSubscriptionPolicy` 单例,记录不存在才回退 `.env`;后台保存后下一次请求立即生效,结构仍为 T-635 的 `policy_version=1` 与两个布尔字段。`release.force_update` 表示该版本是否必须升级,来源于后台 `DownloadRelease.force_update`,默认 `false`;`release.size_bytes` 表示安装包文件大小字节数,来源于后台 `DownloadRelease.size_bytes`,用于桌面端下载后校验文件大小。
|
||
|
||
通用错误响应:
|
||
|
||
```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 |
|
||
| `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 |
|
||
| `device_session_invalid` | 缺失、无效或过期设备会话 | 401 |
|
||
| `device_revoked` | 客户端设备已被吊销 | 403 |
|
||
| `device_mismatch` | 设备会话不属于当前 API Key 所属账号 | 403 |
|
||
| `subscription_required` | 当前账号没有该产品的有效订阅 | 403 |
|
||
| `subscription_expired` | 当前账号订阅已到期或超过宽限期 | 403 |
|
||
| `device_session_required` | 存量迁移申请缺少当前设备会话 | 401 |
|
||
| `migration_not_eligible` | 当前账号没有有效存量迁移资格 | 403 |
|
||
| `migration_request_not_found` | 迁移请求不存在或不属于当前设备 | 404 |
|
||
| `migration_request_expired` | 迁移请求已过期或不可用 | 400 |
|
||
| `migration_request_forbidden` | 网页确认账号不属于迁移请求 | 403 |
|
||
| `device_credential_exists` | 当前设备已完成迁移绑定 | 400 |
|
||
| `device_identity_mismatch` | 同一设备标识对应的安装公钥不一致 | 403 |
|
||
|
||
`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`](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`。
|
||
|
||
请求:
|
||
|
||
```http
|
||
GET /api/v1/client/releases/latest?platform=windows
|
||
```
|
||
|
||
参数:
|
||
|
||
| 参数 | 必填 | 说明 |
|
||
| --- | --- | --- |
|
||
| `platform` | 否 | `windows` / `macos` / `linux`;缺省按 `windows`。非法值返回 `400 bad_request` |
|
||
|
||
有当前版本时响应:
|
||
|
||
```json
|
||
{
|
||
"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"
|
||
},
|
||
"client_policy": {
|
||
"policy_version": 1,
|
||
"subscription_check_enabled": true,
|
||
"subscription_enforcement_enabled": false,
|
||
"updated_at": "2026-07-28T10:00:00+08:00"
|
||
}
|
||
}
|
||
```
|
||
|
||
无当前版本或当前版本没有下载地址时响应:
|
||
|
||
```json
|
||
{
|
||
"platform": "windows",
|
||
"release": null,
|
||
"message": "暂未发布",
|
||
"client_policy": {
|
||
"policy_version": 1,
|
||
"subscription_check_enabled": false,
|
||
"subscription_enforcement_enabled": false,
|
||
"updated_at": "2026-07-28T00:00:00+08:00"
|
||
}
|
||
}
|
||
```
|
||
|
||
字段说明:`force_update` 为布尔值,表示桌面端是否必须升级到该版本;未勾选时返回 `false`。`size_bytes` 为整数或 `null`,单位字节;T-618 起 django-admin 新增 / 编辑客户端发布版本时要求填写 `sha256` 和 `size_bytes`,但历史记录或非 admin 导入数据仍可能为空,客户端应只在该值为正整数时校验下载后的本地文件大小。客户端可先做版本号比较,再按 `force_update=true` 阻止继续使用旧版本或进入强制升级流程,下载完成后同时校验 `size_bytes` 和 `sha256`。`client_policy.policy_version` 当前固定为整数 `1`;两个 `subscription_*_enabled` 均为 JSON boolean;`updated_at` 是带时区 ISO 8601 的策略最后变更时间,不是请求时间。`off` 映射为 `false/false`,`observe` 映射为 `true/false`,`enforce` 映射为 `true/true`。
|
||
|
||
要点:接口只查 `DownloadRelease(platform, is_current=True)` 和纯环境配置;`download_url` 优先使用 `external_url`,否则用 `file.url` 生成绝对 HTTPS URL;`published_at` MVP 可使用 `DownloadRelease.updated_at`;`force_update` 使用后台发布版本上的布尔配置,默认 `false`;`size_bytes` 使用后台填写的安装包字节数,可为空以兼容历史记录。成功和“暂未发布”均返回 `client_policy`,发布记录和策略互不依赖;成功及错误响应带 `Cache-Control: no-store`。响应不得包含本地 `MEDIA_ROOT`、文件系统路径、后台 ID、`is_current`、用户信息、API Key、套餐、点数、模型配置或任何密钥字段。成功和“暂未发布”均返回 HTTP 200,方便桌面端静默检查;非法平台返回 `400 bad_request`。无当前版本或当前版本没有下载地址时返回 `release:null`,不返回独立的 `force_update` / `size_bytes` 顶层字段。客户端仅在识别 `policy_version=1` 且字段类型合法时采用策略;策略请求失败或字段异常时应回退到“检测开启、强制关闭”,服务端授权仍独立生效。
|
||
|
||
### `POST /api/v1/client/devices/register`
|
||
|
||
蝦皮圈客户端登记当前安装实例并获取短期设备会话。该接口使用既有 API Key 鉴权,不接受 Web session;第一阶段只登记和观测,不影响已有生成接口。
|
||
|
||
请求:
|
||
|
||
```json
|
||
{
|
||
"product_code": "cmshopee",
|
||
"device_id": "v1:client-generated-installation-id",
|
||
"device_id_version": "v1",
|
||
"installation_public_key": "client-installation-public-key",
|
||
"platform": "windows",
|
||
"client_version": "0.1.0"
|
||
}
|
||
```
|
||
|
||
成功响应:
|
||
|
||
```json
|
||
{
|
||
"device": {
|
||
"product_code": "cmshopee",
|
||
"platform": "windows",
|
||
"client_version": "0.1.0",
|
||
"status": "active",
|
||
"first_seen_at": "2026-07-21T10:00:00+08:00",
|
||
"last_seen_at": "2026-07-21T10:00:00+08:00"
|
||
},
|
||
"device_session_token": "dvs_cmhub_<only-returned-on-this-registration>",
|
||
"expires_at": "2026-07-21T11:00:00+08:00"
|
||
}
|
||
```
|
||
|
||
首次登记返回 `201`;同一用户、产品和设备重复登记保持同一设备记录、轮换会话令牌并返回 `200`。服务端对 `device_id_version + device_id` 加私有 pepper 后只保存 HMAC 摘要,对安装公钥只保存 SHA-256 摘要;不得上传 MachineGuid、MAC、硬盘序列号或私钥。`device_session_token` 只在本次响应返回,数据库只存 hash,客户端应使用 Windows DPAPI 等本地安全存储保护它。
|
||
|
||
### `POST /api/v1/client/devices/heartbeat`
|
||
|
||
刷新已登记设备的活跃观测。请求头:
|
||
|
||
```http
|
||
X-Device-Session: dvs_cmhub_<device_session_token>
|
||
```
|
||
|
||
成功响应返回当前设备公开摘要、会话到期时间和 `activity_updated`。默认同一设备至少间隔 24 小时才写入一次 `last_seen_at`,因此频繁心跳可能返回 `activity_updated=false`,这是正常行为。缺失、无效或过期会话返回 `401 device_session_invalid`;已吊销设备返回 `403 device_revoked`。该接口不扣点、不创建调用记录,也不刷新过期会话;客户端应重新调用登记接口获取新令牌。
|
||
|
||
### `POST /api/v1/client/migration-requests`
|
||
|
||
存量蝦皮圈客户端发起设备迁移确认请求。需要既有 API Key 和当前设备会话;该接口不扣点、不调用上游、不改变旧生成接口。
|
||
|
||
```http
|
||
POST /api/v1/client/migration-requests
|
||
Authorization: Bearer sk_cmhub_xxx
|
||
X-Device-Session: dvs_cmhub_<device_session_token>
|
||
```
|
||
|
||
响应中的 `device_credential_token` 是设备凭证明文,只返回本次一次,客户端必须安全保存且不得写入日志;后台只存 hash。用户打开 `confirmation_url` 后登录同一账号并确认,才会实际占用席位、签发凭证。没有后台显式创建的 `LegacyMigrationGrant` 时返回 `403 migration_not_eligible`;没有会话返回 `401 device_session_required`。
|
||
|
||
```json
|
||
{
|
||
"request_id": "0f70303b-6ec1-4ea1-8c0c-7c6d9dd7ae17",
|
||
"status": "pending",
|
||
"product_code": "cmshopee",
|
||
"expires_at": "2026-07-21T12:15:00+08:00",
|
||
"confirmed_at": null,
|
||
"confirmation_url": "https://cm.example.com/migration/confirm/0f70303b-6ec1-4ea1-8c0c-7c6d9dd7ae17",
|
||
"device_credential_token": "dvc_cmhub_<one_time_secret>"
|
||
}
|
||
```
|
||
|
||
### `GET /api/v1/client/migration-requests/{request_id}`
|
||
|
||
客户端以同一 API Key 和当前设备会话轮询迁移状态。只返回请求状态、产品、时间和确认链接,**不会**再次返回设备凭证明文;跨用户或跨设备请求返回 `404 migration_request_not_found`。网页刷新、客户端轮询重试不会重复占用席位或重复签发凭证。
|
||
|
||
### 生成接口的可选设备会话
|
||
|
||
以下生成提交路由可在既有 `Authorization: Bearer <API_KEY>` 外,额外携带同账号登记得到的设备会话:`POST /api/v1/generate/title`、`POST /api/v1/analyze/images`、`POST /api/v1/generate/image`、`POST /api/v1/generate/image/tasks`。
|
||
|
||
```http
|
||
X-Device-Session: dvs_cmhub_<device_session_token>
|
||
```
|
||
|
||
会话有效时,服务端将对应 `ClientDevice` 写入本次调用记录;不会把设备原始标识、公钥、会话明文、prompt 或图片写入用量日志。未携带该头时保留存量 API 契约和账务语义。调用方一旦携带该头,伪造或过期会话返回 `401 device_session_invalid`,已吊销设备或跨账号会话返回 `403 device_revoked` / `403 device_mismatch`,且请求在预扣前被拒绝。异步任务的后续 `GET` 轮询不需要该头,仍只按 API Key 所属用户校验任务归属。
|
||
|
||
### `POST /api/v1/generate/title`
|
||
|
||
生成标题。请求:
|
||
|
||
```json
|
||
{
|
||
"prompt": "为这件女装生成5个吸睛标题",
|
||
"model": "title-standard", // 能力别名,非具体模型名;可选,缺省用该操作的默认别名
|
||
"image_url": "https://...", // 可选,看图生成时传商品图(或 image_base64)
|
||
"resolution": "1K", // 可选,影响计费规则匹配
|
||
"parameters": {} // 可选,安全供应商参数;未知字段/核心字段忽略
|
||
}
|
||
```
|
||
|
||
成功响应:
|
||
|
||
```json
|
||
{
|
||
"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/analyze/images`
|
||
|
||
理解一张或多张图片并同步返回文字。请求:
|
||
|
||
```json
|
||
{
|
||
"prompt": "比较这些商品图,说明款式、材质和细节差异",
|
||
"model": "vision-standard",
|
||
"images": [
|
||
{"image_base64": "data:image/jpeg;base64,..."},
|
||
{"image_url": "https://example.com/detail.png"}
|
||
],
|
||
"parameters": {"temperature": 0.2}
|
||
}
|
||
```
|
||
|
||
`images` 必填且保持顺序,每项必须且只能提供一个 `image_url` 或 `image_base64`,允许两种来源混合。默认限制由 `VISION_MAX_IMAGES=8`、`VISION_MAX_IMAGE_BYTES=10485760`、`VISION_MAX_TOTAL_BYTES=33554432` 控制;超限、空列表、格式错误或同项双来源返回 `400 bad_request`,不扣点、不调上游。
|
||
|
||
成功响应:
|
||
|
||
```json
|
||
{
|
||
"text": "第一张是商品正面图,第二张展示了领口和面料细节。",
|
||
"alias": "vision-standard",
|
||
"model_used": "provider-model-for-debugging",
|
||
"points_cost": 3,
|
||
"points_balance": 95,
|
||
"call_id": 12346
|
||
}
|
||
```
|
||
|
||
要点:
|
||
|
||
- `model` 是 `vision` 操作的能力别名;缺省时使用后台配置的默认别名。别名指向的 `AiModel` 与 Provider 必须同时具备 `text` 和 `vision` 能力,否则返回 `model_not_allowed`。
|
||
- prompt 审核先于图片下载 / 解码;命中敏感词返回 `content_blocked`。每个 URL 复用现有公网地址、逐跳重定向、超时和 SSRF 防护。第一版不包含图片内容审核。
|
||
- 第一版按 `PricingRule(operation_type=vision, alias, resolution="")` 对一次请求固定扣点,不按图片数量重复扣费。输入校验失败不预扣;上游失败按现有账务流程幂等退点。
|
||
- Chat Completions / Gemini Provider 按请求顺序发送多张图片;返回 `text` 保留上游完整文字,不执行标题拆分清洗。
|
||
- 输入图片只在本次同步请求内存中使用,不保存到数据库或 media;`CallRecord` 只保存最多 500 字结果摘要,不保存 base64、provider raw 或完整上游响应。
|
||
|
||
### `POST /api/v1/generate/image`
|
||
|
||
生成图片(同步等待)。请求:
|
||
|
||
可选请求头:
|
||
|
||
```http
|
||
X-Client-Version: 0.1.1
|
||
X-Device-Session: dvs_cmhub_<device_session_token>
|
||
```
|
||
|
||
`X-Client-Version` 仅用于 T-615 的用量遥测,帮助服务端区分旧同步接口由哪些客户端版本调用;不影响响应结构。`X-Device-Session` 为 T-625 可选设备关联头,省略时保持旧客户端行为。
|
||
|
||
```json
|
||
{
|
||
"prompt": "把这件衣服换成模特上身的穿搭图",
|
||
"model": "image-hd", // 能力别名,非具体模型名
|
||
"image_base64": "data:image/png;base64,...", // 旧单图字段:或 image_url;不可和 images 同时传
|
||
"resolution": "1K",
|
||
"aspect_ratio": "1:1",
|
||
"parameters": {} // 可选,安全供应商参数;未知字段/核心字段忽略
|
||
}
|
||
```
|
||
|
||
成功响应:
|
||
|
||
```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
|
||
}
|
||
```
|
||
|
||
多图请求示例(不要与旧单图字段混用):
|
||
|
||
```json
|
||
{
|
||
"prompt": "为主商品图生成新的电商展示图",
|
||
"model": "image-hd",
|
||
"images": [
|
||
{"image_base64": "data:image/png;base64,..."},
|
||
{"image_url": "https://cdn.example.com/style-reference.jpg"}
|
||
],
|
||
"resolution": "1K",
|
||
"aspect_ratio": "1:1"
|
||
}
|
||
```
|
||
|
||
要点:`images` 必须为 1–`IMAGE_MAX_INPUT_IMAGES` 张的有序列表,每项必须且只能提供 `image_url` 或 `image_base64`。`images[0]` 是主商品图,`images[1:]` 只作为风格、构图、场景或排版参考;该规则由服务端追加,调用方 prompt 不能关闭或覆盖。改图类模型缺原图返回 `bad_request`,不落 500;新旧字段同时传、空列表或超限同样返回 `400 bad_request`。上游超时返回 `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`。
|
||
|
||
请求:
|
||
|
||
```http
|
||
POST /api/v1/generate/image/tasks
|
||
Authorization: Bearer sk_cmhub_xxx
|
||
Idempotency-Key: desktop-job-20260708-0001
|
||
X-Client-Version: 0.1.1
|
||
X-Device-Session: dvs_cmhub_<device_session_token>
|
||
Content-Type: application/json
|
||
```
|
||
|
||
```json
|
||
{
|
||
"prompt": "把这件衣服换成模特上身的穿搭图",
|
||
"model": "image-hd",
|
||
"images": [
|
||
{"image_base64": "data:image/png;base64,..."},
|
||
{"image_url": "https://cdn.example.com/style-reference.jpg"}
|
||
],
|
||
"resolution": "1K",
|
||
"aspect_ratio": "1:1",
|
||
"parameters": {}
|
||
}
|
||
```
|
||
|
||
成功响应:
|
||
|
||
```json
|
||
{
|
||
"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`。
|
||
- `images` 与旧单个 `image_url` / `image_base64` 字段互斥;新数组保持提交顺序,第 1 张为主图、后续图为参考图。异步任务按顺序保存输入文件,旧单图任务仍可读取其历史 `input_image`。
|
||
- 不保存 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}`
|
||
|
||
轮询异步图片生成任务。请求:
|
||
|
||
```http
|
||
GET /api/v1/generate/image/tasks/2bff8217-47a9-44f1-9bd9-82a375e79dc9
|
||
Authorization: Bearer sk_cmhub_xxx
|
||
```
|
||
|
||
排队或执行中:
|
||
|
||
```json
|
||
{
|
||
"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`:
|
||
|
||
```json
|
||
{
|
||
"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"
|
||
}
|
||
```
|
||
|
||
成功:
|
||
|
||
```json
|
||
{
|
||
"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"
|
||
}
|
||
}
|
||
```
|
||
|
||
失败:
|
||
|
||
```json
|
||
{
|
||
"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`
|
||
|
||
查询当前账号点数余额。成功响应:
|
||
|
||
```json
|
||
{
|
||
"user": "demo-client",
|
||
"points_balance": 88,
|
||
"account": {
|
||
"username": "demo-client",
|
||
"display_name": "主账号"
|
||
}
|
||
}
|
||
```
|
||
|
||
要点:`user` 与 `points_balance` 是旧客户端兼容字段;`account` 供桌面端测试连接时展示账号信息。`account.display_name` 取用户姓名,未设置时回退用户名;不返回邮箱、数据库用户 ID 或其他个人敏感字段。
|
||
|
||
### `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": []
|
||
},
|
||
{
|
||
"alias": "vision-standard",
|
||
"operation_type": "vision",
|
||
"capabilities": ["text", "vision"],
|
||
"requires_image": true,
|
||
"pricing_status": "priced",
|
||
"prices": [
|
||
{"resolution": "default", "points_cost": 3}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
要点:只列 `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 层,供后续生成接口和充值下单调用:
|
||
|
||
```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
|
||
create_recharge_order(..., user, amount, pay_method: str, currency: str = "CNY") -> RechargeOrder
|
||
grant_signup_bonus(user, points: int = 10) -> 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()` 是注册赠点唯一入口:只对当前用户首次成功发放 10 点,锁定 / 创建 `UserWallet`,写 `PointsLedger(change_type=signup_bonus, points_delta=+10)`,并用 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)` 时锁定当前汇率并计算预计到账点数,再向支付平台下单,返回**支付二维码**。请求:
|
||
|
||
```json
|
||
{
|
||
"amount": "100.00",
|
||
"pay_method": "weixin" // weixin | alipay
|
||
}
|
||
```
|
||
|
||
成功响应:
|
||
|
||
```json
|
||
{
|
||
"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=...`
|
||
|
||
由**已登录用户**轮询充值订单状态。只返回当前登录用户自己的订单;不属于当前用户的订单按不存在处理。
|
||
|
||
成功响应:
|
||
|
||
```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` 仅是二维码本地有效期提示,不自动阻断延迟到达的真实支付回调。
|
||
|
||
## 软件套餐订阅支付
|
||
|
||
`/subscription` 是已登录用户的虾皮圈套餐购买/续订页,使用 session + CSRF 创建软件订单并展示二维码。第一版只开放微信主动月度续订,不接受 API Key,不支持自动代扣。
|
||
|
||
### `POST /api/v1/software-orders/callback/wechat`
|
||
|
||
微信服务端回调,`@csrf_exempt`、无登录态。先按既有微信 V3/mock 协议验签,再锁 `SoftwareOrder`,校验 `out_trade_no`、支付通道、金额和交易号;同订单同交易号重复回调返回成功但不重复发放,已支付订单收到不同交易号返回 `400 bad_request`。成功时创建/续订 `SoftwareEntitlement` 并写 `LicenseEvent(order_fulfilled)`,不写 `UserWallet` 或 `PointsLedger`。
|
||
|
||
### `GET /api/v1/software-orders/status?order_no=...`
|
||
|
||
仅当前登录用户可读取自己的软件订单。pending 订单先检查二维码本地过期状态,再尝试主动查单并走与回调相同的权益发放服务。响应字段为 `order_no`、`product_code`、`plan_name`、`amount`、`currency`、`pay_method`、`status`、`code_url`、`expires_at`、`paid_at` 与 `fulfilled_at`。
|
||
|
||
退款不属于该接口或自动化范围:已支付订单的退款和权益撤销由运营按订单人工处理;不得根据某一笔旧订单直接回滚累计权益到期时间。
|
||
|
||
## AI 调用模块合约(`apps/ai`)
|
||
|
||
分两层:**别名解析** + **Provider 适配器**。API 层只传别名,由本模块解析到具体模型并选适配器。
|
||
|
||
### 别名解析
|
||
|
||
```python
|
||
# alias -> 具体 AiModel(含 capabilities、解密后的 key);找不到/能力不符抛业务异常
|
||
resolve_alias(operation_type: str, alias: str | None) -> ResolvedModel
|
||
```
|
||
|
||
T-102/T-619 后,别名解析已接入数据库表 `AiModel` / `ModelAlias`:每次调用读取当前 active 记录,缺省 `alias=None` 时取该 `operation_type` 的默认别名;标题要求 `text`,图片生成要求 `image`,图片理解要求同时具备 `text + vision`。密钥以 `AiModel.api_key_encrypted` 存储,使用 Fernet 解密后进入 `ResolvedModel`。
|
||
|
||
从 `cmbot` 形状导入配置的管理命令:
|
||
|
||
```bash
|
||
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 的调用逻辑落到各适配器内:
|
||
|
||
```python
|
||
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: ...
|
||
def analyze_images(self, prompt: str, model: ResolvedModel,
|
||
images: Sequence[MultimodalImage],
|
||
parameters: dict | None = None) -> TextGenerationResult: ...
|
||
```
|
||
|
||
要点:
|
||
|
||
- `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 编排职责。
|
||
- `analyze_images()` 接收有序 `MultimodalImage(data, mime_type)` 序列;Chat / Gemini 适配器翻译为各自多模态 payload,并从原始响应提取完整文字。图片生成 / 编辑专用 Provider 必须明确拒绝该操作。
|
||
- 适配器按 `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 轮换机制。
|