# 账户冻结和扣费计算逻辑说明

## 📋 文档概述

本文档详细说明后端账户冻结、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: 引入积分单位和扣费系数，完整说明当前逻辑
