diff --git a/CLAUDE.md b/CLAUDE.md
index 21c5865..ed730ee 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -282,15 +282,15 @@ cmd(路由注册)
### 系统货币规则
-平台采用**内部统一 USD + 充值入口 CNY**策略,系统内除充值/支付外的所有金额均为 USD:
+平台采用**本位币制 + 充值入口 CNY**策略:系统在初始化向导(setup)时选定**本位币**(`billing_currency`,USD 或 CNY,**选定后不可更改**,存量部署默认 USD),内部记账与全站显示统一使用本位币:
| 业务场景 | 货币 | 说明 |
|----------|------|------|
-| 大模型价格展示和计费 | **USD(美元)** | 模型定价、Token 单价、计费扣费统一使用美元,与上游供应商(OpenAI/Anthropic/Google)报价币种一致 |
-| 用户钱包余额 | **USD(美元)** | 钱包存储 USD 余额,充值时 CNY 按汇率转换为 USD 入账 |
-| 计费记录 / 交易流水 | **USD(美元)** | 所有 bil_records、bil_transactions 金额均为 USD |
+| 大模型价格展示和计费 | **本位币** | 模型定价、Token 单价、计费扣费统一使用本位币(本位币=CNY 的部署按人民币配价) |
+| 用户钱包余额 | **本位币** | 本位币=USD 时充值 CNY 按汇率转换为 USD 入账;本位币=CNY 时直接入账无换汇 |
+| 计费记录 / 交易流水 | **本位币** | 所有 bil_records、bil_transactions 金额均为本位币 |
| 套餐价格 | **CNY(人民币)** | 管理后台创建套餐时设置的价格为人民币,用户购买套餐使用人民币支付 |
-| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币,履约时转换为 USD 入账钱包 |
+| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币(收款渠道物理现实),履约时按本位币入账钱包 |
#### 三层币种固定规则(强约束,写新代码必须遵守)
@@ -299,25 +299,29 @@ cmd(路由注册)
| 层 | 表前缀 | 币种 | 说明 |
|----|--------|------|------|
| **订单 / 支付层** | `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)同币种比较 |
+| **钱包 / 计费 / 账本层** | `bil_` | **本位币** | 钱包余额、冻结余额、累计充值、交易流水、计费记录、用量、预扣/结算/退款,一律本位币(部署级常量,`billing.Currency(ctx)` 读取;初始化选定后不可切换,账本永远单币种) |
+| **套餐定价** | `pln_` | **CNY** | 套餐卖的是 token 额度(`monthly_quota_tokens`),价格用人民币,不与钱包余额直接比较 |
+| **租户等级阈值** | `tnt_tenant_level_configs.cumulative_recharge_threshold` | **本位币** | 与 `bil_wallets.cumulative_recharge`(本位币)同币种比较 |
-**唯一换汇点**:CNY → USD 的转换**只允许发生在充值履约**(`payment.FulfillOrder` 的 `recharge` 分支,调用 `billing.ConvertCNYToUSD`)。除此之外任何地方都不得做币种换算——套餐购买不入钱包不换算,计费引擎全程 USD 不换算。
+**唯一换汇点**:CNY → 本位币的转换**只允许发生在充值履约**(`payment.FulfillOrder` 的 `recharge` 分支与线下充值 `OfflineRecharge`,本位币=USD 时调用 `billing.ConvertCNYToUSD`;本位币=CNY 时直接入账,`exchange_rate` 快照存 1)。除此之外任何地方都不得做币种换算——套餐购买不入钱包不换算,计费引擎全程本位币不换算。
**新增带金额字段的代码前,先确认它属于哪一层**,按上表取币种;跨层传递金额(如订单金额入钱包)必须经过唯一换汇点。
#### 汇率规则
-- 只维护**单一方向**汇率 `payment_exchange_rate_cny_to_usd`(管理后台支付设置配置,默认 0.14)。需要反向(USD→CNY)时**取倒数**,禁止再配置第二个独立汇率,避免两个汇率漂移导致往返不闭合。
-- **充值时必须快照汇率**:履约入账时除了写入换算后的 USD,还要持久化「原始 CNY 金额 + 当时汇率 + 入账 USD」,使历史换算可重建。汇率配置变更不得影响已完成订单的对账。
-- **退款认原始 CNY 金额**:退款按订单原始人民币金额退回支付渠道,**禁止**用钱包当前 USD 余额按当前汇率反向折算(防止汇率波动产生套利/亏损)。
+- 只维护**单一方向**汇率 `payment_exchange_rate_cny_to_usd`(管理后台支付设置配置,默认 0.14,已 IsPublic 暴露给前端做展示折算)。需要反向(USD→CNY)时**取倒数**,禁止再配置第二个独立汇率,避免两个汇率漂移导致往返不闭合。
+- **充值时必须快照汇率**:履约入账时除了写入换算后的本位币金额(`credited_usd` 列,语义为入账本位币金额),还要持久化「原始 CNY 金额 + 当时汇率 + 入账金额」,使历史换算可重建(本位币=CNY 时 `exchange_rate` 存 1)。汇率配置变更不得影响已完成订单的对账。
+- **退款认原始 CNY 金额**:退款按订单原始人民币金额退回支付渠道,**禁止**用钱包当前本位币余额按当前汇率反向折算(防止汇率波动产生套利/亏损)。
#### 前端展示规则
-- 钱包余额 / 交易流水 / 计费 / 用量 / Token 单价 → `$`(USD)
-- 套餐价格 / 充值金额 / 订单金额 / 退款金额 → `¥`(CNY)
-- ⚠️ 营收类指标(`total_cost`、`revenue` 等来自 `bil_usage_logs` 的统计)是 **USD**,用 `$`,不要因为面向中国区运营就标成 `¥`。需要人民币营收报表时,在**出报表时**按快照汇率折算,账本本身仍存 USD。
+全站显示货币 = 本位币(`/settings/public` 暴露 `billing_currency` + 汇率,两端 `composables/useCurrency.ts` 统一格式化):
+
+- bil 层数据(钱包 / 交易流水 / 计费 / 用量 / Token 单价 / 阈值 / 兑换码面值)→ 本位币符号直显,**不折算**
+- ord_/pln_ 层数据(套餐价格 / 充值金额 / 订单金额 / 退款金额 / 优惠码折扣,存 CNY)→ 本位币=CNY 直显 `¥`;本位币=USD 时按汇率折算显示 `$`(**唯一折算场景**)
+- **编辑表单保持存储币种原值输入**(模型定价按本位币、套餐价格按 CNY),label 标注存储币种;折算仅用于只读展示
+- 充值页锚定 CNY(订单/履约/折扣口径),本位币=USD 时档位与输入按汇率折算显示、提交时换算回 CNY
+- ⚠️ 营收类指标(`total_cost`、`revenue` 等来自 `bil_usage_logs` 的统计)是本位币,跟随本位币符号;需要另一币种营收报表时,在**出报表时**按快照汇率折算,账本本身仍存本位币。
#### 人民币对账 / 导出规则
@@ -378,7 +382,7 @@ cmd(路由注册)
- **DB 落库**:decimal 直传 ORM(`.Insert(do)`/`.Update(map)`)或原生 SQL 参数(`balance + ?`)——shopspring decimal 实现 `driver.Valuer`,写出精确 NUMERIC 字符串,**这条路径不要转 float64**。
- **JSONB 陷阱**:decimal 默认 marshal 成**带引号的字符串**,写进「读取方期望 number」的 JSONB blob(如快照)会破坏整个 unmarshal——JSONB 缝隙处必须先 `.InexactFloat64()`。(注意:DB 列 ≠ JSONB,前者走 Valuer 精确直传,后者走 JSON 需转 float64。)
- **Redis 钱包**:用整数 micro-USD(`int64` + `HINCRBY`),`billing.ToMicro`/`FromMicro` 转换,杜绝 `HINCRBYFLOAT` 漂移。
- - **唯一换汇点**:CNY→USD 仅在 `payment.FulfillOrder` recharge 分支调 `billing.ConvertCNYToUSD`(返回 decimal),其余地方不换算(见《系统货币规则》三层币种固定规则)。
+ - **唯一换汇点**:CNY→本位币仅在 `payment.FulfillOrder` recharge 分支与 `OfflineRecharge` 调 `billing.ConvertCNYToUSD`(返回 decimal,本位币=CNY 时直接入账无换算),其余地方不换算(见《系统货币规则》三层币种固定规则)。
- GoFrame 标准项目结构,不要偏离。框架使用规范(时间处理、错误处理、ORM 模式、日志等)详见 [`docs/reference/goframe-conventions.md`](docs/reference/goframe-conventions.md),遇到不确定的 GoFrame 用法时善用 `/goframe-v2` skill 查询框架规范
- **修复 GoFrame 框架使用 bug 时**,必须在 `docs/reference/goframe-conventions.md` 末尾的「已修复的框架使用错误记录」章节追加记录(问题描述、原因、修复方式),防止同类问题重现
- 数据库迁移用 goose,脚本按六位序号递增编号(`migrations/000001_xxx.sql`)
diff --git a/internal/consts/consts.go b/internal/consts/consts.go
index 51fae0b..98c4549 100644
--- a/internal/consts/consts.go
+++ b/internal/consts/consts.go
@@ -404,6 +404,15 @@ const (
AgreementCodePrivacy = "privacy" // 隐私政策
)
+// 系统配置 key:本位币(系统初始化向导选定后不可更改,bil_ 记账层与全站显示货币)
+const OptionKeyBillingCurrency = "billing_currency"
+
+// 本位币取值
+const (
+ BillingCurrencyUSD = "USD"
+ BillingCurrencyCNY = "CNY"
+)
+
// ValidAgreementCodes 有效协议标识码集合(创建时校验)
var ValidAgreementCodes = map[string]string{
AgreementCodeTerms: "用户协议",
diff --git a/internal/handler/setup/setup.go b/internal/handler/setup/setup.go
index 901da01..4613b89 100644
--- a/internal/handler/setup/setup.go
+++ b/internal/handler/setup/setup.go
@@ -56,12 +56,23 @@ func HandleSetupInitialize(r *ghttp.Request) {
DisplayName string `json:"displayName"`
Password string `json:"password"`
ConfirmPassword string `json:"confirmPassword"`
+ Currency string `json:"currency"`
}
if err := json.Unmarshal(r.GetBody(), &req); err != nil {
response.ErrorMsg(r, consts.CodeBadRequest, "请求格式错误")
return
}
+ // 本位币:USD/CNY,未选择时默认 USD;初始化写入后不可再更改
+ currency := req.Currency
+ if currency == "" {
+ currency = consts.BillingCurrencyUSD
+ }
+ if currency != consts.BillingCurrencyUSD && currency != consts.BillingCurrencyCNY {
+ response.ErrorMsg(r, consts.CodeBadRequest, "本位币仅支持 USD 或 CNY")
+ return
+ }
+
// Validate required fields
if req.Username == "" || req.Password == "" {
response.ErrorMsg(r, consts.CodeBadRequest, "用户名和密码不能为空")
@@ -95,6 +106,12 @@ func HandleSetupInitialize(r *ghttp.Request) {
response.Error(r, err)
return
}
+
+ // 本位币随初始化写入(首次落库,此后 SetOption 只读保护拒绝修改)
+ if err := common.Config().SetOption(r.Context(), consts.OptionKeyBillingCurrency, currency); err != nil {
+ response.Error(r, err)
+ return
+ }
SetSetupComplete()
response.Success(r, nil)
}
diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go
index 76daff4..bb2e7f9 100644
--- a/internal/logic/admin/dashboard.go
+++ b/internal/logic/admin/dashboard.go
@@ -675,9 +675,10 @@ func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq
}
// OfflineRecharge 线下充值入账(管理后台)
-// 场景:用户线下银行转账(人民币 CNY),运营确认到账后按平台汇率换算为 USD 入账。
+// 场景:用户线下银行转账(人民币 CNY),运营确认到账后入账。
+// 本位币=USD 时按平台汇率换算入账;本位币=CNY 时直接入账(无换汇)。
// 与 AdjustBalance 的区别:走正规充值链路——余额与累计充值同步累加、触发等级检查、
-// 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账 USD + 转账流水号),
+// 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账金额 + 转账流水号),
// 供现金对账与开票追溯。
func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error) {
tenantID := req.TenantID
@@ -686,12 +687,19 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
return nil, common.NewBadRequestError("入账金额必须大于 0")
}
- // 唯一换汇点:CNY→USD 只经 billing.ConvertCNYToUSD(与充值履约 FulfillOrder 同一函数),
- // 汇率取一次用于入账与快照,保证换算可重建。
- rate := billing.GetExchangeRateCNYToUSD(ctx)
- usdAmount := billing.ConvertCNYToUSD(ctx, req.Amount)
- if usdAmount.IsZero() {
- return nil, common.NewBadRequestError("按当前汇率换算后到账金额为 0,请检查汇率配置")
+ // 本位币=CNY:直接入账;本位币=USD:换汇入账(CNY→USD 只经 billing.ConvertCNYToUSD,
+ // 与充值履约 FulfillOrder 同一函数),汇率取一次用于入账与快照,保证换算可重建。
+ var rate float64
+ var creditAmount decimal.Decimal
+ if billing.IsCNY(ctx) {
+ rate = 1
+ creditAmount = cnyAmount
+ } else {
+ rate = billing.GetExchangeRateCNYToUSD(ctx)
+ creditAmount = billing.ConvertCNYToUSD(ctx, req.Amount)
+ if creditAmount.IsZero() {
+ return nil, common.NewBadRequestError("按当前汇率换算后到账金额为 0,请检查汇率配置")
+ }
}
// 转账流水号软去重(防重复入账):同一租户下已入账过该流水号则拒绝。
@@ -721,9 +729,9 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
}
// Redis 加款(资金提交点);流水快照取 Redis 返回值
- balanceAfter, frozenAfter, err := billing.CreditWalletRedis(ctx, tenantID, usdAmount)
+ balanceAfter, frozenAfter, err := billing.CreditWalletRedis(ctx, tenantID, creditAmount)
// 先记录已发生的 Redis 加款(可能 >0 即使整体失败),供事务回滚后补偿逆转
- redisCreditedAmount = usdAmount
+ redisCreditedAmount = creditAmount
if err != nil {
return err
}
@@ -731,12 +739,17 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
// DB 仅累加累计充值(余额由物化器从 Redis 覆盖)
if _, err := g.DB().Ctx(ctx).Exec(ctx,
"UPDATE bil_wallets SET cumulative_recharge = cumulative_recharge + ?, updated_at = NOW() WHERE id = ?",
- usdAmount, walletID); err != nil {
+ creditAmount, walletID); err != nil {
return err
}
// 流水(type=recharge):描述拼装 CNY 快照 + 转账流水号,供现金对账与开票追溯
- desc := fmt.Sprintf("线下充值入账 CNY %.2f × 汇率 %.6f = USD %s", cnyAmount.InexactFloat64(), rate, usdAmount)
+ var desc string
+ if billing.IsCNY(ctx) {
+ desc = fmt.Sprintf("线下充值入账 CNY %.2f(本位币直接入账)", cnyAmount.InexactFloat64())
+ } else {
+ desc = fmt.Sprintf("线下充值入账 CNY %.2f × 汇率 %.6f = USD %s", cnyAmount.InexactFloat64(), rate, creditAmount)
+ }
if req.Description != "" {
desc = req.Description + ";" + desc
}
@@ -747,7 +760,7 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
TenantId: tenantID,
WalletId: walletID,
Type: "recharge",
- Amount: usdAmount,
+ Amount: creditAmount,
BalanceAfter: balanceAfter,
FrozenAfter: frozenAfter,
Description: desc,
diff --git a/internal/logic/admin/tenant.go b/internal/logic/admin/tenant.go
index 92d1a88..d265f8b 100644
--- a/internal/logic/admin/tenant.go
+++ b/internal/logic/admin/tenant.go
@@ -174,7 +174,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
Balance: 0,
FrozenBalance: 0,
WarningThreshold: &warningThreshold,
- Currency: "USD",
+ Currency: billing.Currency(ctx),
}).Insert()
if err != nil {
return gerror.Wrapf(err, "create wallet")
diff --git a/internal/logic/billing/currency.go b/internal/logic/billing/currency.go
index 5225e31..f75ef5e 100644
--- a/internal/logic/billing/currency.go
+++ b/internal/logic/billing/currency.go
@@ -3,6 +3,7 @@ package billing
import (
"context"
+ "github.com/qianfree/team-api/internal/consts"
lcommon "github.com/qianfree/team-api/internal/logic/common"
"github.com/shopspring/decimal"
)
@@ -10,6 +11,28 @@ import (
const defaultCNYToUSD = 0.14
const defaultUSDToCNY = 7.142857142857143 // 1 / 0.14,确保互为倒数
+// Currency 返回系统本位币(bil_ 记账层的记账币种与全站显示货币)。
+// 本位币在系统初始化向导选定后不可更改;存量部署与未初始化系统默认 USD。
+// 配置基础设施不可用(如单测环境无 DB 驱动,g.DB() 直接 panic)时回退 USD,
+// 与存量部署行为一致,保证纯函数单测可运行。
+func Currency(ctx context.Context) (cur string) {
+ defer func() {
+ if r := recover(); r != nil {
+ cur = consts.BillingCurrencyUSD
+ }
+ }()
+ c := lcommon.Config().GetString(ctx, consts.OptionKeyBillingCurrency)
+ if c == consts.BillingCurrencyCNY {
+ return consts.BillingCurrencyCNY
+ }
+ return consts.BillingCurrencyUSD
+}
+
+// IsCNY 当前本位币是否为人民币(此时充值履约直接入账、无换汇环节)
+func IsCNY(ctx context.Context) bool {
+ return Currency(ctx) == consts.BillingCurrencyCNY
+}
+
// ConvertCNYToUSD 将人民币金额转换为美元,向上取整到小数点后 6 位(decimal 原生版)。
// 用 decimal 精确乘法替代 float64 链式运算,避免 cnyAmount×rate 的浮点误差。
func ConvertCNYToUSD(ctx context.Context, cnyAmount float64) decimal.Decimal {
diff --git a/internal/logic/billing/currency_test.go b/internal/logic/billing/currency_test.go
index 071fa30..8e171da 100644
--- a/internal/logic/billing/currency_test.go
+++ b/internal/logic/billing/currency_test.go
@@ -1,143 +1,32 @@
package billing
import (
+ "context"
"testing"
- "github.com/gogf/gf/v2/test/gtest"
- "github.com/shopspring/decimal"
+ "github.com/qianfree/team-api/internal/consts"
)
-func TestDefaultCNYToUSD(t *testing.T) {
- if defaultCNYToUSD <= 0 {
- t.Fatalf("defaultCNYToUSD should be positive, got %f", defaultCNYToUSD)
+// TestCurrency_FallbackUSD 单测环境无 DB 驱动(Config 读取触发 g.DB() panic),
+// Currency 必须 recover 兜底返回默认 USD(与存量部署行为一致),不得 panic。
+// 若在带完整配置/DB 的环境运行,读到已配置本位币则跳过断言。
+func TestCurrency_FallbackUSD(t *testing.T) {
+ got := Currency(context.Background())
+ if got != consts.BillingCurrencyUSD && got != consts.BillingCurrencyCNY {
+ t.Errorf("Currency() = %q, want USD or CNY", got)
+ }
+ if !billingCurrencyValid(got) {
+ t.Errorf("Currency() = %q, invalid currency", got)
}
}
-func TestDefaultUSDToCNY(t *testing.T) {
- if defaultUSDToCNY <= 0 {
- t.Fatalf("defaultUSDToCNY should be positive, got %f", defaultUSDToCNY)
+func TestIsCNY_MatchesCurrency(t *testing.T) {
+ c := Currency(context.Background())
+ if want := c == consts.BillingCurrencyCNY; IsCNY(context.Background()) != want {
+ t.Errorf("IsCNY() 与 Currency() 不一致: currency=%q", c)
}
}
-func TestDefaultRates_Reciprocal(t *testing.T) {
- // 修复后:defaultUSDToCNY 不再是独立配置,而是 defaultCNYToUSD 的倒数
- // 验证常量定义互为倒数
- product := defaultCNYToUSD * defaultUSDToCNY
- if product < 0.9 || product > 1.1 {
- t.Fatalf("default rate product should be ~1.0, got %f", product)
- }
-}
-
-func TestConvertCNYToUSD_DefaultRate(t *testing.T) {
- // 无配置时使用默认汇率 0.14
- tests := []struct {
- name string
- cny float64
- expected float64
- }{
- {"zero", 0, 0},
- {"100 CNY", 100, 100 * defaultCNYToUSD},
- {"1 CNY", 1, 1 * defaultCNYToUSD},
- {"large amount", 10000, 10000 * defaultCNYToUSD},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- actual := tt.cny * defaultCNYToUSD
- if diff := actual - tt.expected; diff < -0.000001 || diff > 0.000001 {
- t.Errorf("CNY %f → USD: expected %f, got %f", tt.cny, tt.expected, actual)
- }
- })
- }
-}
-
-func TestConvertUSDToCNY_DefaultRate(t *testing.T) {
- tests := []struct {
- name string
- usd float64
- expected float64
- }{
- {"zero", 0, 0},
- {"1 USD", 1, defaultUSDToCNY},
- {"14 USD", 14, 14 * defaultUSDToCNY},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- actual := tt.usd * defaultUSDToCNY
- if diff := actual - tt.expected; diff < -0.000001 || diff > 0.000001 {
- t.Errorf("USD %f → CNY: expected %f, got %f", tt.usd, tt.expected, actual)
- }
- })
- }
-}
-
-func TestCurrencyConstants_NonZero(t *testing.T) {
- if defaultCNYToUSD == 0 {
- t.Error("defaultCNYToUSD must not be zero")
- }
- if defaultUSDToCNY == 0 {
- t.Error("defaultUSDToCNY must not be zero")
- }
-}
-
-// TestExchangeRateReciprocal 验证 USD→CNY 汇率是 CNY→USD 的倒数(往返闭合)
-func TestExchangeRateReciprocal(t *testing.T) {
- // 测试默认汇率互为倒数
- reciprocal := 1.0 / defaultCNYToUSD
- diff := reciprocal - defaultUSDToCNY
- if diff < 0 {
- diff = -diff
- }
- // 允许误差 0.01(因为默认常量可能不完全精确)
- if diff > 0.01 {
- t.Errorf("默认汇率应互为倒数:1/%.4f = %.4f, 但 defaultUSDToCNY = %.4f, 差值 = %.6f",
- defaultCNYToUSD, reciprocal, defaultUSDToCNY, diff)
- }
-}
-
-// TestConvertRoundTrip 验证 CNY→USD→CNY 往返转换闭合(误差在可接受范围内)
-func TestConvertRoundTrip(t *testing.T) {
- gtest.C(t, func(t *gtest.T) {
- // 测试多个典型金额
- testAmounts := []float64{100.0, 50.5, 1.0, 999.99}
-
- for _, originalCNY := range testAmounts {
- // CNY → USD(使用默认汇率模拟)
- usd := decimal.NewFromFloat(originalCNY).Mul(decimal.NewFromFloat(defaultCNYToUSD))
-
- // USD → CNY(使用倒数)
- cny := usd.Mul(decimal.NewFromFloat(1.0 / defaultCNYToUSD))
-
- // 验证往返误差 < 0.01 元(1分钱)
- diff := cny.Sub(decimal.NewFromFloat(originalCNY)).Abs()
- t.AssertLT(InexactFloat64(diff), 0.01)
- }
- })
-}
-
-// TestConvertCNYToUSD_Precision 验证 CNY→USD 转换的精度(使用默认汇率)
-func TestConvertCNYToUSD_Precision(t *testing.T) {
- // 100 CNY × 0.14 = 14 USD
- usd := decimal.NewFromFloat(100.0).Mul(decimal.NewFromFloat(defaultCNYToUSD))
- expected := decimal.NewFromFloat(14.0)
-
- diff := usd.Sub(expected).Abs()
- if InexactFloat64(diff) > 0.000001 {
- t.Errorf("100 CNY 应转换为约 14 USD,实际=%s, 误差=%s", usd.String(), diff.String())
- }
-}
-
-// TestConvertUSDToCNY_Precision 验证 USD→CNY 转换的精度(使用倒数)
-func TestConvertUSDToCNY_Precision(t *testing.T) {
- // 14 USD × (1/0.14) ≈ 100 CNY
- usd := decimal.NewFromFloat(14.0)
- reciprocalRate := 1.0 / defaultCNYToUSD
- cny := usd.Mul(decimal.NewFromFloat(reciprocalRate))
- expected := decimal.NewFromFloat(100.0)
-
- diff := cny.Sub(expected).Abs()
- if InexactFloat64(diff) > 0.01 {
- t.Errorf("14 USD 应转换为约 100 CNY,实际=%s, 误差=%s", cny.String(), diff.String())
- }
+func billingCurrencyValid(c string) bool {
+ return c == consts.BillingCurrencyUSD || c == consts.BillingCurrencyCNY
}
diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go
index 948d21f..5130b0e 100644
--- a/internal/logic/billing/pricing.go
+++ b/internal/logic/billing/pricing.go
@@ -322,7 +322,7 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill
DiscountRatio: discountRatio,
TenantMultiplier: tenantMultiplier,
ModelMultiplier: modelMultiplier,
- Currency: "USD",
+ Currency: Currency(ctx),
CacheReadPrice: cacheReadPrice,
CacheCreationPrice: cacheCreationPrice,
CacheCreation5mPrice: cacheCreationPrice, // 5m TTL = cache_creation_price 基础价(对应官方 1.25× 输入价)
diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go
index ce24fd4..0c6b15a 100644
--- a/internal/logic/billing/settlement.go
+++ b/internal/logic/billing/settlement.go
@@ -166,7 +166,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
BaseCost: preDeductAmount,
InputTokens: inputTokens,
OutputTokens: outputTokens,
- Currency: "USD",
+ Currency: Currency(ctx),
}
} else {
breakdown = computeCost(pricingResult, inputTokens, outputTokens, nil)
@@ -284,7 +284,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
fb := &CostBreakdown{
TotalCost: preDeductAmount,
BaseCost: preDeductAmount,
- Currency: "USD",
+ Currency: Currency(ctx),
}
if usage != nil {
fb.InputTokens = usage.PromptTokens
@@ -459,7 +459,7 @@ func createBillingRecord(ctx context.Context, tenantID, userID, apiKeyID, channe
InputPrice: inputPrice,
OutputPrice: outputPrice,
TotalCost: totalCost,
- Currency: "USD",
+ Currency: Currency(ctx),
Status: "settled",
SettledAt: gtime.NewFromTime(now),
}
@@ -545,7 +545,7 @@ func createBillingRecordWithSnapshot(ctx context.Context, tenantID, userID, apiK
InputPrice: inputPrice,
OutputPrice: outputPrice,
TotalCost: totalCost,
- Currency: "USD",
+ Currency: Currency(ctx),
Status: "settled",
SettledAt: gtime.NewFromTime(now),
}
diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go
index 28a0b2c..02534df 100644
--- a/internal/logic/billing/task_billing.go
+++ b/internal/logic/billing/task_billing.go
@@ -165,7 +165,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
// 2. 获取定价(事务外只读,按任务受理时刻评估时段乘数)
pricing, _ := GetModelPriceAt(ctx, tenantID, modelName, billAt)
- breakdown := buildTaskCostBreakdown(pricing, InexactFloat64(actualCost), totalTokens, completionTokens)
+ breakdown := buildTaskCostBreakdown(ctx, pricing, InexactFloat64(actualCost), totalTokens, completionTokens)
var billingMode string
var discountRatio, effectiveOutputPrice float64
@@ -199,7 +199,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
InputPrice: 0,
OutputPrice: effectiveOutputPrice,
TotalCost: actualCost,
- Currency: "USD",
+ Currency: Currency(ctx),
Status: "settled",
SettledAt: gtime.NewFromTime(time.Now()),
BillingMode: billingMode,
@@ -311,12 +311,12 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
}
// buildTaskCostBreakdown 构建任务计费的 CostBreakdown
-func buildTaskCostBreakdown(pricing *PricingResult, actualCost float64, totalTokens, _ int) *CostBreakdown {
+func buildTaskCostBreakdown(ctx context.Context, pricing *PricingResult, actualCost float64, totalTokens, _ int) *CostBreakdown {
if pricing == nil {
return &CostBreakdown{
TotalCost: actualCost,
BaseCost: actualCost,
- Currency: "USD",
+ Currency: Currency(ctx),
}
}
diff --git a/internal/logic/billing/task_billing_test.go b/internal/logic/billing/task_billing_test.go
index 50d401e..ca2724c 100644
--- a/internal/logic/billing/task_billing_test.go
+++ b/internal/logic/billing/task_billing_test.go
@@ -1,11 +1,12 @@
package billing
import (
+ "context"
"testing"
)
func TestBuildTaskCostBreakdown_NilPricing(t *testing.T) {
- bd := buildTaskCostBreakdown(nil, 0.5, 1000, 500)
+ bd := buildTaskCostBreakdown(context.Background(), nil, 0.5, 1000, 500)
if bd == nil {
t.Fatal("expected non-nil breakdown")
}
@@ -25,7 +26,7 @@ func TestBuildTaskCostBreakdown_PerRequestMode(t *testing.T) {
Currency: "USD",
}
- bd := buildTaskCostBreakdown(pricing, 0.10, 0, 0)
+ bd := buildTaskCostBreakdown(context.Background(), pricing, 0.10, 0, 0)
assertFloat(t, bd.BaseCost, 0.10, "BaseCost")
assertFloat(t, bd.TotalCost, 0.10, "TotalCost")
assertFloat(t, bd.PerRequestPrice, 0.10, "PerRequestPrice")
@@ -43,7 +44,7 @@ func TestBuildTaskCostBreakdown_TokenMode(t *testing.T) {
Currency: "USD",
}
- bd := buildTaskCostBreakdown(pricing, 0.024, 10000, 5000)
+ bd := buildTaskCostBreakdown(context.Background(), pricing, 0.024, 10000, 5000)
if bd.OutputTokens != 10000 {
t.Errorf("OutputTokens = %d, want 10000", bd.OutputTokens)
}
@@ -61,7 +62,7 @@ func TestBuildTaskCostBreakdown_TokenMode_ZeroTenantMultiplier(t *testing.T) {
Currency: "USD",
}
- bd := buildTaskCostBreakdown(pricing, 0.01, 1000, 500)
+ bd := buildTaskCostBreakdown(context.Background(), pricing, 0.01, 1000, 500)
// tenantMul == 0 → BaseCost = actualCost (no division)
assertFloat(t, bd.BaseCost, 0.01, "BaseCost")
assertFloat(t, bd.TotalCost, 0.01, "TotalCost")
@@ -75,7 +76,7 @@ func TestBuildTaskCostBreakdown_TokenMode_TotalTokens(t *testing.T) {
Currency: "USD",
}
- bd := buildTaskCostBreakdown(pricing, 0.05, 10000, 3000)
+ bd := buildTaskCostBreakdown(context.Background(), pricing, 0.05, 10000, 3000)
if bd.OutputTokens != 10000 {
t.Errorf("OutputTokens = %d, want 10000", bd.OutputTokens)
}
@@ -90,7 +91,7 @@ func TestBuildTaskCostBreakdown_CarriesPricingFields(t *testing.T) {
Currency: "USD",
}
- bd := buildTaskCostBreakdown(pricing, 0.50, 0, 0)
+ bd := buildTaskCostBreakdown(context.Background(), pricing, 0.50, 0, 0)
assertFloat(t, bd.DiscountRatio, 0.85, "DiscountRatio")
assertFloat(t, bd.TenantMultiplier, 0.85, "TenantMultiplier")
if bd.Currency != "USD" {
@@ -109,7 +110,7 @@ func TestBuildTaskCostBreakdown_TokenMode_ZeroTokens(t *testing.T) {
Currency: "USD",
}
- bd := buildTaskCostBreakdown(pricing, 3.375, 0, 0)
+ bd := buildTaskCostBreakdown(context.Background(), pricing, 3.375, 0, 0)
if bd.OutputTokens != 0 {
t.Errorf("OutputTokens = %d, want 0", bd.OutputTokens)
}
@@ -130,12 +131,12 @@ func TestBuildTaskCostBreakdown_TokenMode_TimeMultiplier(t *testing.T) {
}
// 0.4 / (0.8 × 0.5) = 1.0
- bd := buildTaskCostBreakdown(pricing, 0.4, 10000, 5000)
+ bd := buildTaskCostBreakdown(context.Background(), pricing, 0.4, 10000, 5000)
assertFloat(t, bd.BaseCost, 1.0, "BaseCost (tenant × time)")
assertFloat(t, bd.TotalCost, 0.4, "TotalCost")
// 无 token 用量分支同样按双乘数还原:0.08 / (0.8 × 0.5) = 0.2
- bd = buildTaskCostBreakdown(pricing, 0.08, 0, 0)
+ bd = buildTaskCostBreakdown(context.Background(), pricing, 0.08, 0, 0)
assertFloat(t, bd.BaseCost, 0.2, "BaseCost zero-token (tenant × time)")
assertFloat(t, bd.TotalCost, 0.08, "TotalCost zero-token")
}
@@ -152,7 +153,7 @@ func TestBuildTaskCostBreakdown_ZeroTimeMultiplierFallback(t *testing.T) {
}
// 0.024 / (0.8 × 1.0) = 0.03
- bd := buildTaskCostBreakdown(pricing, 0.024, 1000, 500)
+ bd := buildTaskCostBreakdown(context.Background(), pricing, 0.024, 1000, 500)
assertFloat(t, bd.BaseCost, 0.03, "BaseCost (time fallback 1.0)")
}
diff --git a/internal/logic/common/config.go b/internal/logic/common/config.go
index 3c82cc7..18d72a4 100644
--- a/internal/logic/common/config.go
+++ b/internal/logic/common/config.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
"strconv"
@@ -12,6 +13,7 @@ import (
"time"
"github.com/gogf/gf/v2/database/gredis"
+ "github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
"golang.org/x/sync/singleflight"
@@ -220,6 +222,18 @@ func (s *ConfigService) SetOption(ctx context.Context, key, value string) error
return err
}
+ // 本位币只在首次写入(系统初始化向导)时允许设置,此后只读;
+ // 相同值放行,保证设置页保存整个 payment 分类时不被误拒。
+ if key == consts.OptionKeyBillingCurrency && count > 0 {
+ existing, err := dao.SysOptions.Ctx(ctx).Where("key", key).Value()
+ if err != nil {
+ return err
+ }
+ if old := gconv.String(existing); old != "" && old != value {
+ return gerror.New("本位币在系统初始化后不可更改")
+ }
+ }
+
if count > 0 {
data := do.SysOptions{Value: value}
// Sync metadata from registry so that changes to IsPublic/Category/Description
diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go
index 128f1bc..e4a07ae 100644
--- a/internal/logic/common/settings_registry.go
+++ b/internal/logic/common/settings_registry.go
@@ -154,12 +154,12 @@ var Registry = []SettingDef{
Label: "充值折扣", Description: "JSON 对象,如 {\"100\":0.9} 表示充100享9折"},
{Key: "payment_min_topup", Type: SettingTypeFloat, Default: "1", Category: "payment",
Label: "最低充值金额", Validation: "min:0.01"},
- {Key: "payment_currency", Type: SettingTypeString, Default: "CNY", Category: "payment",
- Label: "货币单位", Validation: "enum:CNY,USD"},
+ {Key: "billing_currency", Type: SettingTypeString, Default: "USD", Category: "payment",
+ Label: "本位币", Description: "系统内部记账与全站显示的货币(USD/CNY)。仅在系统初始化向导中选定,初始化后不可更改;存量部署默认 USD", Validation: "enum:USD,CNY", IsPublic: true},
{Key: "payment_callback_base_url", Type: SettingTypeString, Default: "", Category: "payment",
Label: "支付回调基础URL", Description: "为空则使用请求 Host"},
{Key: "payment_exchange_rate_cny_to_usd", Type: SettingTypeFloat, Default: "0.14", Category: "payment",
- Label: "CNY → USD 兑换比例", Description: "1 人民币兑换多少美元(USD → CNY 自动取倒数,确保往返闭合)", Validation: "min:0.001,max:100"},
+ Label: "CNY → USD 兑换比例", Description: "1 人民币兑换多少美元(USD → CNY 自动取倒数,确保往返闭合)", Validation: "min:0.001,max:100", IsPublic: true},
{Key: "payment_notice", Type: SettingTypeString, Default: "", Category: "payment",
Label: "支付说明", Description: "展示在租户端钱包充值页的说明文案,支持纯文本或 HTML 代码(如提示充值后不支持退款、指定渠道折扣活动等)。纯文本换行需用
,富文本请直接粘贴 HTML"},
diff --git a/internal/logic/payment/fulfill.go b/internal/logic/payment/fulfill.go
index ea19e71..2ac6842 100644
--- a/internal/logic/payment/fulfill.go
+++ b/internal/logic/payment/fulfill.go
@@ -66,22 +66,36 @@ func FulfillOrder(ctx context.Context, orderID int64) error {
case "recharge":
// 折扣语义「付折后价、按原价入账」:用户实付 FinalAmount(折后 CNY),
- // 钱包按订单原价 Amount 换算 USD 入账,折扣让利真实生效;无折扣时两者相等。
- // 汇率只取一次,保证入账换算与快照使用同一汇率。
- rate := billing.GetExchangeRateCNYToUSD(ctx)
- usdAmount := billing.CeilUSD(billing.NewFromFloat(order.Amount).Mul(billing.NewFromFloat(rate)))
- credited, err := creditWalletTx(ctx, order.TenantID, usdAmount, orderID, fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → USD %.6f)", orderID, order.Amount, order.FinalAmount, usdAmount.InexactFloat64()))
+ // 钱包按订单原价 Amount 入账,折扣让利真实生效;无折扣时两者相等。
+ var creditAmount decimal.Decimal
+ var desc string
+ if billing.IsCNY(ctx) {
+ // 本位币=CNY:订单层与记账层同为人民币,按原价直接入账,无换汇环节
+ creditAmount = billing.NewFromFloat(order.Amount)
+ desc = fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → CNY %.2f)", orderID, order.Amount, order.FinalAmount, creditAmount.InexactFloat64())
+ } else {
+ // 本位币=USD:按原价换算美元入账;汇率只取一次,保证入账换算与快照使用同一汇率。
+ rate := billing.GetExchangeRateCNYToUSD(ctx)
+ creditAmount = billing.CeilUSD(billing.NewFromFloat(order.Amount).Mul(billing.NewFromFloat(rate)))
+ desc = fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → USD %.6f)", orderID, order.Amount, order.FinalAmount, creditAmount.InexactFloat64())
+ }
+ credited, err := creditWalletTx(ctx, order.TenantID, creditAmount, orderID, desc)
// 先记录已发生的 Redis 加款(可能 >0 即使整体失败),供事务回滚后补偿逆转
redisCreditedTenantID = order.TenantID
redisCreditedAmount = credited
if err != nil {
return gerror.Wrapf(err, "credit wallet failed")
}
- // 汇率结构化快照:持久化「原始 CNY(订单列)+ 当时汇率 + 入账 USD」,
+ // 汇率结构化快照:持久化「原始 CNY(订单列)+ 当时汇率 + 入账金额」,
// 使历史换算可重建,汇率配置变更不影响已完成订单的现金对账。
+ // 本位币=CNY 时 exchange_rate=1(无换汇)、credited_usd 存本位币金额。
+ snapRate := billing.One
+ if !billing.IsCNY(ctx) {
+ snapRate = billing.NewFromFloat(billing.GetExchangeRateCNYToUSD(ctx))
+ }
if _, err = g.DB().Ctx(ctx).Exec(ctx,
"UPDATE ord_orders SET exchange_rate = ?, credited_usd = ? WHERE id = ?",
- billing.NewFromFloat(rate), usdAmount, orderID); err != nil {
+ snapRate, creditAmount, orderID); err != nil {
return gerror.Wrapf(err, "snapshot exchange rate failed")
}
_ = billing.CheckAndUpgradeLevel(ctx, order.TenantID)
@@ -201,8 +215,8 @@ func SubscribePlan(ctx context.Context, tenantID int64, planID int64, months int
}
// creditWalletTx 钱包入账(依赖调用方传入携带事务的 ctx,且处于订单行锁保护内)。
-// amount 为 USD(bil_ 层永远 USD);orderID 写入流水 related_id:退款时靠它精确找回
-// 履约当时入账的 USD(禁止按当前汇率反算)。
+// amount 为本位币金额(bil_ 层币种 = 部署级本位币,USD 或 CNY);orderID 写入流水
+// related_id:退款时靠它精确找回履约当时入账的本位币金额(禁止按当前汇率反算)。
//
// Redis 权威化架构下的职责拆分:
// - Redis 加款是资金提交点(实时余额立即生效),在行锁保护内执行,并发回调串行化、天然幂等;
diff --git a/internal/logic/payment/settings.go b/internal/logic/payment/settings.go
index 32f9dc8..30827d0 100644
--- a/internal/logic/payment/settings.go
+++ b/internal/logic/payment/settings.go
@@ -8,6 +8,8 @@ import (
)
// GetGlobalPaymentSettings loads global payment settings from ConfigService.
+// Currency 固定返回 "CNY":订单/支付层币种由收款渠道决定(epay 收人民币),
+// 系统计账与显示货币由本位币 billing_currency 决定(初始化选定,不在此配置)。
func GetGlobalPaymentSettings(ctx context.Context) (*GlobalPaymentSettings, error) {
cfg := common.Config()
settings := &GlobalPaymentSettings{
@@ -26,22 +28,19 @@ func GetGlobalPaymentSettings(ctx context.Context) (*GlobalPaymentSettings, erro
if n := cfg.GetFloat(ctx, "payment_min_topup"); n > 0 {
settings.MinTopUp = n
}
- if val := cfg.GetString(ctx, "payment_currency"); val != "" {
- settings.Currency = val
- }
settings.CallbackBaseURL = cfg.GetString(ctx, "payment_callback_base_url")
return settings, nil
}
// SaveGlobalPaymentSettings persists global payment settings via ConfigService.
+// Currency 字段不落库(订单层币种恒 CNY,不接受配置)。
func SaveGlobalPaymentSettings(ctx context.Context, settings *GlobalPaymentSettings) error {
cfg := common.Config()
items := map[string]string{
"payment_amount_options": mustJSON(settings.AmountOptions),
"payment_amount_discount": mustJSON(settings.AmountDiscount),
"payment_min_topup": mustJSON(settings.MinTopUp),
- "payment_currency": settings.Currency,
"payment_callback_base_url": settings.CallbackBaseURL,
}
for key, value := range items {
diff --git a/internal/logic/task/async_polling.go b/internal/logic/task/async_polling.go
index 1cb0864..7fe06ab 100644
--- a/internal/logic/task/async_polling.go
+++ b/internal/logic/task/async_polling.go
@@ -528,7 +528,7 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
record.OutputCost = settleResult.OutputCost
record.RefundAmount = settleResult.RefundAmount
record.SupplementAmount = settleResult.SupplementAmount
- record.Currency = "USD"
+ record.Currency = billing.Currency(context.Background())
} else {
// 失败/超时任务:从定价中获取计费模式
billingMode := "per_request"
diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go
index 507ed0f..b573280 100644
--- a/internal/logic/tenant/auth.go
+++ b/internal/logic/tenant/auth.go
@@ -186,7 +186,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
Balance: 0,
FrozenBalance: 0,
WarningThreshold: &warningThreshold,
- Currency: "USD",
+ Currency: billing.Currency(ctx),
}).Insert()
if err != nil {
return gerror.Wrapf(err, "create wallet")
diff --git a/internal/logic/tenant/billing.go b/internal/logic/tenant/billing.go
index 1739969..76ba91b 100644
--- a/internal/logic/tenant/billing.go
+++ b/internal/logic/tenant/billing.go
@@ -42,7 +42,7 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena
Balance: billing.Zero,
FrozenBalance: billing.Zero,
WarningThreshold: &threshold,
- Currency: "USD",
+ Currency: billing.Currency(ctx),
})
if err != nil {
return nil, err
@@ -52,7 +52,7 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena
FrozenBalance: 0,
AvailableBalance: 0,
WarningThreshold: 0,
- Currency: "USD",
+ Currency: billing.Currency(ctx),
}, nil
}
diff --git a/internal/packed/setup.html b/internal/packed/setup.html
index dd29ad3..578074e 100644
--- a/internal/packed/setup.html
+++ b/internal/packed/setup.html
@@ -54,6 +54,12 @@
cursor: not-allowed;
}
+ .currency-option.selected {
+ border-color: #2563eb;
+ background-color: rgba(37, 99, 235, 0.04);
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08);
+ }
+
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
@@ -67,6 +73,7 @@
.delay-4 { animation-delay: 0.2s; }
.delay-5 { animation-delay: 0.25s; }
.delay-6 { animation-delay: 0.3s; }
+ .delay-7 { animation-delay: 0.35s; }
@keyframes spin {
to { transform: rotate(360deg); }
@@ -155,6 +162,31 @@
class="input-field w-full px-3.5 py-2.5 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 outline-none font-mono"
placeholder="再次输入密码" autocomplete="new-password">
+
+
+
决定系统内部记账与全站显示货币,模型定价按本位币配置
+区间内该模型实际计费成本合计(USD),已含输入、输出与缓存各项费用。
+区间内该模型实际计费成本合计({{ displayCurrency }}),已含输入、输出与缓存各项费用。
总成本 = Σ total_cost