fix:统一系统货币单位规则

This commit is contained in:
chengqian committed 2026-06-04 13:52:19 +08:00
1 parent f3b4be9b2a
commit cfc6b2589a
4 files changed
+67 -12

No files matched your search

+43 -5
View File
@@ -272,11 +272,49 @@ wallets 表包含 `frozen_balance` 字段,用于支付中/退款中的金额
| 套餐价格 | **CNY(人民币)** | 管理后台创建套餐时设置的价格为人民币,用户购买套餐使用人民币支付 |
| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币,履约时转换为 USD 入账钱包 |
**换算规则**:
- 管理后台支付设置中配置 CNY → USD 兑换比例(`payment_exchange_rate_cny_to_usd`,默认 0.14)
- 充值履约时调用 `billing.ConvertCNYToUSD(ctx, cnyAmount)` 将 CNY 金额转换为 USD 入账钱包
- 计费引擎全程使用 USD,无需换算
- 前端展示规则:钱包余额/交易记录/计费用 `$`(USD),套餐价格/充值金额/订单金额用 `¥`(CNY)
#### 三层币种固定规则(强约束,写新代码必须遵守)
币种**按层固定**,不允许在层内混用。判断一个金额用什么币种,只看它属于哪一层,不需要逐字段记忆:
| 层 | 表前缀 | 币种 | 说明 |
|----|--------|------|------|
| **订单 / 支付层** | `ord_` | **永远 CNY** | 充值订单、套餐订单、退款,金额一律人民币。`ord_orders.currency` 默认值必须为 `CNY` |
| **钱包 / 计费 / 账本层** | `bil_` | **永远 USD** | 钱包余额、冻结余额、累计充值、交易流水、计费记录、用量、预扣/结算/退款,一律美元 |
| **套餐定价** | `pln_` | **CNY** | 套餐卖的是 token 额度(`monthly_quota_tokens`),价格用人民币,不与 USD 钱包直接比较 |
| **租户等级阈值** | `tnt_tenant_level_configs.cumulative_recharge_threshold` | **USD** | 与 `bil_wallets.cumulative_recharge`(USD)同币种比较 |
**唯一换汇点**:CNY → USD 的转换**只允许发生在充值履约**(`payment.FulfillOrder` 的 `recharge` 分支,调用 `billing.ConvertCNYToUSD`)。除此之外任何地方都不得做币种换算——套餐购买不入钱包不换算,计费引擎全程 USD 不换算。
**新增带金额字段的代码前,先确认它属于哪一层**,按上表取币种;跨层传递金额(如订单金额入钱包)必须经过唯一换汇点。
#### 汇率规则
- 只维护**单一方向**汇率 `payment_exchange_rate_cny_to_usd`(管理后台支付设置配置,默认 0.14)。需要反向(USD→CNY)时**取倒数**,禁止再配置第二个独立汇率,避免两个汇率漂移导致往返不闭合。
- **充值时必须快照汇率**:履约入账时除了写入换算后的 USD,还要持久化「原始 CNY 金额 + 当时汇率 + 入账 USD」,使历史换算可重建。汇率配置变更不得影响已完成订单的对账。
- **退款认原始 CNY 金额**:退款按订单原始人民币金额退回支付渠道,**禁止**用钱包当前 USD 余额按当前汇率反向折算(防止汇率波动产生套利/亏损)。
#### 前端展示规则
- 钱包余额 / 交易流水 / 计费 / 用量 / Token 单价 → `$`(USD)
- 套餐价格 / 充值金额 / 订单金额 / 退款金额 → `¥`(CNY)
- ⚠️ 营收类指标(`total_cost`、`revenue` 等来自 `bil_usage_logs` 的统计)是 **USD**,用 `$`,不要因为面向中国区运营就标成 `¥`。需要人民币营收报表时,在**出报表时**按快照汇率折算,账本本身仍存 USD。
#### 人民币对账 / 导出规则
判断要不要存 CNY,只看**这笔钱有没有真的以人民币发生过**,两种对账答案相反:
| 对账类型 | CNY 是否真实发生 | 做法 |
|----------|------------------|------|
| **现金对账**(充值 / 退款,对支付宝/微信/银行流水) | ✅ 真实发生 | 在**充值订单/充值交易**上快照「原始 CNY 金额 + 当时汇率 + 入账 USD」,CNY 是真相、USD 是派生 |
| **消费对账**(AI 用量 / 计费扣费) | ❌ 从未发生(逐笔均为 USD 计算) | **禁止**在 `bil_usage_logs` 等高频写入表加 CNY 快照字段,导出时折算 |
**为什么消费记录不加 CNY 快照字段**:① 没有真实人民币事件可对,存进去是虚构事实;② `bil_usage_logs` 是全系统写入量最大的表,加派生列存储代价大;③ 逐行存 CNY 再 SUM 会累积舍入误差;④ 制造与 USD 列冲突的第二真相源,反而新增对账问题。
**消费需要展示/导出人民币时**:先按 USD 聚合(`SUM(total_cost)`),**最后乘一次汇率**得到 CNY;报表头注明「所用汇率 + 折算口径 + 仅供参考,非实际支付/开票金额」。汇率用统一的报表汇率(或 `payment_exchange_rate_cny_to_usd` 取倒数),不得逐行各取各的。
**发票 / 税务口径**:合法可开票的人民币金额是用户**实际支付的充值额**(真实 CNY),绝不是「消费额 × 汇率」。租户人民币对账单/发票一律从充值记录出,精确到分、与支付渠道流水一一对应。
> 决策规则:**钱真的以人民币动过 → 存真实 CNY 快照(充值/退款);没动过、只是想看人民币 → 导出时对聚合 USD 折算一次,不落库。**
## 数据库表前缀定义
+1 -1
View File
@@ -70,7 +70,7 @@ func processAutoRenew(ctx context.Context, subscriptionID, tenantID, planID int6
PlanId: planID,
Amount: 0, // will be filled from plan price,
FinalAmount: 0,
Currency: "USD",
Currency: "CNY", // 套餐价为 CNY,订单层一律 CNY(见 CLAUDE.md 三层币种固定规则)
PaymentChannel: "auto_renew",
Status: "pending",
Description: fmt.Sprintf("Auto-renewal for subscription #%d", subscriptionID),
@@ -0,0 +1,17 @@
-- +goose Up
-- 修正订单层币种:按 CLAUDE.md「三层币种固定规则」,ord_ 订单层一律 CNY(充值/套餐均为人民币)。
-- 原 DEFAULT 'USD' 与业务语义相反,且 auto_renew 历史续费订单被错误标记为 USD。
-- 1. 修正默认值:USD -> CNY
ALTER TABLE ord_orders ALTER COLUMN currency SET DEFAULT 'CNY';
-- 2. 补全字段注释,明确单位
COMMENT ON COLUMN ord_orders.currency IS '货币(订单层一律 CNY)';
-- 3. 回填历史错误数据:订单层不应存在 USD,统一修正为 CNY
UPDATE ord_orders SET currency = 'CNY' WHERE currency = 'USD';
-- +goose Down
-- 回滚:恢复原默认值与注释(历史回填的数据不再逆转)
ALTER TABLE ord_orders ALTER COLUMN currency SET DEFAULT 'USD';
COMMENT ON COLUMN ord_orders.currency IS '货币';
+6 -6
View File
@@ -43,8 +43,8 @@ function calcGrowth(current: number, previous: number): { value: number; positiv
function formatMoney(val: any) {
const n = Number(val)
if (!n && n !== 0) return '¥0.00'
return '¥' + n.toFixed(2)
if (!n && n !== 0) return '$0.00'
return '$' + n.toFixed(2)
}
function formatNumber(val: any) {
@@ -234,16 +234,16 @@ function renderTrendChart() {
trendChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['收入 (¥)', '请求数'], bottom: 0 },
legend: { data: ['收入 ($)', '请求数'], bottom: 0 },
grid: { left: 60, right: 60, top: 15, bottom: 36 },
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '收入 (¥)', axisLabel: { formatter: (v: number) => '¥' + v } },
{ type: 'value', name: '收入 ($)', axisLabel: { formatter: (v: number) => '$' + v } },
{ type: 'value', name: '请求数', splitLine: { show: false } },
],
series: [
{
name: '收入 (¥)',
name: '收入 ($)',
type: 'bar',
data: revenues,
barWidth: 12,
@@ -310,7 +310,7 @@ function renderModelChart() {
modelChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 130, right: 50, top: 10, bottom: 20 },
xAxis: { type: 'value', name: '费用 (¥)' },
xAxis: { type: 'value', name: '费用 ($)' },
yAxis: {
type: 'category',
data: names,