From cfc6b2589acddcf8311c680d69442e637f5f45d9 Mon Sep 17 00:00:00 2001 From: chengqian Date: Thu, 4 Jun 2026 13:52:19 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E7=BB=9F=E4=B8=80=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E8=B4=A7=E5=B8=81=E5=8D=95=E4=BD=8D=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 48 +++++++++++++++++-- internal/logic/task/auto_renew.go | 2 +- ...000009_fix_ord_orders_currency_default.sql | 17 +++++++ web/admin/src/views/DashboardPage.vue | 12 ++--- 4 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 migrations/000009_fix_ord_orders_currency_default.sql diff --git a/CLAUDE.md b/CLAUDE.md index 1ef611b..3c42f7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 折算一次,不落库。** ## 数据库表前缀定义 diff --git a/internal/logic/task/auto_renew.go b/internal/logic/task/auto_renew.go index d630cae..8d7be80 100644 --- a/internal/logic/task/auto_renew.go +++ b/internal/logic/task/auto_renew.go @@ -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), diff --git a/migrations/000009_fix_ord_orders_currency_default.sql b/migrations/000009_fix_ord_orders_currency_default.sql new file mode 100644 index 0000000..9b85906 --- /dev/null +++ b/migrations/000009_fix_ord_orders_currency_default.sql @@ -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 '货币'; diff --git a/web/admin/src/views/DashboardPage.vue b/web/admin/src/views/DashboardPage.vue index 4500c7f..3958939 100644 --- a/web/admin/src/views/DashboardPage.vue +++ b/web/admin/src/views/DashboardPage.vue @@ -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,