556 lines
15 KiB
Markdown
556 lines
15 KiB
Markdown
# 账户冻结和扣费计算逻辑说明
|
||
|
||
## 📋 文档概述
|
||
|
||
本文档详细说明后端账户冻结、Token消费计算、扣费系数应用及余额扣减的完整逻辑。
|
||
|
||
---
|
||
|
||
## 🎯 核心概念定义
|
||
|
||
### 1. 计量单位体系
|
||
|
||
```
|
||
人民币(元) ←→ 积分
|
||
|
||
换算关系:
|
||
- 1 元 = 100 积分
|
||
- 数据库中的 balance、amount、frozen_amount 字段单位均为【积分】
|
||
```
|
||
|
||
### 2. 扣费系数
|
||
|
||
```yaml
|
||
# 配置文件:application.yml
|
||
account:
|
||
deduction:
|
||
coefficient: 2 # 扣费系数,默认2倍
|
||
```
|
||
|
||
**含义:**
|
||
- 实际消耗 1 积分,扣除 2 积分
|
||
- 计算公式:`实际扣费 = 基础费用 × 扣费系数`
|
||
|
||
### 3. 关键数据表字段
|
||
|
||
| 表名 | 字段 | 类型 | 单位 | 说明 |
|
||
|------|------|------|------|------|
|
||
| account | balance | decimal(10,2) | **积分** | 账户总余额 |
|
||
| account | frozen_amount | decimal(10,2) | **积分** | 冻结金额 |
|
||
| account_transaction | amount | decimal(10,2) | **积分** | 交易金额 |
|
||
| account_transaction | before_balance | decimal(10,2) | **积分** | 交易前余额 |
|
||
| account_transaction | after_balance | decimal(10,2) | **积分** | 交易后余额 |
|
||
| model_price | input_per_cent | bigint | **个/分** | 1分钱可购买的输入Token数 |
|
||
| model_price | output_per_cent | bigint | **个/分** | 1分钱可购买的输出Token数 |
|
||
|
||
---
|
||
|
||
## 💰 模型价格配置示例
|
||
|
||
| 厂商 | 模型名称 | 输入价格(元/百万Token) | 输出价格(元/百万Token) | input_per_cent | output_per_cent |
|
||
|------|----------|---------------------|---------------------|----------------|----------------|
|
||
| 阿里巴巴 | Qwen 3.5 Plus | 4.0000 | 12.0000 | 250000 | 83333 |
|
||
| 字节跳动 | 豆包 Lite | 0.6000 | 1.2000 | 1666667 | 833333 |
|
||
| OpenAI | GPT-4o | 18.0000 | 72.0000 | 55556 | 13889 |
|
||
|
||
**配置说明:**
|
||
- `input_per_cent`: 1分钱可以购买的输入Token数量
|
||
- `output_per_cent`: 1分钱可以购买的输出Token数量
|
||
|
||
**计算关系:**
|
||
```
|
||
input_per_cent = 1,000,000 / (input_price × 100)
|
||
output_per_cent = 1,000,000 / (output_price × 100)
|
||
|
||
例如 Qwen 3.5 Plus:
|
||
- input_price = 4.0000 元/百万Token
|
||
- input_per_cent = 1,000,000 / (4.0000 × 100) = 250,000
|
||
```
|
||
|
||
---
|
||
|
||
## 🔢 完整计算逻辑
|
||
|
||
### 阶段一:创建冻结单(预扣费)
|
||
|
||
#### 接口:`POST /api/accountFrozen/frozen`
|
||
|
||
**输入参数:**
|
||
```json
|
||
{
|
||
"sessionId": "会话ID",
|
||
"modelName": "Qwen 3.5 Plus",
|
||
"estimatedInputTokens": 120000,
|
||
"estimatedOutputTokens": 200,
|
||
"frozenType": 1
|
||
}
|
||
```
|
||
|
||
**计算步骤:**
|
||
|
||
```java
|
||
// 1. 查询模型价格配置
|
||
ModelPrice modelPrice = queryByModelName("Qwen 3.5 Plus");
|
||
// 配置:inputPerCent = 250000, outputPerCent = 83333
|
||
|
||
// 2. 计算输入Token费用(向上取整到分)
|
||
long inputFee = estimatedInputTokens / modelPrice.getInputPerCent();
|
||
if (estimatedInputTokens % modelPrice.getInputPerCent() > 0) {
|
||
inputFee += 1; // 不足1分按1分计算
|
||
}
|
||
// 示例:120000 / 250000 = 0,余数 120000 > 0,所以 inputFee = 1 分
|
||
|
||
// 3. 计算输出Token费用(向上取整到分)
|
||
long outputFee = estimatedOutputTokens / modelPrice.getOutputPerCent();
|
||
if (estimatedOutputTokens % modelPrice.getOutputPerCent() > 0) {
|
||
outputFee += 1; // 不足1分按1分计算
|
||
}
|
||
// 示例:200 / 83333 = 0,余数 200 > 0,所以 outputFee = 1 分
|
||
|
||
// 4. 计算基础费用(分)
|
||
// 注意:因为1分=1积分,所以totalFee直接就是积分数量
|
||
long totalFee = inputFee + outputFee;
|
||
// 示例:1 + 1 = 2 分 = 2 积分
|
||
|
||
// 5. 应用扣费系数
|
||
BigDecimal baseAmount = BigDecimal.valueOf(totalFee);
|
||
BigDecimal coefficient = accountDeductionProperties.getCoefficient(); // 从配置文件获取,默认2
|
||
BigDecimal finalFrozenAmount = baseAmount.multiply(coefficient);
|
||
// 示例:2 × 2 = 4 积分
|
||
|
||
// 6. 检查可用余额是否足够
|
||
BigDecimal availableBalance = balance - frozenAmount;
|
||
if (availableBalance < finalFrozenAmount) {
|
||
throw new BizException("余额不足");
|
||
}
|
||
|
||
// 7. 更新账户冻结金额
|
||
account.frozenAmount += finalFrozenAmount;
|
||
```
|
||
|
||
**计算公式总结:**
|
||
|
||
```
|
||
冻结金额(积分)= [⌈输入Token / inputPerCent⌉ + ⌈输出Token / outputPerCent⌉] × 扣费系数
|
||
|
||
其中 ⌈x 表示向上取整
|
||
```
|
||
|
||
**实际案例(扣费系数=2):**
|
||
```
|
||
输入Token: 120,000
|
||
输出Token: 200
|
||
Qwen 3.5 Plus: input_per_cent=250000, output_per_cent=83333
|
||
|
||
计算:
|
||
- inputFee = ⌈120000/250000⌉ = 1 积分
|
||
- outputFee = ⌈200/83333⌉ = 1 积分
|
||
- baseAmount = 2 积分
|
||
- finalFrozenAmount = 2 × 2 = 4 积分
|
||
```
|
||
|
||
---
|
||
|
||
### 阶段二:释放冻结单(实际扣费)
|
||
|
||
#### 接口:`POST /api/accountFrozen/release`
|
||
|
||
**输入参数:**
|
||
```json
|
||
{
|
||
"frozenId": "冻结单ID",
|
||
"usageInputTokens": 121067,
|
||
"usageOutputTokens": 209,
|
||
"usageTotalTokens": 121276
|
||
}
|
||
```
|
||
|
||
**计算步骤:**
|
||
|
||
```java
|
||
// 1. 查询冻结单
|
||
AccountFrozen frozen = getByFrozenId(frozenId);
|
||
// 获取 modelName: "Qwen 3.5 Plus"
|
||
|
||
// 2. 查询模型价格配置
|
||
ModelPrice modelPrice = queryByModelName("Qwen 3.5 Plus");
|
||
// 配置:inputPerCent = 250000, outputPerCent = 83333
|
||
|
||
// 3. 计算实际输入Token费用(向上取整到分)
|
||
long inputFee = usageInputTokens / modelPrice.getInputPerCent();
|
||
if (usageInputTokens % modelPrice.getInputPerCent() > 0) {
|
||
inputFee += 1;
|
||
}
|
||
// 示例:121067 / 250000 = 0,余数 121067 > 0,所以 inputFee = 1 分
|
||
|
||
// 4. 计算实际输出Token费用(向上取整到分)
|
||
long outputFee = usageOutputTokens / modelPrice.getOutputPerCent();
|
||
if (usageOutputTokens % modelPrice.getOutputPerCent() > 0) {
|
||
outputFee += 1;
|
||
}
|
||
// 示例:209 / 83333 = 0,余数 209 > 0,所以 outputFee = 1 分
|
||
|
||
// 5. 计算基础费用(分)
|
||
// 注意:因为1分=1积分,所以totalFee直接就是积分数量
|
||
long totalFee = inputFee + outputFee;
|
||
// 示例:1 + 1 = 2 分 = 2 积分
|
||
|
||
// 6. 应用扣费系数
|
||
BigDecimal baseAmount = BigDecimal.valueOf(totalFee);
|
||
BigDecimal coefficient = accountDeductionProperties.getCoefficient(); // 从配置文件获取,默认2
|
||
BigDecimal finalAmount = baseAmount.multiply(coefficient);
|
||
// 示例:2 × 2 = 4 积分
|
||
|
||
// 7. 释放全部冻结金额
|
||
account.frozenAmount -= frozen.frozenAmount;
|
||
|
||
// 8. 扣减实际消费金额
|
||
if (finalAmount > 0) {
|
||
if (account.balance < finalAmount) {
|
||
account.balance = 0; // 余额不足时设为0
|
||
} else {
|
||
account.balance -= finalAmount;
|
||
}
|
||
}
|
||
|
||
// 9. 生成交易记录
|
||
AccountTransaction transaction = {
|
||
userId: frozen.userId,
|
||
transactionType: 3, // 购买内容
|
||
amount: finalAmount, // 4 积分(已应用扣费系数)
|
||
beforeBalance: 原余额,
|
||
afterBalance: 扣减后余额,
|
||
status: 1, // 成功
|
||
payType: 3, // 余额支付
|
||
businessType: "frozen_release",
|
||
isExpense: 1, // 支出
|
||
inputToken: 121067,
|
||
outputToken: 209,
|
||
totalTokens: 121276,
|
||
modelName: "Qwen 3.5 Plus",
|
||
remark: "冻结单释放扣减: " + frozen.frozenId
|
||
};
|
||
|
||
// 10. 更新冻结单状态
|
||
frozen.status = "FINALIZED";
|
||
frozen.finalAmount = finalAmount;
|
||
```
|
||
|
||
**计算公式总结:**
|
||
|
||
```
|
||
实际扣费(积分)= [⌈实际输入Token / inputPerCent⌉ + 实际输出Token / outputPerCent⌉] × 扣费系数
|
||
```
|
||
|
||
---
|
||
|
||
## ✅ 完整案例验证
|
||
|
||
### 案例数据
|
||
|
||
**用户请求:**
|
||
- 模型:Qwen 3.5 Plus
|
||
- 预估输入Token: 120,000
|
||
- 预估输出Token: 200
|
||
- 实际输入Token: 121,067
|
||
- 实际输出Token: 209
|
||
- 扣费系数:2(配置文件默认值)
|
||
- 用户余额:20 积分
|
||
|
||
### 阶段一:创建冻结单
|
||
|
||
```
|
||
计算过程:
|
||
1. inputFee = ⌈120000/250000⌉ = 1 积分
|
||
2. outputFee = ⌈200/83333⌉ = 1 积分
|
||
3. baseAmount = 1 + 1 = 2 积分
|
||
4. finalFrozenAmount = 2 × 2 = 4 积分(应用扣费系数)
|
||
|
||
账户变化:
|
||
- balance: 20 积分(不变)
|
||
- frozenAmount: 0 + 4 = 4 积分
|
||
- availableBalance: 20 - 4 = 16 积分
|
||
```
|
||
|
||
### 阶段二:释放冻结单
|
||
|
||
```
|
||
计算过程:
|
||
1. inputFee = ⌈121067/250000⌉ = 1 积分
|
||
2. outputFee = ⌈209/83333⌉ = 1 积分
|
||
3. baseAmount = 1 + 1 = 2 积分
|
||
4. finalAmount = 2 × 2 = 4 积分(应用扣费系数)
|
||
|
||
账户变化:
|
||
- frozenAmount: 4 - 4 = 0 积分(释放冻结)
|
||
- balance: 20 - 4 = 16 积分(扣减实际费用)
|
||
|
||
数据库交易记录:
|
||
- transaction_id: 145
|
||
- before_balance: 20.00 积分
|
||
- amount: 4.00 积分
|
||
- after_balance: 16.00 积分
|
||
- input_token: 121067
|
||
- output_token: 209
|
||
- model_name: "Qwen 3.5 Plus"
|
||
```
|
||
|
||
**验算:** 20.00 - 4.00 = 16.00 ✓ 完全正确!
|
||
|
||
---
|
||
|
||
## 📊 完整业务流程图
|
||
|
||
```
|
||
用户发起AI请求
|
||
↓
|
||
【阶段1:创建冻结单】
|
||
↓
|
||
1. 预估Token数量(输入+输出)
|
||
↓
|
||
2. 查询模型价格配置(inputPerCent, outputPerCent)
|
||
↓
|
||
3. 计算预估费用(向上取整到分)
|
||
baseAmount = ⌈estInput/inputPerCent⌉ + ⌈estOutput/outputPerCent⌉
|
||
↓
|
||
4. 应用扣费系数
|
||
finalFrozenAmount = baseAmount × coefficient
|
||
↓
|
||
5. 检查可用余额(balance - frozenAmount >= finalFrozenAmount)
|
||
↓
|
||
6. 冻结金额(frozenAmount += finalFrozenAmount)
|
||
↓
|
||
7. 创建冻结单(status = "RESERVED")
|
||
↓
|
||
AI模型执行完成
|
||
↓
|
||
【阶段2:释放冻结单】
|
||
↓
|
||
1. 获取实际Token使用量
|
||
↓
|
||
2. 查询模型价格配置
|
||
↓
|
||
3. 计算实际费用(向上取整到分)
|
||
baseAmount = ⌈actualInput/inputPerCent⌉ + ⌈actualOutput/outputPerCent⌉
|
||
↓
|
||
4. 应用扣费系数
|
||
finalAmount = baseAmount × coefficient
|
||
↓
|
||
5. 释放冻结金额(frozenAmount -= frozen.frozenAmount)
|
||
↓
|
||
6. 扣减实际消费(balance -= finalAmount)
|
||
↓
|
||
7. 生成交易记录(account_transaction)
|
||
↓
|
||
8. 更新冻结单状态(status = "FINALIZED")
|
||
↓
|
||
返回结果给用户
|
||
```
|
||
|
||
---
|
||
|
||
## ⚠️ 重要说明
|
||
|
||
### 1. 单位统一性
|
||
|
||
**所有涉及金额的字段在数据库中均以【积分】为单位存储:**
|
||
- `account.balance` - 积分
|
||
- `account.frozen_amount` - 积分
|
||
- `account_transaction.amount` - 积分
|
||
- `account_transaction.before_balance` - 积分
|
||
- `account_transaction.after_balance` - 积分
|
||
|
||
### 2. 向上取整规则
|
||
|
||
Token费用计算采用**向上取整到分**的策略:
|
||
```java
|
||
// 不足1分按1分计算
|
||
if (tokens % perCent > 0) {
|
||
fee += 1;
|
||
}
|
||
```
|
||
|
||
**原因:** 避免用户通过拆分请求来规避最小计费单位
|
||
|
||
### 3. 扣费系数应用
|
||
|
||
扣费系数在两个阶段都会应用:
|
||
- **创建冻结单**:预估费用 × 扣费系数
|
||
- **释放冻结单**:实际费用 × 扣费系数
|
||
|
||
**示例(扣费系数=2):**
|
||
```
|
||
基础费用:2 积分(121,067输入Token + 209输出Token)
|
||
实际扣费:2 × 2 = 4 积分
|
||
```
|
||
|
||
### 4. 余额不足处理
|
||
|
||
```java
|
||
// 如果余额不够实际扣减,将balance设置为0
|
||
if (balance.compareTo(finalAmount) < 0) {
|
||
account.setBalance(BigDecimal.ZERO);
|
||
} else {
|
||
account.setBalance(balance.subtract(finalAmount));
|
||
}
|
||
```
|
||
|
||
**原因:** 防止余额出现负数
|
||
|
||
### 5. 数据精度
|
||
|
||
数据库字段定义为 `decimal(10,2)`:
|
||
- 最大值:99,999,999.99 积分 = 999,999.9999 元
|
||
- 小数位数:2位
|
||
- 精度损失:在极端情况下可能存在 0.01 积分的舍入误差
|
||
|
||
---
|
||
|
||
## 📝 前端展示建议
|
||
|
||
### 方案一:以"积分"为单位展示(推荐)
|
||
|
||
```javascript
|
||
// 后端返回的数据(积分)
|
||
const balance = 16; // 积分
|
||
|
||
// 前端显示
|
||
const displayBalance = balance.toFixed(2) + '积分'; // "16.00积分"
|
||
```
|
||
|
||
### 方案二:转换为"元"展示
|
||
|
||
```javascript
|
||
// 后端返回的数据(积分)
|
||
const balance = 16; // 积分
|
||
|
||
// 转换为元显示
|
||
const yuan = (balance / 100).toFixed(2); // "0.16" 元
|
||
const displayText = `${yuan}元`;
|
||
```
|
||
|
||
### 方案三:混合展示(推荐用于明细)
|
||
|
||
```javascript
|
||
// 同时显示元和积分
|
||
const yuan = (balance / 100).toFixed(2);
|
||
const points = balance.toFixed(2);
|
||
const displayText = `${yuan}元(${points}积分)`;
|
||
// 输出:"0.16元(16.00积分)"
|
||
```
|
||
|
||
### 方案四:显示扣费明细
|
||
|
||
```javascript
|
||
// 显示完整的扣费信息
|
||
const baseFee = 2; // 基础费用
|
||
const coefficient = 2; // 扣费系数
|
||
const actualFee = 4; // 实际扣费
|
||
|
||
const displayText = `
|
||
基础费用:${baseFee}积分
|
||
扣费系数:×${coefficient}
|
||
实际扣费:${actualFee}积分(${(actualFee/100).toFixed(2)}元)
|
||
`;
|
||
```
|
||
|
||
---
|
||
|
||
## 🔧 如何调整扣费系数
|
||
|
||
### 修改配置文件
|
||
|
||
**开发环境** (`application-dev.yml`):
|
||
```yaml
|
||
account:
|
||
deduction:
|
||
coefficient: 1.5 # 1.5倍扣费(测试用)
|
||
```
|
||
|
||
**生产环境** (`application-prod.yml`):
|
||
```yaml
|
||
account:
|
||
deduction:
|
||
coefficient: 2 # 2倍扣费
|
||
```
|
||
|
||
### 配置说明
|
||
|
||
- 修改后重启服务即可生效
|
||
- 无需修改代码
|
||
- 支持小数(如 1.5、2.5)
|
||
- 默认值为 2
|
||
|
||
### 不同场景的扣费系数建议
|
||
|
||
| 场景 | 扣费系数 | 说明 |
|
||
|------|----------|------|
|
||
| 测试环境 | 1.0 | 不额外扣费,方便测试 |
|
||
| 开发环境 | 1.5 | 适度扣费,接近真实场景 |
|
||
| 生产环境-普惠模型 | 1.5 | 降低用户成本 |
|
||
| 生产环境-高级模型 | 2.0 | 标准扣费 |
|
||
| 生产环境-旗舰模型 | 2.5 | 高端服务额外扣费 |
|
||
|
||
---
|
||
|
||
## ✅ 验证清单
|
||
|
||
前端可以通过以下方式验证后端计算的正确性:
|
||
|
||
- [ ] 交易记录的 `before_balance - amount = after_balance`
|
||
- [ ] 根据模型价格配置,手动计算 Token 基础费用
|
||
- [ ] 验证向上取整逻辑(不足1分按1分计算)
|
||
- [ ] 检查冻结单的预估费用是否应用了扣费系数
|
||
- [ ] 确认释放后的实际费用是否应用了扣费系数
|
||
- [ ] 验证账户的 `frozen_amount` 在释放后正确减少
|
||
- [ ] 核对扣费系数是否与配置文件一致
|
||
|
||
### 计算验证公式
|
||
|
||
```
|
||
预期费用(积分)= [⌈输入Token/inputPerCent⌉ + 输出Token/outputPerCent] × coefficient
|
||
|
||
验证:
|
||
- before_balance - after_balance = amount ✓
|
||
- amount = 预期费用 ✓
|
||
```
|
||
|
||
---
|
||
|
||
## 🔍 关键代码位置
|
||
|
||
### 后端实现文件
|
||
|
||
1. **扣费配置属性类**
|
||
- 文件:`src/main/java/com/kexue/skills/config/AccountDeductionProperties.java`
|
||
- 作用:读取配置文件中的扣费系数
|
||
|
||
2. **冻结单控制器**
|
||
- 文件:`src/main/java/com/kexue/skills/controller/AccountFrozenController.java`
|
||
- 接口:`POST /api/accountFrozen/frozen`
|
||
- 接口:`POST /api/accountFrozen/release`
|
||
|
||
3. **冻结单服务实现**
|
||
- 文件:`src/main/java/com/kexue/skills/service/impl/AccountFrozenServiceImpl.java`
|
||
- 方法:`createFrozen()` - 第 57-141 行
|
||
- 方法:`releaseFrozen()` - 第 148-283 行
|
||
- 扣费系数应用:第 110-111 行、第 209-210 行
|
||
|
||
4. **配置文件**
|
||
- 文件:`src/main/resources/application.yml`
|
||
- 配置项:`account.deduction.coefficient`
|
||
|
||
---
|
||
|
||
## 📞 联系方式
|
||
|
||
如有疑问,请联系后端开发团队。
|
||
|
||
**文档版本:** v3.0
|
||
**更新时间:** 2026-04-13
|
||
**作者:** 后端开发团队
|
||
**更新说明:**
|
||
- v1.0: 初始版本,基于"元"为单位
|
||
- v2.0: 根据模型价格配置重新验证,发现单位问题
|
||
- v3.0: 引入积分单位和扣费系数,完整说明当前逻辑
|