返回博客

充了值余额没涨、还能再建几个 Agent?怡途Claw 账本查询 API 实战

2026年8月5日阅读约 65 分钟
怡途Claw 账本查询 API 实战

充值到账了余额却没动?想知道当前套餐还能开几个实例、账单流水和发票去哪拉?这篇把 /api/app/billing 的 plans/subscription/balance/records/invoices/instance-limit/quota-usage 一组只读账本查询接口一次讲透,含价格分/元双轨、余额读时自愈对账、发票同源视图等坑位。

用怡途Claw 跑了一阵子 Agent 之后,总会撞上几个跟「钱和额度」有关的查询场景:明明支付页面提示充值成功了,刷新一看余额却没动,到底到账没有?这个月到底花了多少钱、想拉一份账单流水跟财务对账、或者干脆生成一张发票走公司报销,接口在哪找?当前这个套餐还能不能再开一个 Agent、CPU 和内存还剩多少额度?这些问题,都不是去翻 token 用量明细能回答的(那是 /token-usage,按每一次请求的输入输出 token 拆开看,301 讲过),也不是去看媒体生成花了多少钱(那是 /media-usage,按生图生视频的次数和秒数算,310 讲过),更不是去发起一笔新的支付下单(那是 309 讲的 /payment/create,写操作)。它们都指向同一组「只读」的账本查询接口:/api/app/billing 下的 plans / subscription / balance / records / invoices / instance-limit / quota-usage。这一篇就把这组接口一次讲透,顺手把几个最容易踩的坑--价格分/元双轨、余额读时自愈对账、发票其实是流水的视图--都说清楚。

先说一句总纲,免得后面乱:这组接口全部挂在 @Controller('app/billing') 下,统一走 JwtAuthGuard,所以每个请求都要带登录后的 JWT(307 讲过怎么拿 token),req.user.userId 是所有查询的主键。它们都是 GET,不写库、不扣费、不创建任何东西(/balance 除外,它顺带做一次异步对账,下面单说)。换句话说,你可以放心地反复调、轮询调,不会产生副作用。

一、套餐目录:GET /api/app/billing/plans、/plans/:id

最基础的查询:平台到底卖哪些套餐。不需要传任何参数,JWT 鉴权后直接拉:

GET /api/app/billing/plans
Authorization: Bearer <你的 JWT>

后端 getPlans(channel?) 的逻辑是:先从 planRepository 捞所有 isActive=true 的套餐,按 sortOrder 升序、createdAt 降序排好;如果传了 channel 参数(比如 wechatalipayapple_iap),再做一道内存过滤--salesChannels 为空或 null 的套餐视为「全渠道可用」,照常返回;salesChannels 非空的,只有数组里包含你传的这个 channel 才返回。这套设计是为了让苹果内购渠道只露出挂了 appleProductIdMonthly/Yearly 的套餐,而微信/支付宝渠道看到全量。

返回的每个套餐对象,经过 normalizePlanPriceForApp 归一化之后,字段大致是:

  • id / name / code / type:套餐主键、展示名、业务码(如 personalteam)、类型枚举。
  • price / priceMonthly / priceYearly这里的单位是元,不是分。下面单说这个坑。
  • priceCent / priceMonthlyCent / priceYearlyCent:原始的分值,normalizePlanPriceForApp 主动塞进来的,方便你对账。
  • instanceLimit / cpuLimit / memoryLimit / diskLimit:套餐里能开几个实例、几核 CPU、几 GB 内存、几 GB 磁盘的上限。
  • maxInstances / maxCpuCores / maxMemoryMb / maxDiskGb:带兜底链的上限值,maxInstances ?? instanceLimit ?? 1maxCpuCores ?? cpuLimit ?? 2maxMemoryMb ?? (memoryLimit ? memoryLimit*1024 : 2048)maxDiskGb ?? diskLimit ?? 50。第七节讲 quota-usage 时会再碰到这套兜底。
  • billingCycle / durationMonths:计费周期、订阅时长(月)。
  • isRecommended / isActive / sortOrder:是否推荐位、是否上架、排序权重。
  • features:特性列表,前端展示用。
  • dockerImage / runtimeType / runtimeTypes:套餐绑定的镜像和运行时类型(Claude Code、OpenClaw 等)。
  • salesChannels:销售渠道数组,null 表示全渠道。
  • appleProductIdMonthly / appleProductIdYearly:苹果商店商品 ID,没接苹果就是 null。

