主程序计费接入

主程序里的计费,不建议自己直接改user.power,而应该统一走AppBillingServiceBaseBillingService

1. 计费模块结构

主程序计费能力在:
packages/core/src/modules/billing/
├── billing.module.ts
├── base-billing.service.ts
├── app-billing.service.ts
└── types.ts
关系:
BillingModule
全局模块,提供计费服务
BaseBillingService
底层能力,负责真正加减积分与记账
AppBillingService
主程序使用的服务,当前主要继承BaseBillingService
主程序里packages/api/src/modules/app.module.ts已经导入了BillingModule,所以大多数主程序 service 里可以直接注入:
@Injectable
export class ReportService { constructor(private readonly appBillingService: AppBillingService) {}
}

2. 为什么必须走计费服务

因为BaseBillingService不只是改一个数字,它还会:
1.
查询用户当前积分
2.
校验余额是否足够
3.
事务里更新user.power
4.
插入account_log
5.
对会员赠送积分做优先扣减
如果你直接改user.power,会丢掉:
余额流水
关联单号
来源信息
会员赠送积分扣减顺序

3.BaseBillingService提供的方法

getUserPower(userId)

读取当前积分余额。
const power = await this.appBillingService.getUserPower(userId);

hasSufficientPower(userId, requiredAmount)

校验余额是否足够。
const ok = await this.appBillingService.hasSufficientPower(userId, 10);
if (!ok) { throw HttpErrorFactory.badRequest("积分不足");
}

deductUserPower(options, entityManager?)

扣减积分。

addUserPower(options, entityManager?)

增加积分。
一个最小可运行示例:
@Injectable
export class FeatureService { constructor(private readonly appBillingService: AppBillingService) {} async run(userId: string, taskNo: string) { await this.appBillingService.deductUserPower({ userId, amount: 3, accountType: ACCOUNT_LOG_TYPE.CHAT_DEC, source: { type: ACCOUNT_LOG_SOURCE.CHAT, source: "报告生成", }, remark: "报告生成消耗", associationNo: taskNo, }); }
}

4. 扣费参数怎么填

扣费参数结构:
await this.appBillingService.deductUserPower({ userId, amount, accountType, source: { type, source: "来源描述", }, remark: "备注", associationNo: "关联单号", associationUserId: "关联用户ID",
});
字段介绍:
userId
被扣费用户
amount
扣减积分
accountType
账户流水类型
source.type
来源大类
source.source
可读的来源名称
remark
备注
associationNo
关联单号、会话号、订单号、任务号
associationUserId
操作人或关联人

5. 加积分参数怎么填

await this.appBillingService.addUserPower({ userId, amount, accountType, source: { type, source: "来源描述", }, remark: "备注", associationNo: "订单号", expireAt, subscriptionId,
});
仅加积分时才有的附加字段:
expireAt
过期时间,会员赠送积分常用
subscriptionId
关联会员订阅

6.ACCOUNT_LOG_SOURCEACCOUNT_LOG_TYPE

常见来源:
ACCOUNT_LOG_SOURCE.RECHARGE
ACCOUNT_LOG_SOURCE.SYSTEM
ACCOUNT_LOG_SOURCE.CHAT
ACCOUNT_LOG_SOURCE.AGENT_CHAT
ACCOUNT_LOG_SOURCE.PLUGIN
ACCOUNT_LOG_SOURCE.MEMBERSHIP_GIFT
ACCOUNT_LOG_SOURCE.CARD_KEY_REDEEM
常见类型:
ACCOUNT_LOG_TYPE.RECHARGE_INC
ACCOUNT_LOG_TYPE.RECHARGE_GIVE_INC
ACCOUNT_LOG_TYPE.RECHARGE_DEC
ACCOUNT_LOG_TYPE.SYSTEM_MANUAL_INC
ACCOUNT_LOG_TYPE.SYSTEM_MANUAL_DEC
ACCOUNT_LOG_TYPE.CHAT_DEC
ACCOUNT_LOG_TYPE.AGENT_CHAT_DEC
ACCOUNT_LOG_TYPE.AGENT_GUEST_CHAT_DEC
ACCOUNT_LOG_TYPE.PLUGIN_DEC
ACCOUNT_LOG_TYPE.MEMBERSHIP_GIFT_INC
ACCOUNT_LOG_TYPE.MEMBERSHIP_GIFT_DEC
ACCOUNT_LOG_TYPE.MEMBERSHIP_GIFT_EXPIRED
ACCOUNT_LOG_TYPE.CARD_KEY_REDEEM_INC
选择原则:
主程序自己扣用户积分:
用与业务场景最接近的ACCOUNT_LOG_TYPE
source.type选大类
source.source写清楚业务名,方便财务和运营排查

