Files
cmhub/docs/api.md
T

266 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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,防绕过计费归属)。
- 图片生成为**同步**接口,可能耗时较长,调用方与网关需设置足够超时(≥ 300s)。
- 用户端注册使用同一个 `User` 账本主体;注册邮箱必须验证且唯一,避免同邮箱对应多个点数账户。
- 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`,后续可替换为对象存储。
通用错误响应:
```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 |
| `upstream_error` | 上游 AI 失败(已退点) | 502 |
| `signature_invalid` | 支付回调验签失败 | 400 |
| `amount_mismatch` | 支付回调金额与本地订单金额不一致 | 400 |
## 对外接口
> **重要约定**:`model` 字段传的是**能力别名**(如 `title-standard` / `image-hd`),不是具体供应商模型名。后台把别名映射到当前的具体模型,换供应商时调用方零改动。供应商特有参数放 `parameters`,但只允许 Provider 白名单内的安全参数透传;`model`、`n`、`size`、`resolution`、`messages`、`image*` 等核心/计费字段由服务端固定,调用方传入时忽略。
### `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`。
### `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",
"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
}
```
要点:改图类模型缺原图返回 `bad_request`,不落 500;上游失败返回 `upstream_error` 且**不扣点**(已预扣则退回)。结果默认存对象存储返回 `image_url`,避免同步响应体过大。
### `GET /api/v1/balance`
查询当前账号点数余额。成功响应:
```json
{
"user": "demo-client",
"points_balance": 88
}
```
## 计费模块合约(`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
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
```
要点:
- `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)`,点数为整数。
- `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` 流水,重复调用不会重复加点;成功调用不能走失败退点。
## 支付充值(自助扫码:微信 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",
"exchange_rate": "10.00",
"points_granted": 1000,
"pay_method": "weixin",
"code_url": "weixin://wxpay/bizpayurl?pr=abc123", // 微信 native 返回;支付宝为 qr_code(https://qr.alipay.com/...)
"expires_at": "2026-06-29T12:10:00Z" // 二维码有效期
}
```
- 下单字段:`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;订单绑定发起用户,防充错账户。
## AI 调用模块合约(`apps/ai`)
分两层:**别名解析** + **Provider 适配器**。API 层只传别名,由本模块解析到具体模型并选适配器。
### 别名解析
```python
# 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` 形状导入配置的管理命令:
```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: ...
```
要点:
- `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_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 轮换机制。