想查单个套餐的详情,直接 GET /api/app/billing/plans/:idgetPlanById 按 id 精确查(不校验 isActive,所以下架的也能查到),同样走 normalizePlanPriceForApp 归一化后返回。

第一个坑:价格分/元双轨。 数据库里 subscription_plan 表存的 price(整数分),这是支付系统的惯例,避免浮点。但对外给 App 用的 /plans 接口,normalizePlanPriceForApp 会把 price/priceMonthly/priceYearly 除以 100 转成(保留两位小数),同时把原始分值塞进 priceCent/priceMonthlyCent/priceYearlyCent/records/invoices 也一样,normalizeBillingRecordForApp 给每条记录同时返回 amount(元)和 amountCent(分)、refundedAmount(元)和 refundedAmountCent(分)。所以如果你要跨层对账--比如拿接口返回的 plan.price 跟数据库里直接查出来的 amount 字段比--记住一个是元一个是分,差 100 倍。前端展示一律用元字段,后端/对账一律用 Cent 字段,别混。

二、当前订阅与历史:GET /api/app/billing/subscription、/subscriptions

「我现在用的是什么套餐、什么时候到期」走 GET /api/app/billing/subscription

GET /api/app/billing/subscription
Authorization: Bearer <你的 JWT>

getCurrentSubscription(userId) 的判定逻辑值得仔细看,因为它决定了「当前有效订阅」的语义。它查的是满足下面任一条件、且按 createdAt 降序取最新的一条:

  • status = ACTIVEcurrentPeriodEnd >= now
  • status = CANCELLEDcurrentPeriodEnd >= now
  • status = TRIALcurrentPeriodEnd >= now

注意第二条:已取消但还在有效期内的订阅,仍然算「当前订阅」。这是「这个月已经关了自动续费、但本月还没到期、所以还能继续用」的语义。只有 currentPeriodEnd 过了之后,这条订阅才真正不再是「当前」。EXPIREDSUSPENDED 不在这三个条件里,所以不会被当作当前订阅返回。没有匹配的订阅就返回 null(响应体里 data/package 为 null)。

返回的对象是 mapSubscriptionToResponse 包出来的,里面会嵌一个 plan 字段(套餐详情,同样经过价格归一化),还有 currentPeriodStart / currentPeriodEnd / startedAt / autoRenew / status / paymentProvider 这些。

别名一堆,别以为接口重复了。 这个 controller 里塞了一组历史兼容别名:

  • GET /package = GET /subscription
  • GET /subscriptions/current = GET /subscription
  • GET /packages/current = GET /subscription

它们内部直接调 getSubscription(req),返回结构一模一样。所以你在前端代码里看到 /packages/current、在老文档里看到 /subscription、在某个 SDK 里看到 /package,是同一个东西,不用纠结用哪个。

想看订阅历史(包括已经过期、已取消的所有订阅),走 GET /api/app/billing/subscriptions

GET /api/app/billing/subscriptions
Authorization: Bearer <你的 JWT>

getUserSubscriptionsuserId 查全部、按 createdAt 降序返回数组。它不做 currentPeriodEnd >= now 的过滤,所以你能看到从注册以来的所有订阅记录,包括过期很久的。同样有别名 GET /packages = GET /subscriptions

三、余额:GET /api/app/billing/balance

「我账户里还有多少钱」走 GET /api/app/billing/balance

GET /api/app/billing/balance
Authorization: Bearer <你的 JWT>

这个接口有两个点必须讲,一个是「读时自愈」,一个是字段含义。

读时自愈对账。 getBalance 方法的第一行不是查余额,而是这句:

this.paymentService.reconcilePaidRechargeOrders(req.user.userId).catch(...)

它异步(不 await,失败 catch 掉、不影响余额返回)去跑 reconcilePaidRechargeOrders:捞这个用户 type=RECHARGEstatus=PAID 的支付订单,按 paidAt 降序、take 20 条,逐条检查 notifyData.fulfilledAt--如果某个已支付订单还没有「已发货」标记,就补调一次 handlePaymentSuccess 把钱真正加到余额上,并打上 source: 'reconcile-paid-recharge-orders'