7. 主程序常见计费场景

场景一:对话前先校验最低余额

这个模式在chat-billing.handler.tsagent-billing.ts里都有。
const currentPower = await this.appBillingService.getUserPower(userId);
const minRequired = Math.ceil((estimatedTokens / billingRule.tokens) * billingRule.power); if (currentPower < minRequired) { throw HttpErrorFactory.badRequest("积分不足,充值后重试");
}
适合:
AI 对话
智能体执行
提交任务前先校验余额

场景二:按实际消耗扣费

await this.appBillingService.deductUserPower({ userId, amount, accountType: ACCOUNT_LOG_TYPE.CHAT_DEC, source: { type: ACCOUNT_LOG_SOURCE.CHAT, source: "基本对话", }, remark: "基本对话消耗", associationNo: conversationId,
});
适合:
大模型 token 消耗
图片生成
文档解析
报告生成

场景三:系统后台手动调积分

项目内user.service.ts就是这么做的:
await this.appBillingService.addUserPower({ userId, amount: dto.amount, accountType: ACCOUNT_LOG_TYPE.SYSTEM_MANUAL_INC, source: { type: ACCOUNT_LOG_SOURCE.SYSTEM, source: "系统操作", }, remark: "系统调整用户积分", associationUserId: currentUser.id,
});
扣减则改成:
deductUserPower
ACCOUNT_LOG_TYPE.SYSTEM_MANUAL_DEC

场景四:充值到账

充值回调里建议和订单状态更新放一个事务:
await this.userRepository.manager.transaction(async (entityManager) => { await this.appBillingService.addUserPower( { userId: order.userId, amount: power, accountType: ACCOUNT_LOG_TYPE.RECHARGE_INC, source: { type: ACCOUNT_LOG_SOURCE.RECHARGE, source: "用户充值", }, remark: "充值成功", associationNo: order.orderNo, }, entityManager, ); await entityManager.update(RechargeOrder, order.id, { payStatus: 1, payTime: new Date, });
});

场景五:会员赠送积分

await this.appBillingService.addUserPower({ userId, amount: giftPower, accountType: ACCOUNT_LOG_TYPE.MEMBERSHIP_GIFT_INC, source: { type: ACCOUNT_LOG_SOURCE.MEMBERSHIP_GIFT, source: "会员赠送", }, remark: "会员周期赠送积分", expireAt, subscriptionId,
});
这个场景非常重要,因为BaseBillingService在扣费时会优先扣:
1.
未过期的会员赠送积分
2.
且优先扣最早过期的赠送积分
3.
完成时再体现为用户总积分减少

8. 事务里的正确用法

如果一个业务流程里既要扣积分,又要落业务数据,建议放一个事务,并把entityManager传进去:
await this.userRepository.manager.transaction(async (entityManager) => { await this.appBillingService.deductUserPower( { userId, amount: 5, accountType: ACCOUNT_LOG_TYPE.CHAT_DEC, source: { type: ACCOUNT_LOG_SOURCE.CHAT, source: "报告生成", }, remark: "报告生成消耗", associationNo: taskNo, }, entityManager, ); await entityManager.insert(TaskEntity, { userId, taskNo, status: "done", });
});
这样能保证:
扣费成功但业务落库失败时回滚
业务落库成功但扣费失败时也回滚

9. 主程序计费最佳实践

不要直接user.power = user.power - x
不要只记订单,不记account_log
预校验余额与实际扣费建议分两步
associationNo尽量传业务单号,方便排查
source.source写清楚具体功能,不要只写“系统”
涉及订单、任务、订阅联动时尽量放事务里

319 篇文档 · 内容同步自官方帮助中心