充值到账了余额却没动?想知道当前套餐还能开几个实例、账单流水和发票去哪拉?这篇把 /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 参数(比如 wechat、alipay、apple_iap),再做一道内存过滤--salesChannels 为空或 null 的套餐视为「全渠道可用」,照常返回;salesChannels 非空的,只有数组里包含你传的这个 channel 才返回。这套设计是为了让苹果内购渠道只露出挂了 appleProductIdMonthly/Yearly 的套餐,而微信/支付宝渠道看到全量。
返回的每个套餐对象,经过 normalizePlanPriceForApp 归一化之后,字段大致是:
id/name/code/type:套餐主键、展示名、业务码(如personal、team)、类型枚举。price/priceMonthly/priceYearly:这里的单位是元,不是分。下面单说这个坑。priceCent/priceMonthlyCent/priceYearlyCent:原始的分值,normalizePlanPriceForApp主动塞进来的,方便你对账。instanceLimit/cpuLimit/memoryLimit/diskLimit:套餐里能开几个实例、几核 CPU、几 GB 内存、几 GB 磁盘的上限。maxInstances/maxCpuCores/maxMemoryMb/maxDiskGb:带兜底链的上限值,maxInstances ?? instanceLimit ?? 1、maxCpuCores ?? cpuLimit ?? 2、maxMemoryMb ?? (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/:id,getPlanById 按 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 = ACTIVE且currentPeriodEnd >= nowstatus = CANCELLED且currentPeriodEnd >= nowstatus = TRIAL且currentPeriodEnd >= now
注意第二条:已取消但还在有效期内的订阅,仍然算「当前订阅」。这是「这个月已经关了自动续费、但本月还没到期、所以还能继续用」的语义。只有 currentPeriodEnd 过了之后,这条订阅才真正不再是「当前」。EXPIRED 和 SUSPENDED 不在这三个条件里,所以不会被当作当前订阅返回。没有匹配的订阅就返回 null(响应体里 data/package 为 null)。
返回的对象是 mapSubscriptionToResponse 包出来的,里面会嵌一个 plan 字段(套餐详情,同样经过价格归一化),还有 currentPeriodStart / currentPeriodEnd / startedAt / autoRenew / status / paymentProvider 这些。
别名一堆,别以为接口重复了。 这个 controller 里塞了一组历史兼容别名:
GET /package=GET /subscriptionGET /subscriptions/current=GET /subscriptionGET /packages/current=GET /subscription
它们内部直接调 getSubscription(req),返回结构一模一样。所以你在前端代码里看到 /packages/current、在老文档里看到 /subscription、在某个 SDK 里看到 /package,是同一个东西,不用纠结用哪个。
想看订阅历史(包括已经过期、已取消的所有订阅),走 GET /api/app/billing/subscriptions:
GET /api/app/billing/subscriptions
Authorization: Bearer <你的 JWT>
getUserSubscriptions 按 userId 查全部、按 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=RECHARGE 且 status=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)。
第二个坑:日期筛选的单边行为。 queryBillingRecords 对 createdAt 的处理是:
- 两个都传:
Between(start, end),闭区间[start, end)的语义(TypeORMBetween是含两端,但因为 end 通常传的是日期不带时分秒,实际效果看下一条)。 - 只传
startDate:MoreThanOrEqual(start),从这天往后所有记录。 - 只传
endDate:LessThan(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。description:planName || '套餐服务',没有套餐名就兜底成「套餐服务」。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 更原始(有 billingType、failureMessage、退款字段);如果是要给用户一个「发票列表」的视图,用 /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表示无限。canCreate:limit === -1 || currentCount < limit,能不能再建。remaining:limit === -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-limit 看 canCreate,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 || 1,current是当前实例数,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),但 current 是 getUserPlanUsage 算出来的 MB 值,单位已经对齐,可以直接比。
第六个坑:没有订阅返回 null,不是 0。 getUserQuotaUsage 开头是 if (!subscription || !subscription.plan) return null。所以没订阅的用户调这个接口,data 是 null,不是一堆 0。前端要判空,别解构 null。
八、aliases 速查表与坑位小结
最后把这一组接口的别名和几个核心坑位归个总,方便对照。
别名速查表(都返回同一份数据,历史兼容用):
| 别名 | 等价于 |
|---|---|
GET /package | GET /subscription |
GET /subscriptions/current | GET /subscription |
GET /packages/current | GET /subscription |
GET /packages | GET /subscriptions |
GET /quota | GET /quota-usage |
坑位小结:
- 价格分/元双轨:DB 存分,
/plans、/records、/invoices对外给元(price/amount)+ 分(priceCent/amountCent)。跨层对账用 Cent 字段,前端展示用元字段。 - 余额读时自愈:
/balance异步跑reconcilePaidRechargeOrders,把已支付未发货的充值订单补发到账。「充值不到账」先调一次/balance再看,往往自愈。只对 RECHARGE 类型。 - 发票是流水的视图:
/invoices内部调queryBillingRecords,状态做了succeeded->paid的映射,paidAt仅 succeeded 有值。对账用/records,给用户看发票用/invoices。 - 当前订阅含 CANCELLED 未到期:
/subscription判定是 ACTIVE/CANCELLED/TRIAL +currentPeriodEnd>=now。关了续费但没到期,仍是当前订阅。 /records的 endDate 是开区间:单传endDate走LessThan(<),当天不包含,要传下个月 1 号。跟/token-usage的「自动补 23:59:59」不一样。- instance-limit 的 -1:
limit=-1是无限,canCreate恒 true、remaining=-1,不是异常。 - 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 类接口拆开讲,跟本篇的计费查询不重叠。