这就是为什么会有「充值成功了余额却没涨」这种情况:支付回调(支付平台异步通知)可能丢了一次、或者在你拿到支付成功页面那一刻还没来得及发货,余额的 totalRecharged 还没加上去。这时候你再读一次 /balance,接口会顺手把漏发的补上,余额就回来了。所以排查「充值不到账」时,先调一次 /balance 触发对账,再查 balance 字段,往往就自愈了,不用找运维。对账只针对 RECHARGE(充值)类型的订单,订阅续费的发货不在这条链路上。

字段含义。 余额返回结构是:

  • balance:账户余额,单位元。
  • availableBalance:可用余额,当前等于 balance
  • frozenAmount:冻结金额,当前恒为 0(代码里写死 frozenAmount: 0)。这是个预留字段,目前没有冻结逻辑,别在前端做「冻结金额展示」的预期。
  • totalRecharged:历史累计充值。
  • totalConsumed:历史累计消费。
  • userId / updatedAt:用户 ID、余额最后更新时间。

getOrCreateBalance 保证每个用户都有一条 user_balance 记录,第一次查会自动建。所以新用户调 /balance 不会报错,返回一个全 0 的余额对象。

四、账单流水:GET /api/app/billing/records

想拉一份明细流水走对账,走 GET /api/app/billing/records

GET /api/app/billing/records?page=1&pageSize=20&status=succeeded&billingType=subscription&startDate=2026-07-01&endDate=2026-08-01
Authorization: Bearer <你的 JWT>

查询参数走 QueryBillingRecordsDto,校验比较严:

  • page:页码,整数,最小 1,默认 1。
  • pageSize:每页条数,整数,最小 1、最大 100,默认 20。想拉全量就分页翻,别想一次拿几万条。
  • status:支付状态枚举 PaymentStatus,可选值 pending / processing / succeeded / failed / refunded / partially_refunded / cancelled。传了非法值会被 @IsEnum 拦成 400。
  • billingType:计费类型枚举 BillingType,可选值 subscription(订阅续费)/ one_time(一次性购买)/ upgrade(升级)/ downgrade(降级退款)/ usage(额外用量)/ refund(退款)/ gift(赠送)/ gift_revoke(赠送撤销)。
  • subscriptionId:按订阅 ID 过滤,必须是合法 UUID。
  • startDate / endDate:日期过滤,必须是合法的 ISO 日期字符串(@IsDateString)。

第二个坑:日期筛选的单边行为。 queryBillingRecordscreatedAt 的处理是:

  • 两个都传:Between(start, end),闭区间 [start, end) 的语义(TypeORM Between 是含两端,但因为 end 通常传的是日期不带时分秒,实际效果看下一条)。
  • 只传 startDateMoreThanOrEqual(start),从这天往后所有记录。
  • 只传 endDateLessThan(end)注意是 < 不是 <=。如果你传 endDate=2026-08-01(解析成 2026-08-01T00:00:00),那 8 月 1 号当天的所有记录都不包含,只拿到 8 月 1 号之前的。想包含当天,得传 endDate=2026-08-02

这跟 /token-usage/media-usage 不一样--后两者在 service 层会把 endDate 自动补到 23:59:59.999,而 /records 这里是裸用 TypeORM 操作符,不补尾。所以同一个「查某月账单」的需求,/records 的 endDate 要传下个月 1 号,/token-usage 的 endDate 传当月最后一天就行。别用一套参数套两个接口。

返回的每条记录经过 normalizeBillingRecordForApp,字段是:

  • id / userId / subscriptionId / planId / planName:记录主键、用户、订阅、套餐、套餐名。
  • billingType / status:计费类型、支付状态。
  • amount(元)/ amountCent(分)/ formattedAmount(形如 "99.00" 的字符串,getFormattedAmount 就是 (amount/100).toFixed(2))/ currency
  • periodStart / periodEnd:账单周期起止。
  • paymentProvider / externalPaymentId:支付渠道、外部支付单号。
  • failureMessage:失败原因(status=failed 时看这个)。
  • refundedAmount(元)/ refundedAmountCent(分)/ refundedAt / refundReason:退款相关。
  • invoiceId / invoiceUrl / receiptUrl:发票 ID、发票 URL、回执 URL。
  • createdAt / updatedAt

