返回博客

额度用完想续费?怡途Claw 套餐订阅与支付下单 API 全流程实战

2026年8月2日阅读约 42 分钟
额度用完想续费?怡途Claw 套餐订阅与支付下单 API 全流程实战

怡途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、还剩多少额度",是查询;这篇是"我要怎么下单付钱、这笔单到底付了没有",是交易。一个看账本,一个收银台。

实际接进来你会碰到这些问题:

  • 免费额度用完了,调哪个接口下单买套餐?请求体里 channeltype 怎么填?
  • 微信、支付宝、苹果内购三个渠道,下单后返回的字段一样吗?PC 端扫码和手机端 JSAPI 调起,参数差在哪?
  • 我是续费一个快到期的实例,还是新买一个套餐,传参有什么不同?
  • 下单返回里 qrCodepayUrl 都有,到底给前端哪个?
  • 用户跳去支付了,我怎么知道他到底付没付?是一直轮询 query 还是等回调?
  • 订单创建出来一直没付,过期了怎么办?能不能主动关掉?
  • 苹果内购的用户换手机、重装 App,之前买的年度订阅怎么恢复?

这组接口挂在 app/payment 下,全部要带 Authorization: Bearer <token>(307 讲过怎么拿)。本文从 app-payment.controller.tspayment-order.service.ts 的代码来看每个接口到底做了什么。

先搞清楚三个枚举

下单之前得把三个枚举对上号,不然请求体填不对。

支付渠道 channel,三选一:

  • wechat 微信支付
  • alipay 支付宝
  • apple_iap 苹果内购(App Store)

订单类型 type,三选一:

  • monthly 月付套餐
  • yearly 年付套餐
  • recharge 充值(往余额里充钱,不是买套餐)

订单状态 status,五态:

  • pending 待支付(刚下单)
  • paid 已支付
  • failed 支付失败
  • closed 已关闭(超时或主动关单)
  • refunded 已退款

注意 channeltype 是组合关系,不是二选一。比如"微信买年付套餐"就是 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"
}

字段说明(除了 channeltype 必填,其余都可选,但组合起来语义不同):

  • planId 套餐 ID。不传就用数据库里的默认"个人版"套餐。后端代码里写死了兜底逻辑:找不到指定套餐就回退到个人版,再找不到才报错。所以你只想卖默认套餐时可以不传。
  • targetInstanceId 续费目标实例 ID。这是续费和新购的分水岭:传了就是给这个已有实例续期,不传就是新购(支付成功后后端会自动创建一个新实例)。续费时订单会把 planIdtargetInstanceId 绑在一起,回调成功后只延长这个实例的到期时间,不会新建。
  • 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"
  }
}

几个要点:

  • amountamountCent 同时给,单位分别是元和分。后端 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>"

pagelimit 分页(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收据>" }'

后端流程:

  1. verifyReceiptForRestore 向苹果验签,valid=false 直接 400 Apple 收据验证失败: <原因>
  2. 验签通过拿到所有交易记录,再逐笔 isActiveAppleRestoreTransaction 过滤出当前还没过期的
  3. 判过期分两种:订阅型(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/querystatus=paid -> 第三方回调到后端,后端自动发放权益(订阅生效、实例续期或新建、充值入账)-> 用户再调 billing/balance 就能看到余额变了。

所以一个"用户买套餐"的动作,前端要调的不只是 payment/create 这一下,而是 billing/plans(看价)+ payment/create(下单)+ payment/query(确认)三段,外加支付渠道 SDK 自己的调起。把这三段接顺,支付链路就通了。

下一篇会落到 app-agents——查当前用户的 agent 概要和性能数据,那组接口端点偏少但和实例运行状态直接相关,接监控面板时用得上。