Files
cmhub/docs/api.md
T

19 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,防绕过计费归属)。
  • 图片生成为同步接口,可能耗时较长,调用方与网关需设置足够超时(≥ 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,后续可替换为对象存储。

T-303 已实现余额查询基线:GET /api/v1/balance 已接入 API Key 鉴权,返回当前 UserWallet.points_balance;测试覆盖响应余额与 PointsLedger.points_delta 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。

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 单笔上限。

通用错误响应:

{
  "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
no_exchange_rate 未配置当前币种汇率,无法创建充值订单 400
payment_order_create_failed 支付平台下单失败 502
order_not_found 充值订单不存在或不属于当前用户 404
rate_limited 请求过于频繁,请稍后再试 429

对外接口

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

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。若传 image_url,服务端只会下载公网 http / https 图片,并在扣点前拒绝内网、回环、链路本地、元数据地址、跳转到内网的地址和超过大小上限的响应;被拒绝时返回 bad_request 且不预扣点。

POST /api/v1/generate/image

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

{
  "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_error 且不扣点(已预扣则退回)。结果默认存对象存储返回 image_url,避免同步响应体过大。请求里的 image_url 适用同标题接口相同的 SSRF 防护和大小上限;内网图片请用 image_base64。

GET /api/v1/balance

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

{
  "user": "demo-client",
  "points_balance": 88
}

计费模块合约(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
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),点数为整数。
  • 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;订单绑定发起用户,防充错账户。
  • 开发 / 测试 PAYMENT_CALLBACK_MODE=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_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 轮换机制。