怡途Claw 套餐订阅、续费、充值与支付下单 API 实战:POST /api/app/payment/create 走微信/支付宝/苹果内购三渠道,下单返回 qrCode 还是 payUrl 取决于渠道和 openid,type 决定月付年付还是充值,targetInstanceId 续费已有实例,query 轮询订单状态,close 关单,apple-iap/restore 换机恢复订阅。和 301 计费查询不同层。
前面讲了实例怎么启动停止(306)、登录怎么拿 token(307)、资料怎么改和注销(308)。这组 /api/app/* 里还有一类专门管"花钱"的:买套餐、续费实例、充值余额、查订单状态。它和 301 那篇讲的计费不是一回事——301 是"我已经花了多少 token、还剩多少额度",是查询;这篇是"我要怎么下单付钱、这笔单到底付了没有",是交易。一个看账本,一个收银台。
实际接进来你会碰到这些问题:
- 免费额度用完了,调哪个接口下单买套餐?请求体里
channel和type怎么填? - 微信、支付宝、苹果内购三个渠道,下单后返回的字段一样吗?PC 端扫码和手机端 JSAPI 调起,参数差在哪?
- 我是续费一个快到期的实例,还是新买一个套餐,传参有什么不同?
- 下单返回里
qrCode和payUrl都有,到底给前端哪个? - 用户跳去支付了,我怎么知道他到底付没付?是一直轮询
query还是等回调? - 订单创建出来一直没付,过期了怎么办?能不能主动关掉?
- 苹果内购的用户换手机、重装 App,之前买的年度订阅怎么恢复?
这组接口挂在 app/payment 下,全部要带 Authorization: Bearer <token>(307 讲过怎么拿)。本文从 app-payment.controller.ts 和 payment-order.service.ts 的代码来看每个接口到底做了什么。
先搞清楚三个枚举
下单之前得把三个枚举对上号,不然请求体填不对。
支付渠道 channel,三选一:
wechat微信支付alipay支付宝apple_iap苹果内购(App Store)
订单类型 type,三选一:
monthly月付套餐yearly年付套餐recharge充值(往余额里充钱,不是买套餐)
订单状态 status,五态:
pending待支付(刚下单)paid已支付failed支付失败closed已关闭(超时或主动关单)refunded已退款
注意 channel 和 type 是组合关系,不是二选一。比如"微信买年付套餐"就是 channel=wechat, type=yearly;"支付宝充值 100 块"就是 channel=alipay, type=recharge。苹果内购只走套餐(monthly/yearly),不充直营余额。
创建订单:POST /api/app/payment/create
最核心的接口。请求体:
{
"channel": "wechat",
"type": "yearly",
"planId": "plan_xxx",
"targetInstanceId": "inst_xxx",
"returnUrl": "https://your-app.com/payment/success",
"openid": "oXXXXXXX",
"runtimeType": "openclaw",
"llmBaseUrl": "https://api.example.com/v1",
"llmApiKey": "sk-xxx",
"llmModelId": "gpt-4o"
}
字段说明(除了 channel 和 type 必填,其余都可选,但组合起来语义不同):
planId套餐 ID。不传就用数据库里的默认"个人版"套餐。后端代码里写死了兜底逻辑:找不到指定套餐就回退到个人版,再找不到才报错。所以你只想卖默认套餐时可以不传。targetInstanceId续费目标实例 ID。这是续费和新购的分水岭:传了就是给这个已有实例续期,不传就是新购(支付成功后后端会自动创建一个新实例)。续费时订单会把planId和targetInstanceId绑在一起,回调成功后只延长这个实例的到期时间,不会新建。amount充值金额,只有type=recharge时才需要,单位是元,最小 0.01。套餐购买的金额由planId对应的套餐价格决定,不用传。openid微信 JSAPI 支付才需要——用户在微信浏览器/小程序里付款时要带。这个字段还兼职装苹果内购的收据数据(同一个openid字段,苹果内购时塞 receipt),DTO 没强类型区分,按渠道约定传。returnUrl支付完跳回的前端地址,支付宝 H5 和部分微信 H5 场景用。runtimeType用户选的运行时类型(openclaw/hermes/claude-code),新购时跟着订单走,支付成功后建实例用。llmBaseUrl/llmApiKey/llmModelId大模型配置,同样是新购时绑进订单,建实例时直接用,省得用户买完还要再配一遍。
返回:
{
"success": true,
"data": {
"orderId": "ord_a1b2c3",
"outTradeNo": "YT20260802xxxx",
"amount": 299.00,
"amountCent": 29900,
"status": "pending",
"expireAt": "2026-08-02T03:30:00.000Z",
"qrCode": "weixin://wxpay/bizpayurl?pr=xxx"
}
}
几个要点:
amount和amountCent同时给,单位分别是元和分。后端yuanToCent = Math.round(value * 100)。这是防前端浮点踩坑的常规操作——你直接用amountCent算最稳,不用自己*100担心精度。status下单就是pending,待支付。订单一创建就开始倒计时,expireAt是过期时间,过了这个点订单自动失效。- 最关键的是
data里除了固定几个字段,还有一坨渠道相关的支付参数直接展开(spread)进来,叫payData。这一坨按渠道长得完全不一样,下面单独说。
payData 按渠道不同,别拿错字段
这是接支付最容易翻车的地方。create 返回的 data 是 { orderId, outTradeNo, amount, amountCent, status, expireAt, ...payData },那个 ...payData 是按渠道展开的,不同渠道字段不同:
- 微信 Native 扫码(PC 端,没传 openid):
{ qrCode: "weixin://wxpay/bizpayurl?pr=xxx" }。你把这个qrCode字符串生成二维码贴出来,用户用微信扫。 - 微信 JSAPI(传了 openid,微信内浏览器/小程序):
{ jsapiParams: { appId, timeStamp, nonceStr, package, signType, paySign } }。这是一整套前端wx.requestPayment调起参数,直接喂给 JS-SDK。 - 微信 H5(手机普通浏览器):
{ payUrl: "https://wx.tenpay.com/..." },一个跳转链接,window.location过去拉起微信。 - 支付宝:
{ payUrl: "https://..." },支付宝的收银台跳转链接,前端跳过去就行。 - 苹果内购:
{ transactionId, productId },给客户端确认这笔交易用的。 - 免费订单(amount 为 0,活动赠送给之类):
{ payUrl: ".../payment/success?orderId=xxx&isFree=true" },直接跳成功页,没有真实支付环节。
所以前端处理 create 返回时不能写死取某个字段,要按 channel 分支:微信扫码取 qrCode 画码,微信 JSAPI 取 jsapiParams 调起,支付宝/H5 取 payUrl 跳转。代码里那个 fallback: true 标记是降级提示——比如你本来想要 H5 链接但只拿到了 qrCode,说明这次降级成了扫码,前端得提示用户去扫。
查订单状态:GET /api/app/payment/query
下单之后用户到底付没付,靠这个接口。前端轮询它:
curl "https://www.yitukeji.cn/api/app/payment/query?orderId=ord_a1b2c3" \
-H "Authorization: Bearer <你的token>"
返回:
{
"success": true,
"data": {
"orderId": "ord_a1b2c3",
"outTradeNo": "YT20260802xxxx",
"tradeNo": "420000xxxx20260802xxxx",
"planId": "plan_xxx",
"targetInstanceId": "inst_xxx",
"channel": "wechat",
"type": "yearly",
"amount": 299.00,
"amountCent": 29900,
"status": "paid",
"paidAt": "2026-08-02T02:15:33.000Z",
"expireAt": "2026-08-02T03:30:00.000Z",
"createdAt": "2026-08-02T02:00:20.000Z"
}
}
判定的核心是 status:
pending还没付,继续轮询。paid付了,paidAt是支付完成时间,tradeNo是第三方(微信/支付宝)那边的交易号。这时后端回调链路已经跑完——订阅生效、实例续期或新建、余额入账都在这步之后自动完成。failed/closed这单废了,让用户重新下单。refunded退过款了。
一个安全细节:queryOrder 虽然只传 orderId,但 service 里会校验这个订单是不是当前 token 用户的。你不能拿别人的 orderId 去查别人的单,查不到自己的会直接报错。所以这个接口对前端是安全的,不用担心越权。
轮询节奏别太快,建议 3-5 秒一次,看到 paid 或终态就停。订单 expireAt 一般是下单后 30 分钟左右,过点了自动转 closed,没必要一直轮询。
订单列表:GET /api/app/payment/orders
看当前用户的所有订单:
curl "https://www.yitukeji.cn/api/app/payment/orders?page=1&limit=10&status=paid" \
-H "Authorization: Bearer <你的token>"
page、limit 分页(limit 默认 10),status 可选过滤某个状态。返回:
{
"success": true,
"data": {
"orders": [
{
"orderId": "ord_a1b2c3",
"outTradeNo": "YT20260802xxxx",
"tradeNo": "420000xxxx",
"channel": "wechat",
"type": "yearly",
"amount": 299.00,
"amountCent": 29900,
"status": "paid",
"paidAt": "2026-08-02T02:15:33.000Z",
"expireAt": "2026-08-02T03:30:00.000Z",
"createdAt": "2026-08-02T02:00:20.000Z",
"planName": "个人版年付",
"planId": "plan_xxx",
"targetInstanceId": "inst_xxx"
}
],
"pagination": { "page": 1, "limit": 10, "total": 23 }
}
}
这里有个后端做了但你不一定注意到的活:planName 不是订单表里存的字段,是后端拿到这页订单里所有的 planId 去重,再批量查套餐表回填的名字。代码里对每个 planId 单独 getPlanById,查不到(套餐下架了)就 catch 掉、planName 给空字符串。所以你可能在历史订单里看到个别 planName 是空的——那是套餐已经被下架了,不是 bug。
targetInstanceId 在列表里也带,能直接看出这单是续费哪个实例的还是新购的(新购时这个字段是空的,支付成功后建实例时才生成 ID,所以新购订单的 targetInstanceId 可能为空)。
订单详情:GET /api/app/payment/orders/:orderId
和 query 返回的字段几乎一样,区别是路径风格——这个走 RESTful 路径参数,query 走 query string。都是查单笔订单,都是带用户鉴权的。按你前端的路由习惯选一个就行,没必要两个都调。
关闭订单:POST /api/app/payment/close/:orderId
用户下了单没付,想取消,或者前端想清理掉一个 pending 订单:
curl -X POST "https://www.yitukeji.cn/api/app/payment/close/ord_a1b2c3" \
-H "Authorization: Bearer <你的token>"
返回就一句:
{ "success": true, "message": "订单已关闭" }
关单后订单状态变 closed,用户没法再扫那个码付款。这个操作幂等——关一个已经关了的单不会报错。续费/购买场景下少用,主要是充值或活动订单用户改主意时清掉,避免一堆 pending 挂着。
苹果内购恢复:POST /api/app/payment/apple-iap/restore
苹果内购的特殊场景:用户换了手机、卸载重装 App、或者多设备登录,之前买的年度套餐丢了,要恢复。苹果那边交易记录在苹果服务器上,靠收据找回。
curl -X POST "https://www.yitukeji.cn/api/app/payment/apple-iap/restore" \
-H "Authorization: Bearer <你的token>" \
-H "Content-Type: application/json" \
-d '{ "receipt": "<苹果返回的base64收据>" }'
后端流程:
- 先
verifyReceiptForRestore向苹果验签,valid=false直接 400Apple 收据验证失败: <原因>。 - 验签通过拿到所有交易记录,再逐笔
isActiveAppleRestoreTransaction过滤出当前还没过期的。 - 判过期分两种:订阅型(
expiresDateMs存在)直接比当前时间;一次性/非自动续期,按productId反查套餐、用purchaseDateMs + durationMonths算到期。
返回未过期的交易:
{
"success": true,
"data": {
"environment": "production",
"transactions": [
{
"productId": "com.yitu.yearly",
"transactionId": "2xxxxxx",
"purchaseDateMs": "1719800000000",
"expiresDateMs": "1751400000000",
"isTrial": false
}
]
}
}
前端拿到这批交易后,可以再走正常下单/激活流程把订阅绑回当前账号。注意恢复只返回交易记录,不会自动给当前用户开订阅——拿到记录后还要根据业务再走一遍权益发放,这一步是分开的。
这一整套和 301 计费什么关系
容易混,这里说清楚:
- 301 讲的
/api/app/billing/*是账本视角:查余额(balance)、查 token 用量明细(token-usage)、查套餐和当前订阅(plans/subscription)。只读,告诉你"现在什么状态、花了多少"。 - 这篇讲的
/api/app/payment/*是收银台视角:下单(create)、查这单付没付(query)、订单历史(orders)、关单(close)、苹果恢复(apple-iap/restore)。写操作,真的在产生交易。
完整链路是这样的:用户在套餐页看到 billing/plans 列出来的套餐 -> 选一个调 payment/create 下单 -> 前端拿 payData 拉起微信/支付宝/苹果支付 -> 轮询 payment/query 等 status=paid -> 第三方回调到后端,后端自动发放权益(订阅生效、实例续期或新建、充值入账)-> 用户再调 billing/balance 就能看到余额变了。
所以一个"用户买套餐"的动作,前端要调的不只是 payment/create 这一下,而是 billing/plans(看价)+ payment/create(下单)+ payment/query(确认)三段,外加支付渠道 SDK 自己的调起。把这三段接顺,支付链路就通了。
下一篇会落到 app-agents——查当前用户的 agent 概要和性能数据,那组接口端点偏少但和实例运行状态直接相关,接监控面板时用得上。