分页结构是 data.data(记录数组)+ data.pagination(page/pageSize/total/totalPages/hasNext/hasPrev),和 token-usage 那套一致。

五、发票视图:GET /api/app/billing/invoices

「开发票」走 GET /api/app/billing/invoices

GET /api/app/billing/invoices?page=1&pageSize=20
Authorization: Bearer <你的 JWT>

第三个坑:发票不是独立表,是流水的视图。 看代码就明白:getInvoices 方法的第一行是 const records = await this.billingService.queryBillingRecords(req.user.userId, query)--它直接调了上一节的 queryBillingRecords,用同样的 QueryBillingRecordsDto 参数,拿同一份 billing_record 数据,然后在 controller 层把每条记录 reshape 成「发票」的形状:

  • id:记录 ID。
  • amount(元)/ amountCent(分):同流水。
  • status状态映射,不是直接透传。succeeded -> paid(已付)、pending -> unpaid(未付)、failed -> void(作废)、其余(processing / refunded / partially_refunded / cancelled)-> draft(草稿)。所以发票上的状态词跟流水上的不一样,别拿发票的 paid 去跟流水的 status 字段直接比对,要对回 succeeded
  • descriptionplanName || '套餐服务',没有套餐名就兜底成「套餐服务」。
  • createdAt:记录创建时间。
  • paidAt只有 status=succeeded 才有值(等于 createdAt),其他状态一律 null。所以「未付发票没有 paidAt」是正常的,不是数据缺失。
  • pdfUrl:直接取流水的 invoiceUrl,没有就是 null。

返回结构是 data.invoices(数组)+ data.total + data.page + data.pageSize,注意它没有 hasNext/hasPrev/totalPages,只有 total,前端翻页要自己算 Math.ceil(total/pageSize)

这节的核心结论:发票和流水是同一份数据的两种呈现。如果你要拿来做财务对账,直接用 /records 更原始(有 billingTypefailureMessage、退款字段);如果是要给用户一个「发票列表」的视图,用 /invoices 拿到的状态词和字段名更贴脸。两者参数完全互通(都能传 status/billingType/日期/subscriptionId),区别只在返回的字段形状。

六、实例上限:GET /api/app/billing/instance-limit

「我这个套餐还能不能再建一个 Agent」走 GET /api/app/billing/instance-limit

GET /api/app/billing/instance-limit
Authorization: Bearer <你的 JWT>

getInstanceLimitInfo 的逻辑:先按第一节那套「当前有效订阅」的判定(ACTIVE/CANCELLED未到期/TRIAL + currentPeriodEnd>=now,带 relations: ['plan'])找当前订阅;有订阅就用 subscription.plan.instanceLimit;没有订阅就回落找个人版套餐(getPlanByType(PERSONAL))的 instanceLimit,再没有就默认 1。然后 getUserAgentCount 查当前实例数,算出:

  • currentCount:当前已建实例数。
  • limit:上限。-1 表示无限
  • canCreatelimit === -1 || currentCount < limit,能不能再建。
  • remaininglimit === -1 ? -1 : Math.max(0, limit - currentCount),还能建几个。

第四个坑:-1 不是 bug 是「无限」。 如果你的套餐 instanceLimit 配的是 -1(企业版常见),那 limit 返回 -1、canCreate 恒 true、remaining 也是 -1。前端拿到 -1 别当成「负数异常」去报警,要当成「不限量」展示。无订阅用户回落到个人版默认 1 个,remaining 就是 1 - currentCount,建满了 canCreate 变 false、remaining 是 0。

这个接口和 300 讲的实例部署直接联动:部署前调一次 /instance-limitcanCreate,false 就别去打 POST /api/app/instances 了,会被业务层拒掉;想扩容就先去 309 讲的 /payment/create 升级套餐。

七、配额大盘:GET /api/app/billing/quota-usage

