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/admin/update.go b/internal/logic/admin/update.go index d488e55..9d6c6cc 100644 --- a/internal/logic/admin/update.go +++ b/internal/logic/admin/update.go @@ -115,7 +115,9 @@ func (s *sAdmin) UpdateExecute(ctx context.Context, req *v1.UpdateExecuteReq) (* // Get check result to find download URL cached := update.GetManager().GetCheckResult() - if cached == nil { + // 缓存里有更新但没匹配到平台安装包时强制重查,而不是直接报「没有可用的更新包」: + // 典型场景是发行版刚发布、CI 尚未上传资产窗口期的检查结果被缓存,资产几分钟后就位 + if cached == nil || update.HasUpdateButNoAsset(cached) { // Force a check first result, err := update.CheckForUpdate(ctx, true) if err != nil { 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 ccd25c1..f50620a 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -156,12 +156,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/monitor/api_metrics.go b/internal/logic/monitor/api_metrics.go index 6759a47..9b233ef 100644 --- a/internal/logic/monitor/api_metrics.go +++ b/internal/logic/monitor/api_metrics.go @@ -64,13 +64,22 @@ type trafficFlowRow struct { // normalizeMonitorDateRange 规范化监控类接口的日期范围,返回 YYYY-MM-DD 的 [start, end](含)。 // 缺省近 30 天;end 不超过今天;跨度上限 trafficFlowMaxDays。流量桑基图与模型性能共用。 func normalizeMonitorDateRange(startDate, endDate string) (string, string) { + return normalizeMonitorDateRangeAt(startDate, endDate, time.Now()) +} + +// normalizeMonitorDateRangeAt is separated for deterministic tests. Date-only +// filters must be compared as calendar dates in the server's location, rather +// than against the current instant. Otherwise, on a non-UTC server before its +// UTC offset, today's parsed midnight can incorrectly fall after time.Now() +// and the range silently falls back to the default 30 days. +func normalizeMonitorDateRangeAt(startDate, endDate string, now time.Time) (string, string) { const layout = "2006-01-02" - today := time.Now() - end, err := time.Parse(layout, endDate) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + end, err := time.ParseInLocation(layout, endDate, now.Location()) if err != nil || end.After(today) { end = today } - start, err := time.Parse(layout, startDate) + start, err := time.ParseInLocation(layout, startDate, now.Location()) if err != nil || start.After(end) { start = end.AddDate(0, 0, -29) } diff --git a/internal/logic/monitor/api_metrics_test.go b/internal/logic/monitor/api_metrics_test.go index 8307a2d..9ec4a9f 100644 --- a/internal/logic/monitor/api_metrics_test.go +++ b/internal/logic/monitor/api_metrics_test.go @@ -5,11 +5,22 @@ import ( "reflect" "strings" "testing" + "time" v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/logic/common" ) +func TestNormalizeMonitorDateRangeKeepsTodayInNonUTCTimezone(t *testing.T) { + location := time.FixedZone("UTC+8", 8*60*60) + now := time.Date(2026, time.August, 29, 0, 10, 0, 0, location) + + start, end := normalizeMonitorDateRangeAt("2026-08-29", "2026-08-29", now) + if start != "2026-08-29" || end != "2026-08-29" { + t.Fatalf("same-day range = [%s, %s], want [2026-08-29, 2026-08-29]", start, end) + } +} + // perfFieldValue 按 JSON 字段名(snake_case)从结构体取值,整数统一归一为 int64、 // 浮点统一为 float64,与旧的 []map[string]any 断言口径保持一致。 func perfFieldValue(item v1.ModelPerformanceSummary, field string) any { 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/logic/update/checker.go b/internal/logic/update/checker.go index be0756b..97af3b8 100644 --- a/internal/logic/update/checker.go +++ b/internal/logic/update/checker.go @@ -114,8 +114,11 @@ func CheckForUpdate(ctx context.Context, force bool) (*CheckResult, error) { // Store ETag for conditional requests manager.lastETag.Store(etag) - // Cache result - setCheckCache(ctx, result) + // Cache result。「有更新但没匹配到平台安装包」的结果不写 Redis:TTL 24h 太长, + // 会挡住发布窗口期之后的检查拿到完整资产列表(内存结果照常存,供状态展示) + if !HasUpdateButNoAsset(result) { + setCheckCache(ctx, result) + } manager.checkResult.Store(result) return result, nil @@ -185,6 +188,14 @@ func fetchLatestRelease(ctx context.Context, conditional bool) (*GitHubRelease, return &release, etag, nil } +// HasUpdateButNoAsset 判断检查结果是否为「有更新但当前平台没匹配到安装包」。 +// 典型成因:发行版刚发布、CI 尚未上传资产的窗口期拉到的结果(资产几分钟后才就位)。 +// 这类结果不可信:不能写缓存,已入库的脏缓存读到后按无效处理, +// 执行更新时则强制重查,而不是直接报「没有可用的更新包」。 +func HasUpdateButNoAsset(r *CheckResult) bool { + return r != nil && r.HasUpdate && r.DownloadURL == "" +} + // getCheckCache reads cached check result from Redis. // 缓存是纯优化(TTL 24h),Redis 未配置/不可用时应降级为无缓存、走实时检查, // 绝不能因 g.Redis() 客户端创建失败而 panic(见 goframe-conventions.md 已修复记录)。 @@ -205,6 +216,11 @@ func getCheckCache(ctx context.Context) (out *CheckResult) { if err := json.Unmarshal(val.Bytes(), &result); err != nil { return nil } + // 「有更新但没匹配到平台安装包」的脏缓存(发布窗口期写入,含旧版本遗留数据)按无效处理, + // 让调用方 fallthrough 到实时检查拿完整资产列表 + if HasUpdateButNoAsset(&result) { + return nil + } return &result } diff --git a/internal/logic/update/checker_test.go b/internal/logic/update/checker_test.go index 4619cfd..249e414 100644 --- a/internal/logic/update/checker_test.go +++ b/internal/logic/update/checker_test.go @@ -46,6 +46,27 @@ func TestCompareVersions(t *testing.T) { } } +func TestHasUpdateButNoAsset(t *testing.T) { + cases := []struct { + name string + result *CheckResult + want bool + }{ + {"nil 结果", nil, false}, + {"有更新且有安装包", &CheckResult{HasUpdate: true, DownloadURL: "https://example.com/team-api-0.2.14-linux-amd64.tar.gz"}, false}, + {"无更新且无安装包", &CheckResult{HasUpdate: false, DownloadURL: ""}, false}, + {"有更新但缺安装包(发布窗口期脏数据)", &CheckResult{HasUpdate: true, DownloadURL: ""}, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := HasUpdateButNoAsset(c.result); got != c.want { + t.Errorf("HasUpdateButNoAsset(%+v) = %v, want %v", c.result, got, c.want) + } + }) + } +} + func TestNormalizeSemver(t *testing.T) { cases := []struct{ in, want string }{ {"0.2.0_39", "0.2.0+39"}, // Makefile git describe 版本:下划线转构建元数据 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"> + + +
+ +
+ + +
+

决定系统内部记账与全站显示货币,模型定价按本位币配置

+
@@ -168,7 +200,7 @@ -
+
@@ -555,7 +559,7 @@ watch(() => props.visible, (val) => {

按量定价

- 价格单位为 $ / 1M Token + 价格单位为 {{ currencySymbol }} / 1M Token
@@ -566,7 +570,7 @@ watch(() => props.visible, (val) => { placeholder="0" class="w-full" > - + @@ -577,7 +581,7 @@ watch(() => props.visible, (val) => { placeholder="0" class="w-full" > - + @@ -588,7 +592,7 @@ watch(() => props.visible, (val) => { placeholder="0" class="w-full" > - + @@ -599,7 +603,7 @@ watch(() => props.visible, (val) => { placeholder="0" class="w-full" > - +
@@ -651,7 +655,7 @@ watch(() => props.visible, (val) => { :precision="4" class="w-full" > - + @@ -661,7 +665,7 @@ watch(() => props.visible, (val) => { :precision="4" class="w-full" > - +
@@ -673,7 +677,7 @@ watch(() => props.visible, (val) => { :precision="4" class="w-full" > - + @@ -683,7 +687,7 @@ watch(() => props.visible, (val) => { :precision="4" class="w-full" > - +
diff --git a/web/admin/src/composables/useCurrency.ts b/web/admin/src/composables/useCurrency.ts new file mode 100644 index 0000000..4e2b18d --- /dev/null +++ b/web/admin/src/composables/useCurrency.ts @@ -0,0 +1,71 @@ +import { computed } from 'vue' +import { usePublicSettings } from '@/composables/usePublicSettings' + +/** + * 全站货币显示(本位币制),与租户端 useCurrency 镜像: + * - bil_ 层(钱包/计费/定价/用量/阈值)币种 = 本位币 billing_currency,直显不折算 + * - ord_/pln_ 层(订单/套餐/优惠码折扣)恒 CNY;本位币=USD 时按汇率折算显示,本位币=CNY 直显 + * - 本位币由系统初始化向导选定,管理后台只读(保存支付设置后调用 refresh 立即生效) + * - 编辑表单保持存储币种原值输入,折算仅用于只读展示 + */ +export type CurrencyCode = 'USD' | 'CNY' + +/** 默认 CNY→USD 汇率(与后端 billing.defaultCNYToUSD 一致,配置未加载/无效时兜底) */ +const FALLBACK_CNY_TO_USD = 0.14 + +const { settings, fetchSettings } = usePublicSettings() + +/** 全站显示货币(= 系统本位币),未加载前保守默认 USD */ +export const displayCurrency = computed(() => + settings.value.billing_currency === 'CNY' ? 'CNY' : 'USD', +) + +/** CNY→USD 汇率(支付设置配置,系统设置保存后 refresh 生效) */ +export const cnyToUsd = computed(() => { + const r = Number(settings.value.payment_exchange_rate_cny_to_usd) + return r > 0 ? r : FALLBACK_CNY_TO_USD +}) + +export interface FormatMoneyOptions { + /** 数据存储层币种:'USD' | 'CNY'(按字段所属数据层传入,非显示币种) */ + source?: CurrencyCode + /** 显示精度;默认按显示币种取 USD 6 位 / CNY 2 位 */ + precision?: number + /** 正负号前缀(交易流水) */ + showSign?: boolean +} + +/** + * 统一金额格式化(跟随本位币)。 + * source='CNY'(订单层)且本位币=USD 时按汇率折算;bil 层数据传 source=displayCurrency 即直显。 + * 内部读取响应式 computed,render 函数中调用可建立依赖,配置变化自动重渲染。 + */ +export function formatMoney(value: unknown, opts: FormatMoneyOptions = {}): string { + const { source = 'USD', precision, showSign = false } = opts + const display = displayCurrency.value + let num = Number(value ?? 0) + // 唯一折算场景:订单层 CNY 金额在本位币 USD 部署下展示 + if (source === 'CNY' && display === 'USD') { + num = num * cnyToUsd.value + } + const symbol = display === 'CNY' ? '¥' : '$' + const p = precision ?? (display === 'CNY' ? 2 : 6) + const sign = showSign ? (num > 0 ? '+' : num < 0 ? '-' : '') : '' + // 先 toFixed 确保精度,再用 Number 去掉末尾的 0 + return `${sign}${symbol}${Number(Math.abs(num).toFixed(p))}` +} + +/** bil_ 层(钱包/计费/定价等本位币数据)快捷格式化:直显 + 本位币符号 */ +export function formatBilling(value: unknown, precision?: number, showSign = false): string { + return formatMoney(value, { source: displayCurrency.value, precision, showSign }) +} + +/** ord_/pln_ 层(订单/套餐等 CNY 数据)快捷格式化:本位币=CNY 直显,USD 折算 */ +export function formatOrder(value: unknown, precision?: number, showSign = false): string { + return formatMoney(value, { source: 'CNY', precision, showSign }) +} + +/** 强制刷新公共配置(系统设置保存后调用,让已打开页面立即按新汇率/本位币重渲染) */ +export function refreshCurrencySettings(): Promise { + return fetchSettings(true) +} diff --git a/web/admin/src/composables/useDateRange.ts b/web/admin/src/composables/useDateRange.ts index 0c33ac6..8b429c5 100644 --- a/web/admin/src/composables/useDateRange.ts +++ b/web/admin/src/composables/useDateRange.ts @@ -10,13 +10,6 @@ function toDateTimeStr(d: Date): string { // 默认查询当天:起始时间为当天 0 点,截止时间留空(后端按「到现在」实时处理)。 // 日期选择器里的「现在」仅用于展示;查询时截止仍为默认「现在」则不传 end_date,避免固定截止时间漏掉后续新记录。 // 该值在模块加载时固定一次,页面间共享,保证「截止等于默认现在则不下发 end_date」的判据一致。 -const defaultEnd = toDateTimeStr(new Date()) - -function defaultTodayRange(): string[] { - const d = new Date() - return [`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} 00:00:00`, defaultEnd] -} - // 指定偏移天数的当天 0 点 / 23:59:59(offsetDays=0 表示今天) function dateStartStr(offsetDays: number): string { const d = new Date() @@ -31,13 +24,21 @@ function dateEndStr(offsetDays: number): string { // 日期时间选择器底部快捷选项:今天 / 昨天 / 近三天 / 近一周。 // 截止「现在」的选项复用 defaultEnd,保持「截止不传 → 后端按到现在实时查询」的语义。 -const quickDateRanges: ShortcutType[] = [ - { label: '今天', value: () => defaultTodayRange() }, - { label: '昨天', value: () => [dateStartStr(1), dateEndStr(1)] }, - { label: '近三天', value: () => [dateStartStr(2), defaultEnd] }, - { label: '近一周', value: () => [dateStartStr(6), defaultEnd] }, -] - export function useDateRange() { + // 默认展示截止时间预留 1 小时;查询仍识别此默认值并省略 end_date, + // 因而后端会实时查询到当前时刻,用户停留页面后刷新也不会漏掉新记录。 + const defaultEndDate = new Date() + defaultEndDate.setHours(defaultEndDate.getHours() + 1) + const defaultEnd = toDateTimeStr(defaultEndDate) + const defaultTodayRange = (): string[] => { + const d = new Date() + return [`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} 00:00:00`, defaultEnd] + } + const quickDateRanges: ShortcutType[] = [ + { label: '今天', value: defaultTodayRange }, + { label: '昨天', value: () => [dateStartStr(1), dateEndStr(1)] }, + { label: '近三天', value: () => [dateStartStr(2), defaultEnd] }, + { label: '近一周', value: () => [dateStartStr(6), defaultEnd] }, + ] return { defaultEnd, defaultTodayRange, quickDateRanges } } diff --git a/web/admin/src/composables/usePublicSettings.ts b/web/admin/src/composables/usePublicSettings.ts new file mode 100644 index 0000000..a151212 --- /dev/null +++ b/web/admin/src/composables/usePublicSettings.ts @@ -0,0 +1,66 @@ +import { ref, readonly } from 'vue' +import request from '@/utils/request' + +/** + * 全局公共配置(/settings/public,无认证)。 + * 与租户端 usePublicSettings 镜像:模块级单例 + 30s TTL + 并发合并, + * 管理后台用于本位币/汇率(货币显示)与站点名等公共配置。 + */ +export interface PublicSettings { + site_name: string + site_description: string + maintenance_mode: boolean + maintenance_message: string + maintenance_duration: string + demo_mode: boolean + demo_message: string + [key: string]: unknown +} + +const settings = ref({ + site_name: '', + site_description: '', + maintenance_mode: false, + maintenance_message: '', + maintenance_duration: '', + demo_mode: false, + demo_message: '', +}) + +let fetchPromise: Promise | null = null +let lastFetchTime = 0 +const CACHE_TTL = 30_000 // 30秒缓存 + +export function usePublicSettings() { + async function fetchSettings(force = false): Promise { + const now = Date.now() + if (!force && now - lastFetchTime < CACHE_TTL && fetchPromise === null) { + return + } + + if (fetchPromise) { + return fetchPromise + } + + fetchPromise = (async () => { + try { + const { data } = await request.get('/settings/public') + if (data?.data?.settings) { + Object.assign(settings.value, data.data.settings) + } + lastFetchTime = Date.now() + } catch { + // silent — non-critical + } finally { + fetchPromise = null + } + })() + + return fetchPromise + } + + return { + settings: readonly(settings), + fetchSettings, + } +} diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index 8983eb2..9bc3974 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -78,12 +78,6 @@ const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/views/finance/TransactionsPage.vue'), meta: { title: '交易流水' }, }, - { - path: 'plans', - name: 'AdminPlans', - component: () => import('@/views/finance/PlansPage.vue'), - meta: { title: '套餐管理' }, - }, { path: 'orders', name: 'AdminOrders', @@ -91,10 +85,9 @@ const adminRoutes: RouteRecordRaw[] = [ meta: { title: '订单管理' }, }, { + // 旧"支付设置"独立页已并入系统设置(payment 分类),URL 保留重定向兼容 path: 'payment-settings', - name: 'AdminPaymentSettings', - component: () => import('@/views/finance/PaymentSettingsPage.vue'), - meta: { title: '支付设置' }, + redirect: { name: 'AdminSettings' }, }, { path: 'redemptions', diff --git a/web/admin/src/router/index.ts b/web/admin/src/router/index.ts index 7c8f2cf..3f56125 100644 --- a/web/admin/src/router/index.ts +++ b/web/admin/src/router/index.ts @@ -4,6 +4,7 @@ import adminRoutes from './admin' import { useAuthStore } from '@/stores/auth' import request, { shouldRefresh, getRefreshToken } from '@/utils/request' import { useSiteName } from '@/composables/useSiteName' +import { usePublicSettings } from '@/composables/usePublicSettings' import { useTopProgress } from '@/composables/useTopProgress' const routes: RouteRecordRaw[] = [ @@ -45,6 +46,7 @@ async function checkSetupStatus(): Promise { } const { siteName, fetchSiteName } = useSiteName() +const { fetchSettings: fetchPublicSettings } = usePublicSettings() const { start, done } = useTopProgress() router.beforeEach(async (to) => { @@ -54,7 +56,8 @@ router.beforeEach(async (to) => { authStore.loadFromStorage() if (to.meta.title) { - await fetchSiteName() + // 站点名与公共配置(本位币/汇率,供全站货币显示)并行拉取,30s TTL + await Promise.all([fetchSiteName(), fetchPublicSettings()]) const name = siteName.value || 'Team-API' document.title = `${to.meta.title as string} — ${name}` } diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index f541311..5ab32d1 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -337,7 +337,6 @@ const menuGroups = [ label: '财务中心', icon: IconIdcard, items: [ - { name: 'AdminPlans', label: '套餐管理', icon: IconIdcard }, { name: 'AdminOrders', label: '订单管理', icon: IconCodeBlock }, { name: 'AdminTransactions', label: '交易流水', icon: IconCommand }, { name: 'AdminRedemptions', label: '兑换码管理', icon: IconGift }, @@ -397,7 +396,6 @@ const menuGroups = [ { name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock }, { name: 'AdminSettings', label: '系统设置', icon: IconSettings }, { name: 'AdminFiles', label: '文件管理', icon: IconStorage }, - { name: 'AdminPaymentSettings', label: '支付设置', icon: IconStorage }, ], }, ] diff --git a/web/admin/src/views/DashboardPage.vue b/web/admin/src/views/DashboardPage.vue index 5a3bdce..15e10aa 100644 --- a/web/admin/src/views/DashboardPage.vue +++ b/web/admin/src/views/DashboardPage.vue @@ -1,5 +1,5 @@ - - diff --git a/web/admin/src/views/finance/PlansPage.vue b/web/admin/src/views/finance/PlansPage.vue index 2b00f02..e1311b2 100644 --- a/web/admin/src/views/finance/PlansPage.vue +++ b/web/admin/src/views/finance/PlansPage.vue @@ -9,6 +9,7 @@ import TableStats from '@/components/TableStats.vue' import ResponsiveTable from '@/components/ResponsiveTable.vue' import request from '@/utils/request' import { useExport } from '@/composables/useExport' +import { formatOrder } from '@/composables/useCurrency' const loading = ref(false) const plans = ref([]) @@ -24,8 +25,8 @@ const columns: TableColumnData[] = [ { title: 'ID', dataIndex: 'id', width: 70 }, { title: '名称', dataIndex: 'name', width: 120 }, { title: '标识', dataIndex: 'identifier', width: 100 }, - { title: '月价', dataIndex: 'monthly_price', width: 100, render({ record }) { return `¥${Number(record.monthly_price).toFixed(2)}` } }, - { title: '年价', dataIndex: 'yearly_price', width: 100, render({ record }) { return `¥${Number(record.yearly_price).toFixed(2)}` } }, + { title: '月价', dataIndex: 'monthly_price', width: 100, render({ record }) { return formatOrder(record.monthly_price, 2) } }, + { title: '年价', dataIndex: 'yearly_price', width: 100, render({ record }) { return formatOrder(record.yearly_price, 2) } }, { title: '状态', dataIndex: 'status', width: 80, render({ record }) { return h(Tag, { color: record.status === 'active' ? 'green' : undefined, size: 'small' }, () => record.status === 'active' ? '上架' : '下架') }, diff --git a/web/admin/src/views/finance/PromoCodesPage.vue b/web/admin/src/views/finance/PromoCodesPage.vue index 123407e..f7c8ef9 100644 --- a/web/admin/src/views/finance/PromoCodesPage.vue +++ b/web/admin/src/views/finance/PromoCodesPage.vue @@ -9,6 +9,7 @@ import TableStats from '@/components/TableStats.vue' import ResponsiveTable from '@/components/ResponsiveTable.vue' import request from '@/utils/request' import { useExport } from '@/composables/useExport' +import { formatOrder } from '@/composables/useCurrency' const loading = ref(false) const promoCodes = ref([]) @@ -164,7 +165,7 @@ const { exporting, exportFile } = useExport({
diff --git a/web/admin/src/views/finance/RedemptionsPage.vue b/web/admin/src/views/finance/RedemptionsPage.vue index 12de1bd..c4ccd62 100644 --- a/web/admin/src/views/finance/RedemptionsPage.vue +++ b/web/admin/src/views/finance/RedemptionsPage.vue @@ -9,6 +9,7 @@ import TableStats from '@/components/TableStats.vue' import ResponsiveTable from '@/components/ResponsiveTable.vue' import request from '@/utils/request' import { useExport } from '@/composables/useExport' +import { formatBilling } from '@/composables/useCurrency' const loading = ref(false) const redemptions = ref([]) @@ -88,7 +89,7 @@ const usageColumns: TableColumnData[] = [ const colors: any = { quota: 'green', plan: 'blue', duration: 'orangered' } return h(Tag, { color: colors[record.type], size: 'small' }, () => labels[record.type] || record.type) }}, - { title: '面值', dataIndex: 'value', width: 100, render({ record }) { return record.type === 'quota' ? `+${Number(record.value).toFixed(6)}` : '-' } }, + { title: '面值', dataIndex: 'value', width: 100, render({ record }) { return record.type === 'quota' ? formatBilling(record.value, 6, true) : '-' } }, { title: '组织名称', dataIndex: 'tenant_name', width: 140 }, { title: '兑换人', dataIndex: 'username', width: 120 }, { title: '时间', dataIndex: 'created_at', width: 170, render({ record }) { return record.created_at?.substring(0, 19) || '-' } }, diff --git a/web/admin/src/views/finance/TransactionsPage.vue b/web/admin/src/views/finance/TransactionsPage.vue index f0b1975..afb9ee3 100644 --- a/web/admin/src/views/finance/TransactionsPage.vue +++ b/web/admin/src/views/finance/TransactionsPage.vue @@ -6,6 +6,7 @@ import PageHeader from '@/components/PageHeader.vue' import TableStats from '@/components/TableStats.vue' import ResponsiveTable from '@/components/ResponsiveTable.vue' import request from '@/utils/request' +import { formatBilling } from '@/composables/useCurrency' const loading = ref(false) const data = ref([]) @@ -50,13 +51,13 @@ const columns: TableColumnData[] = [ render({ record }) { const amount = record.amount || 0 const color = amount > 0 ? 'rgb(var(--green-6))' : amount < 0 ? 'rgb(var(--red-6))' : undefined - const prefix = amount > 0 ? '+' : '' - return h('span', { style: { color, fontWeight: 500, fontFamily: 'monospace' } }, `${prefix}$${Math.abs(amount).toFixed(6)}`) + // bil 层本位币金额:showSign 自动带 +/- 符号(同时修复负数缺失负号的问题) + return h('span', { style: { color, fontWeight: 500, fontFamily: 'monospace' } }, formatBilling(amount, 6, true)) }, }, { title: '变动后余额', dataIndex: 'balance_after', width: 130, - render({ record }) { return `$${(record.balance_after || 0).toFixed(6)}` }, + render({ record }) { return formatBilling(record.balance_after || 0, 6) }, }, { title: '描述', dataIndex: 'description', width: 180, ellipsis: true, tooltip: true }, { title: '模型', dataIndex: 'model_name', width: 140, ellipsis: true, tooltip: true }, diff --git a/web/admin/src/views/monitor/ModelPerformancePage.vue b/web/admin/src/views/monitor/ModelPerformancePage.vue index 5c0c79f..13240d7 100644 --- a/web/admin/src/views/monitor/ModelPerformancePage.vue +++ b/web/admin/src/views/monitor/ModelPerformancePage.vue @@ -8,6 +8,7 @@ import TableStats from '@/components/TableStats.vue' import ResponsiveTable from '@/components/ResponsiveTable.vue' import TableEmpty from '@/components/TableEmpty.vue' import request from '@/utils/request' +import { displayCurrency, formatBilling } from '@/composables/useCurrency' const loading = ref(false) const data = ref([]) @@ -224,7 +225,8 @@ const gradeColor: Record = { critical: 'red', } -const columns: TableColumnData[] = [ +// 列定义为 computed:总成本列头币种跟随本位币(配置异步加载后自动更新) +const columns = computed(() => [ { title: '模型', dataIndex: 'model_name', ellipsis: true, tooltip: true, width: 220, sortable: { sortDirections: ['ascend', 'descend'] } }, { title: '请求数', @@ -299,14 +301,14 @@ const columns: TableColumnData[] = [ render: ({ record }: any) => fmtNum(record.total_tokens).toLocaleString(), }, { - title: '总成本 (USD)', + title: `总成本 (${displayCurrency.value})`, dataIndex: 'total_cost', width: 140, align: 'right', sortable: { sortDirections: ['ascend', 'descend'] }, - render: ({ record }: any) => `$${fmtNum(record.total_cost).toFixed(6)}`, + render: ({ record }: any) => formatBilling(fmtNum(record.total_cost), 6), }, -] +]) // 成功率分级(与后端 gradeSuccessRate 同阈值):excellent≥99 / good≥95 / warning≥90 / critical<90 function rateGrade(rate: number): string { @@ -520,7 +522,7 @@ function renderChannelMatrix(record: any) {
总成本
-

区间内该模型实际计费成本合计(USD),已含输入、输出与缓存各项费用。

+

区间内该模型实际计费成本合计({{ displayCurrency }}),已含输入、输出与缓存各项费用。

总成本 = Σ total_cost

@@ -596,8 +598,8 @@ function renderChannelMatrix(record: any) {
{{ stats.overallRate.toFixed(2) }}%
-
总成本 (USD)
-
${{ stats.totalCost.toFixed(6) }}
+
总成本 ({{ displayCurrency }})
+
{{ formatBilling(stats.totalCost, 6) }}
请求缓存命中率
diff --git a/web/admin/src/views/monitor/TrafficFlowPage.vue b/web/admin/src/views/monitor/TrafficFlowPage.vue index 673d786..d42e667 100644 --- a/web/admin/src/views/monitor/TrafficFlowPage.vue +++ b/web/admin/src/views/monitor/TrafficFlowPage.vue @@ -7,6 +7,7 @@ import { TooltipComponent } from 'echarts/components' import VChart from 'vue-echarts' import PageHeader from '@/components/PageHeader.vue' import request from '@/utils/request' +import { formatBilling } from '@/composables/useCurrency' // 桑基图无需 GridComponent(无笛卡尔坐标轴) use([CanvasRenderer, SankeyChart, TooltipComponent]) @@ -45,7 +46,8 @@ function defaultRange(days: number): [string, string] { } function formatValue(v: number): string { - if (metric.value === 'cost') return `$${Number(v).toFixed(6)}` + // 成本指标按 bil 层本位币展示(tooltip formatter 在悬浮时才调用,天然读取最新本位币) + if (metric.value === 'cost') return formatBilling(v, 6) return Number(v).toLocaleString() } diff --git a/web/admin/src/views/security/RequestAuditLogsPage.vue b/web/admin/src/views/security/RequestAuditLogsPage.vue index 349ee8b..62da0e8 100644 --- a/web/admin/src/views/security/RequestAuditLogsPage.vue +++ b/web/admin/src/views/security/RequestAuditLogsPage.vue @@ -128,7 +128,7 @@ function goToTask(taskId: string) { } const columns: TableColumnData[] = [ - { title: 'ID', dataIndex: 'id', width: 100 }, + { title: '时间', dataIndex: 'created_at', width: 180 }, { title: '租户', dataIndex: 'tenant_name', width: 120, ellipsis: true, render({ record }) { return record.tenant_name || record.tenant_id }}, @@ -180,7 +180,6 @@ const columns: TableColumnData[] = [ return h(Tag, { color: auditLevelTagColor[record.audit_level] || 'gray', size: 'small' }, () => auditLevelLabel[record.audit_level] || record.audit_level) }, }, - { title: '时间', dataIndex: 'created_at', width: 180 }, { title: '操作', dataIndex: 'actions', width: 80, fixed: 'right', render({ record }) { diff --git a/web/admin/src/views/system/settings/PaymentTab.vue b/web/admin/src/views/system/settings/PaymentTab.vue index 0c6490f..1db730a 100644 --- a/web/admin/src/views/system/settings/PaymentTab.vue +++ b/web/admin/src/views/system/settings/PaymentTab.vue @@ -26,11 +26,8 @@ function updateCnyToUsd(usdToCny: number | undefined) {
基础设置
- - - - - + + > = Symbol('settingsFormValues') @@ -35,6 +36,8 @@ export function useSettings(category: () => string) { try { await request.put(`/admin/settings/${category()}`, { settings: formValues }) Message.success('保存成功') + // 汇率等公共配置变更后强制刷新,已打开页面立即按新值重渲染货币显示 + refreshCurrencySettings() await refresh() } catch { // error toast already shown by interceptor diff --git a/web/admin/src/views/tenant/TenantLevelsPage.vue b/web/admin/src/views/tenant/TenantLevelsPage.vue index f99a124..2958d33 100644 --- a/web/admin/src/views/tenant/TenantLevelsPage.vue +++ b/web/admin/src/views/tenant/TenantLevelsPage.vue @@ -1,5 +1,5 @@ - + diff --git a/web/tenant/src/views/marketplace/components/ModelCard.vue b/web/tenant/src/views/marketplace/components/ModelCard.vue new file mode 100644 index 0000000..be91bd0 --- /dev/null +++ b/web/tenant/src/views/marketplace/components/ModelCard.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/web/tenant/src/views/marketplace/components/ModelDetailDrawer.vue b/web/tenant/src/views/marketplace/components/ModelDetailDrawer.vue new file mode 100644 index 0000000..8f5f664 --- /dev/null +++ b/web/tenant/src/views/marketplace/components/ModelDetailDrawer.vue @@ -0,0 +1,227 @@ + + + diff --git a/web/tenant/src/views/marketplace/marketplaceMeta.ts b/web/tenant/src/views/marketplace/marketplaceMeta.ts new file mode 100644 index 0000000..4d18f38 --- /dev/null +++ b/web/tenant/src/views/marketplace/marketplaceMeta.ts @@ -0,0 +1,120 @@ +import type { MarketplaceModel, TimePriceItem } from '@/api/marketplace' +import { formatBilling } from '@/composables/useCurrency' + +// 分类元数据:卡片磁贴渐变、筛选 pills 与各类徽章共用同一份配置 +export interface CategoryMeta { + key: string + // null 表示「全部」,请求时不传 category + value: string | null + // 完整名称:卡片类别徽章 / 抽屉 / 规格行 + label: string + // 两个字:筛选 pills 用 + shortLabel: string + // hover 提示 + description: string + // Icon.vue 内置图标名 + icon: string + // 渐变起止色 + from: string + to: string + // 发光阴影 RGB("r, g, b"),供 rgba() 拼接 box-shadow + glow: string +} + +// 色值与全局 .icon-tile-* 系列保持一致,chat 占据主色 teal +export const categoryMetaList: CategoryMeta[] = [ + { key: 'all', value: null, label: '全部模型', shortLabel: '全部', description: '浏览完整模型目录', icon: 'grid', from: '#2dd4bf', to: '#0d9488', glow: '20, 184, 166' }, + { key: 'chat', value: 'chat', label: '对话与推理', shortLabel: '对话', description: '文本生成、推理与工具调用', icon: 'chat', from: '#2dd4bf', to: '#0d9488', glow: '20, 184, 166' }, + { key: 'embedding', value: 'embedding', label: '向量嵌入', shortLabel: '向量', description: '语义检索与知识库构建', icon: 'cube', from: '#a78bfa', to: '#8b5cf6', glow: '139, 92, 246' }, + { key: 'image', value: 'image', label: '图像生成', shortLabel: '图像', description: '文生图、图像理解与编辑', icon: 'eye', from: '#f472b6', to: '#ec4899', glow: '236, 72, 153' }, + { key: 'audio', value: 'audio', label: '音频处理', shortLabel: '音频', description: '语音识别、合成与音乐生成', icon: 'bell', from: '#fbbf24', to: '#f59e0b', glow: '245, 158, 11' }, + { key: 'rerank', value: 'rerank', label: '重排序', shortLabel: '重排', description: '检索结果相关性排序', icon: 'trendingUp', from: '#22d3ee', to: '#06b6d4', glow: '6, 182, 212' }, +] + +// 未知分类的渲染兜底:仅用于展示,绝不进入筛选 pills +export const fallbackCategoryMeta: CategoryMeta = { + key: 'other', value: null, label: '其他模型', shortLabel: '其他', description: '其他模型能力', icon: 'bolt', from: '#d1d5db', to: '#9ca3af', glow: '156, 163, 175', +} + +export function getCategoryMeta(category?: string | null): CategoryMeta { + if (!category) return categoryMetaList[0] + return categoryMetaList.find(item => item.value === category) || fallbackCategoryMeta +} + +// 能力中文标签:合并控制台模型页的映射,缺失的 key 回退为可读文本 +const capabilityLabels: Record = { + streaming: '流式输出', + function_calling: '工具调用', + tool_calling: '工具调用', + parallel_function_calling: '并行工具调用', + tool_choice: '工具选择', + vision: '视觉理解', + multimodal: '多模态', + json_mode: 'JSON 模式', + response_schema: '结构化输出', + system_messages: '系统消息', + reasoning: '深度思考', + web_search: '联网搜索', + image_generation: '图像生成', + prompt_caching: '提示词缓存', + audio: '音频能力', + audio_input: '音频输入', + audio_output: '音频输出', + pdf_input: 'PDF 输入', + embedding_image: '图像嵌入', +} + +// capabilities 是 { key: boolean } 形态,只取为真的项并转成中文标签 +export function getCapabilityList(model: MarketplaceModel, limit = 4): string[] { + if (!model.capabilities || typeof model.capabilities !== 'object') return [] + return Object.entries(model.capabilities) + .filter(([, value]) => value === true || value === 1 || value === 'true') + .map(([key]) => capabilityLabels[key] || key.replaceAll('_', ' ')) + .filter((item, index, list) => list.indexOf(item) === index) + .slice(0, limit) +} + +export function formatTokens(tokens?: number | null): string { + const value = Number(tokens || 0) + if (!value) return '—' + if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M` + if (value >= 1_000) return `${Number((value / 1_000).toFixed(1))}K` + return value.toLocaleString('en-US') +} + +export function formatPrice(price?: number | null): string { + const value = Number(price) + if (!Number.isFinite(value)) return '—' + // 本位币直显:最多保留 6 位小数(与后端 NUMERIC(20,10) 精度对齐),自动去掉末尾的 0 + return formatBilling(value, 6) +} + +export function billingModeLabel(mode?: string | null): string { + if (mode === 'per_request') return '按次计费' + if (mode === 'tiered') return '阶梯计费' + return '按量计费' +} + +// ── 时段价目展示辅助(价格为后端换算好的展示价,前端直接渲染) ── + +function daysLabel(days?: number[] | null): string { + if (!days || days.length === 0) return '每天' + const sorted = [...days].sort((a, b) => a - b) + if (sorted.join(',') === '1,2,3,4,5') return '工作日' + if (sorted.join(',') === '6,7') return '周末' + const names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'] + return sorted.map((d) => names[d] || String(d)).join('、') +} + +export function timeWindow(tp: TimePriceItem): string { + const parts: string[] = [daysLabel(tp.days)] + if (tp.start_time && tp.end_time) parts.push(`${tp.start_time}~${tp.end_time}`) + else parts.push('全天') + if (tp.valid_from) parts.push(tp.valid_to ? `${tp.valid_from}~${tp.valid_to}` : `${tp.valid_from} 起`) + return parts.join(' · ') +} + +export function timePriceText(model: MarketplaceModel, tp: TimePriceItem): string { + if (model.billing_mode === 'per_request') return `${formatPrice(tp.per_request_price)} /次` + return `输入 ${formatPrice(tp.input_price)} · 输出 ${formatPrice(tp.output_price)}${model.billing_mode === 'tiered' ? ' 起' : ''}` +} diff --git a/web/tenant/src/views/service/ApiKeysPage.vue b/web/tenant/src/views/service/ApiKeysPage.vue index a156472..94e3a80 100644 --- a/web/tenant/src/views/service/ApiKeysPage.vue +++ b/web/tenant/src/views/service/ApiKeysPage.vue @@ -11,6 +11,7 @@ import { renderBadge, tableScrollX } from '@/utils/renderUtils' import request from '@/utils/request' import { toast } from '@/utils/toast' import { useConfirm } from '@/composables/useConfirm' +import { formatBilling } from '@/composables/useCurrency' const { confirm } = useConfirm() @@ -143,10 +144,11 @@ function formatLimit(value: number | null | undefined, suffix = ''): string { return `${value}${suffix}` } +// Key 额度展示(bil 层,本位币直显) function formatQuota(key: ApiKey): string { const used = key.used_quota || 0 - if (!key.total_quota || key.total_quota <= 0) return `$${used.toFixed(2)} / 不限` - return `$${used.toFixed(2)} / $${key.total_quota.toFixed(2)}` + if (!key.total_quota || key.total_quota <= 0) return `${formatBilling(used, 2)} / 不限` + return `${formatBilling(used, 2)} / ${formatBilling(key.total_quota, 2)}` } async function disableKey(keyId: number) { diff --git a/web/tenant/src/views/service/ModelComparisonPage.vue b/web/tenant/src/views/service/ModelComparisonPage.vue index ae481cd..3a67d2f 100644 --- a/web/tenant/src/views/service/ModelComparisonPage.vue +++ b/web/tenant/src/views/service/ModelComparisonPage.vue @@ -3,6 +3,7 @@ import { ref, reactive, onMounted, computed, h } from 'vue' import type { DataTableColumns } from 'naive-ui' import request from '@/utils/request' import { tableScrollX } from '@/utils/renderUtils' +import { formatBilling } from '@/composables/useCurrency' import Icon from '@/components/common/Icon.vue' import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue' @@ -87,9 +88,10 @@ function isBest(item: ComparisonItem, field: keyof ComparisonItem): boolean { return val === Math.min(...values) } +// 金额格式化统一走本位币(bil 层数据直显,formatBilling 内部读取响应式 displayCurrency 自动重渲染) function formatCost(v: number): string { - if (v < 0.01 && v > 0) return v.toFixed(6) - return v.toFixed(4) + if (v < 0.01 && v > 0) return formatBilling(v, 6) + return formatBilling(v, 4) } function formatPct(v: number): string { @@ -191,7 +193,7 @@ const columns = computed>(() => [ h( 'span', { class: isBest(row, 'total_cost') && row.requests > 0 ? 'text-emerald-600 font-medium' : '' }, - row.requests > 0 ? '$' + formatCost(row.total_cost) : '-' + row.requests > 0 ? formatCost(row.total_cost) : '-' ), }, { @@ -202,7 +204,7 @@ const columns = computed>(() => [ h( 'span', { class: isBest(row, 'avg_cost_per_request') && row.requests > 0 ? 'text-emerald-600 font-medium' : '' }, - row.requests > 0 ? '$' + formatCost(row.avg_cost_per_request) : '-' + row.requests > 0 ? formatCost(row.avg_cost_per_request) : '-' ), }, { @@ -305,7 +307,7 @@ const columns = computed>(() => [
-
${{ formatCost(summary.total_cost) }}
+
{{ formatCost(summary.total_cost) }}
总费用
@@ -362,7 +364,7 @@ const columns = computed>(() => [ height: Math.max(2, (getTrendCost(day, model) / maxTrendCost) * 120) + 'px', backgroundColor: modelColor(mi), }" - :title="`${model}: $${getTrendCost(day, model).toFixed(4)}`" + :title="`${model}: ${formatBilling(getTrendCost(day, model), 4)}`" >
diff --git a/web/tenant/src/views/service/ModelsPage.vue b/web/tenant/src/views/service/ModelsPage.vue index 7e08982..3342832 100644 --- a/web/tenant/src/views/service/ModelsPage.vue +++ b/web/tenant/src/views/service/ModelsPage.vue @@ -4,6 +4,7 @@ import { NInput } from 'naive-ui' import { useRouter } from 'vue-router' import Icon from '@/components/common/Icon.vue' import request from '@/utils/request' +import { formatBilling } from '@/composables/useCurrency' const router = useRouter() @@ -170,9 +171,10 @@ function formatTokens(n: number): string { return String(n) } +// 价格格式化统一走本位币(bil 层定价直显,formatBilling 内部读取响应式 displayCurrency 自动重渲染) function formatPrice(n: number | null): string { if (n == null || n === 0) return '-' - return '$' + n.toFixed(2) + return formatBilling(n, 2) } function formatTokenRange(min: number, max: number | null): string { diff --git a/web/tenant/src/views/service/TaskLogsPage.vue b/web/tenant/src/views/service/TaskLogsPage.vue index 8c2c0ad..44a19c5 100644 --- a/web/tenant/src/views/service/TaskLogsPage.vue +++ b/web/tenant/src/views/service/TaskLogsPage.vue @@ -10,6 +10,7 @@ import BaseSelect from '../../components/common/BaseSelect.vue' import request from '@/utils/request' import { tableScrollX } from '@/utils/renderUtils' import { useExport } from '@/composables/useExport' +import { formatBilling } from '@/composables/useCurrency' import DateTimeRangePicker from '@/components/common/DateTimeRangePicker.vue' // 日期辅助(native,避免引入 dayjs 依赖) @@ -110,9 +111,9 @@ const platformLabel: Record = { ali: '阿里', } +// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染) function formatCost(n: number | undefined): string { - if (!n) return '$0.000000' - return '$' + n.toFixed(6) + return formatBilling(n, 6) } function formatTime(s: string | undefined): string { diff --git a/web/tenant/src/views/service/UsageLogsPage.vue b/web/tenant/src/views/service/UsageLogsPage.vue index 3ee11d4..bede5dc 100644 --- a/web/tenant/src/views/service/UsageLogsPage.vue +++ b/web/tenant/src/views/service/UsageLogsPage.vue @@ -10,6 +10,7 @@ import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue' import request from '@/utils/request' import { tableScrollX } from '@/utils/renderUtils' import { useExport } from '@/composables/useExport' +import { formatBilling } from '@/composables/useCurrency' // timestamp(ms) ↔ 后端字符串 YYYY-MM-DD HH:mm:ss function tsToStr(ts: number): string { @@ -186,10 +187,11 @@ function openDetail(log: any) { detailModal.value = true } +// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染) function formatCost(n: any): string { const v = Number(n) - if (n == null || isNaN(v)) return '$0.000000' - return '$' + v.toFixed(6) + if (n == null || isNaN(v)) return formatBilling(0, 6) + return formatBilling(v, 6) } function formatMs(n: any): string { diff --git a/web/tenant/src/views/service/playground/calculateCost.ts b/web/tenant/src/views/service/playground/calculateCost.ts index be88c03..958ad4e 100644 --- a/web/tenant/src/views/service/playground/calculateCost.ts +++ b/web/tenant/src/views/service/playground/calculateCost.ts @@ -11,9 +11,11 @@ export interface TokenUsage { total_tokens?: number } +import { formatBilling } from '@/composables/useCurrency' + /** * calculateCost 根据模型定价和 token usage 估算费用 - * @returns 格式化的费用字符串,如 "$0.001234";无法计算时返回 null + * @returns 格式化的费用字符串(跟随本位币),如 "$0.001234";无法计算时返回 null */ export function calculateCost( model: ModelPricing | undefined, @@ -27,7 +29,7 @@ export function calculateCost( // 按次计费 if (model.billing_mode === 'per_request' && model.per_request_price) { const price = Number(model.per_request_price) - if (price > 0) return `$${price.toFixed(6)}` + if (price > 0) return formatBilling(price, 6) return null } @@ -40,5 +42,5 @@ export function calculateCost( const cost = (promptTokens * inputPrice + completionTokens * outputPrice) / 1_000_000 if (cost <= 0) return null - return `$${cost.toFixed(6)}` + return formatBilling(cost, 6) } diff --git a/web/tenant/src/views/settings/OrganizationPage.vue b/web/tenant/src/views/settings/OrganizationPage.vue index 907c552..d26dc50 100644 --- a/web/tenant/src/views/settings/OrganizationPage.vue +++ b/web/tenant/src/views/settings/OrganizationPage.vue @@ -6,6 +6,7 @@ import Icon from '@/components/common/Icon.vue' import BaseModal from '@/components/common/BaseModal.vue' import request from '@/utils/request' import { toast } from '@/utils/toast' +import { formatBilling } from '@/composables/useCurrency' const authStore = useTenantAuthStore() @@ -210,9 +211,10 @@ function formatDiscount(multiplier: number) { return `${discount === 0 ? '无折扣' : discount + '% 折扣'}` } +// 升级门槛属 bil 层(本位币),直显 + 本位币符号 function formatThreshold(val: number) { if (val === 0) return '—' - return '$' + val.toFixed(2) + return formatBilling(val, 2) } function getNextLevelThreshold() { @@ -468,7 +470,7 @@ onMounted(() => {
- 累计充值 ${{ levelBenefits.cumulative_recharge.toFixed(2) }} + 累计充值 {{ formatBilling(levelBenefits.cumulative_recharge, 2) }}
@@ -482,7 +484,7 @@ onMounted(() => { >

- 再充值 ${{ (getNextLevelThreshold()! - levelBenefits.cumulative_recharge).toFixed(2) }} 即可升级 + 再充值 {{ formatBilling(getNextLevelThreshold()! - levelBenefits.cumulative_recharge, 2) }} 即可升级

diff --git a/web/tenant/src/views/team/MemberDetailPage.vue b/web/tenant/src/views/team/MemberDetailPage.vue index 8231c5e..ee0332f 100644 --- a/web/tenant/src/views/team/MemberDetailPage.vue +++ b/web/tenant/src/views/team/MemberDetailPage.vue @@ -9,6 +9,7 @@ import Icon from '@/components/common/Icon.vue' import BaseSelect from '../../components/common/BaseSelect.vue' import request from '@/utils/request' import { toast } from '@/utils/toast' +import { displayCurrency, formatBilling } from '@/composables/useCurrency' const router = useRouter() const route = useRoute() @@ -163,8 +164,8 @@ const statCards = computed(() => { icon: 'creditCard', color: '#8b5cf6', soft: '#ede9fe', - value: u ? `$${u.month_total_cost.toFixed(2)}` : '--', - detail: u ? 'USD' : '', + value: u ? formatBilling(u.month_total_cost, 2) : '--', + detail: u ? displayCurrency.value : '', }, { label: 'API Key 数量', @@ -606,7 +607,7 @@ onMounted(() => {
本月消费 - ${{ usage.month_total_cost.toFixed(4) }} + {{ formatBilling(usage.month_total_cost, 4) }}
API Key 数量 @@ -648,7 +649,7 @@ onMounted(() => { - ${{ quota.quota_used.toFixed(6) }} / ${{ quota.quota_limit.toFixed(6) }} + {{ formatBilling(quota.quota_used, 6) }} / {{ formatBilling(quota.quota_limit, 6) }}
@@ -813,7 +814,7 @@ onMounted(() => {
- + >(() => [ key: 'budget', width: 140, render: (row) => - h('span', { class: 'font-mono' }, row.budget ? `$${Number(row.budget).toFixed(2)}` : '不限'), + h('span', { class: 'font-mono' }, row.budget ? formatBilling(row.budget, 2) : '不限'), }, { title: '创建时间',