想看「CPU/内存/磁盘/文件存储都用掉多少了」走 GET /api/app/billing/quota-usage(别名 GET /quota):

GET /api/app/billing/quota-usage
Authorization: Bearer <你的 JWT>

getUserQuotaUsage 返回的是一个聚合大盘,结构是:

  • plan{ id, name, type },当前套餐基本信息。
  • instances{ max, current, remaining, percentage },实例数用量。max = plan.instanceLimit || 1current 是当前实例数,remaining = Math.max(0, max - current)percentage = max > 0 ? (current/max)*100 : 0
  • resources:资源用量,分三项。
    • cpu{ max, current, unit: 'cores' }max = plan.cpuLimit || 2
    • memory{ max, current, unit: 'MB' }max = plan.memoryLimit ? plan.memoryLimit * 1024 : 2048套餐里 memoryLimit 单位是 GB,这里乘 1024 转成 MB)。
    • disk{ max, current, unit: 'GB' }max = plan.diskLimit || 50
  • fileStorage{ used, limit, unit: 'bytes' },文件存储用量,used/limit 来自 instanceFilesService.getUserStorageStats,单位是字节(bytes),limit 为 null 表示不限。

第五个坑:单位不统一,别直接相加。 同一个 resources 里,CPU 是 cores、内存是 MB、磁盘是 GB,fileStorage 又是 bytes。前端做进度条时每个维度要各自算 current/max 百分比,千万别把内存的 MB 和磁盘的 GB 加在一起。内存的 max 还做了 GB->MB 的换算(×1024),但 currentgetUserPlanUsage 算出来的 MB 值,单位已经对齐,可以直接比。

第六个坑:没有订阅返回 null,不是 0。 getUserQuotaUsage 开头是 if (!subscription || !subscription.plan) return null。所以没订阅的用户调这个接口,data 是 null,不是一堆 0。前端要判空,别解构 null。

八、aliases 速查表与坑位小结

最后把这一组接口的别名和几个核心坑位归个总,方便对照。

别名速查表(都返回同一份数据,历史兼容用):

别名等价于
GET /packageGET /subscription
GET /subscriptions/currentGET /subscription
GET /packages/currentGET /subscription
GET /packagesGET /subscriptions
GET /quotaGET /quota-usage

坑位小结

  1. 价格分/元双轨:DB 存分,/plans/records/invoices 对外给元(price/amount)+ 分(priceCent/amountCent)。跨层对账用 Cent 字段,前端展示用元字段。
  2. 余额读时自愈/balance 异步跑 reconcilePaidRechargeOrders,把已支付未发货的充值订单补发到账。「充值不到账」先调一次 /balance 再看,往往自愈。只对 RECHARGE 类型。
  3. 发票是流水的视图/invoices 内部调 queryBillingRecords,状态做了 succeeded->paid 的映射,paidAt 仅 succeeded 有值。对账用 /records,给用户看发票用 /invoices
  4. 当前订阅含 CANCELLED 未到期/subscription 判定是 ACTIVE/CANCELLED/TRIAL + currentPeriodEnd>=now。关了续费但没到期,仍是当前订阅。
  5. /records 的 endDate 是开区间:单传 endDateLessThan<),当天不包含,要传下个月 1 号。跟 /token-usage 的「自动补 23:59:59」不一样。
  6. instance-limit 的 -1limit=-1 是无限,canCreate 恒 true、remaining=-1,不是异常。
  7. quota-usage 单位混杂:CPU cores / 内存 MB / 磁盘 GB / 文件 bytes,内存 max 还做了 GB->MB 换算。各维度单独算百分比,别混加。无订阅返回 null。

这组「只读账本查询」接口和之前讲过的三条线是互补关系:301 的 /token-usage 是按请求粒度的 token 明细、310 的 /media-usage 是按生成次数的媒体明细、309 的 /payment/create 是写入侧的下单流程,本篇的 plans/subscription/balance/records/invoices/instance-limit/quota-usage 则是只读的「账户大盘」。做财务对账页、做用户中心的额度展示、做充值到账自检,基本就靠这一组接口组合着用。下一篇会接着往对话统计侧走,把 /api/app/chat 的 stats 类接口拆开讲,跟本篇的计费查询不重叠。