Merge pull request #23 from qianfree/dev

新增本位币设置
This commit is contained in:
Qian authored and GitHub committed 2026-08-29 10:25:44 +08:00
commit 623b43c2ce
104 files changed
+2295 -3972

No files matched your search

+20 -16
View File
@@ -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`)
+7 -1
View File
@@ -150,7 +150,11 @@ type ChannelDetailRes struct {
KeyType string `json:"key_type"`
KeyStatus string `json:"key_status"`
KeyName string `json:"key_name"`
TokenExpiresAt string `json:"token_expires_at"`
// KeyCooldownRemaining 凭证冷却剩余秒数(0=未冷却)。冷却期内该 Key 会被调度器
// 整段跳过且不发起上游请求,渠道全部 Key 冷却即整体不可用——必须可见,否则
// 表现为"健康分正常、探测也正常,但就是无可用渠道"。
KeyCooldownRemaining int `json:"key_cooldown_remaining"`
TokenExpiresAt string `json:"token_expires_at"`
}
// ChannelKeyCreateReq 添加渠道 Key 请求
@@ -236,6 +240,8 @@ type ChannelKeyItem struct {
KeyType string `json:"key_type"`
TokenExpiresAt string `json:"token_expires_at"`
CreatedAt string `json:"created_at"`
// CooldownRemaining 凭证冷却剩余秒数(0=未冷却),含义见 ChannelDetailRes.KeyCooldownRemaining
CooldownRemaining int `json:"cooldown_remaining"`
}
// ChannelAbilitiesGetReq 获取渠道模型能力请求
+1 -1
View File
@@ -786,7 +786,7 @@ err := g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error {
**原因**:`g.Redis().Do(ctx, "HMGET", key, "f1", "f2")` 对**不存在的 key/字段**,经 gf redis contrib 驱动转换后返回的是 `[]string{"", ""}`(空字符串),`.Vars()` 后每个元素 **`IsNil()==false`**、`Float64()==0`。`ReadRuntime` 用 `!vals[0].IsNil()` 判「有数据才覆盖乐观默认值 1」,判断永远为真 → 默认值被 0 覆盖。调度打分侧 `healthFactor` 恰好有 `succ<=0 && lat==0 视为满分` 的兜底所以转发不受影响,但快照聚合没有兜底,展示值被拖垮。`readBreaker` 同模式(空串 state 恰好 Int()==0==CLOSED 侥幸正确)。
**修复**:`internal/dispatchadapter/state_redis.go` `ReadRuntime`/`readBreaker` 改为 `vals[i].String() != ""` 判有无数据;空串一律按无数据处理保留默认值。回归测试 `Test缺失健康键读默认值`。
**修复**:`internal/dispatchadapter/state_redis.go` `ReadRuntime`/`readBreaker` 改为 `vals[i].String() != ""` 判有无数据;空串一律按无数据处理保留默认值。回归测试 `TestMissingHealthKey_ReadsDefault`。
**正确做法(通用规则)**:判断 gf redis `HMGET`/批量读的字段是否存在,**不要依赖 `IsNil()`**(驱动转换后 nil 会变空字符串);用 `String() != ""` 判断,或改用 Lua 脚本内 `HGET ... or '默认值'` 在 Redis 侧兜底。单 key `HGET` 缺失返回的是真 nil(`IsNil()==true`),两者行为不一致,混用时尤其注意。
+9
View File
@@ -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: "用户协议",
+19 -19
View File
@@ -24,7 +24,7 @@ func testCatalog(rows []catalogRow, keys map[int64][]int64, runtime runtimeReade
return c
}
func TestCatalog_快照构建与范围过滤(t *testing.T) {
func TestCatalog_SnapshotBuildAndScopeFilter(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary", CostRatio: 0.8},
{ChannelID: 2, ChannelName: "B", ModelName: "gpt-4o", Weight: 5, Tier: "secondary", CostRatio: 1.0},
@@ -50,7 +50,7 @@ func TestCatalog_快照构建与范围过滤(t *testing.T) {
assert.Empty(t, c.Snapshot(context.Background(), 0, "unknown", nil))
}
func TestCatalog_转发元数据(t *testing.T) {
func TestCatalog_ForwardMeta(t *testing.T) {
rows := []catalogRow{
{ChannelID: 2, ChannelName: "B", ChannelType: 3, BaseURL: "https://up.example.com",
ModelName: "claude-sonnet", UpstreamModel: "claude-sonnet-4", Weight: 5, Tier: "secondary",
@@ -76,7 +76,7 @@ func TestCatalog_转发元数据(t *testing.T) {
assert.False(t, ok)
}
func TestCatalog_运行时读值合并(t *testing.T) {
func TestCatalog_RuntimeReadoutMerge(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ModelName: "m", Weight: 10, Tier: "primary", MaxConcurrency: 50},
}
@@ -96,7 +96,7 @@ func TestCatalog_运行时读值合并(t *testing.T) {
assert.Equal(t, dispatch.BreakerHalfOpen, got[0].Breaker)
}
func TestCatalog_严格容量查询(t *testing.T) {
func TestCatalog_StrictCapacityLookup(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ModelName: "m", Weight: 10, Tier: "primary", StrictCapacity: true, MaxConcurrency: 30},
{ChannelID: 2, ModelName: "m", Weight: 10, Tier: "primary"},
@@ -111,7 +111,7 @@ func TestCatalog_严格容量查询(t *testing.T) {
assert.False(t, strict)
}
func TestCatalog_加载失败保留上一份快照(t *testing.T) {
func TestCatalog_LoadFailureKeepsPreviousSnapshot(t *testing.T) {
pol := dispatch.DefaultRoutingPolicy()
calls := 0
c := NewCatalog(
@@ -133,10 +133,10 @@ func TestCatalog_加载失败保留上一份快照(t *testing.T) {
assert.Len(t, c.Snapshot(ctx, 0, "m", nil), 1, "last-known 快照必须保留")
}
// TestCatalog_Redis降级沿用上一份健康读值 Redis 读失败时若让乐观默认值 succ=1 落进
// TestCatalog_RedisDegradedCarriesForwardHealth Redis 读失败时若让乐观默认值 succ=1 落进
// 新快照,一次 Redis 抖动就会把全渠道的健康记忆清零、healthFactor 集体回满分,
// 与 Rebuild 声称的 last-known 语义相悖。
func TestCatalog_Redis降级沿用上一份健康读值(t *testing.T) {
func TestCatalog_RedisDegradedCarriesForwardHealth(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary", CostRatio: 1},
}
@@ -160,9 +160,9 @@ func TestCatalog_Redis降级沿用上一份健康读值(t *testing.T) {
assert.Equal(t, 4000.0, got[0].LatEwmaMs, "延迟读值同样沿用")
}
// TestCatalog_首次刷新即降级无历史可沿用 新实例启动时 Redis 就不可用:没有 last-known
// TestCatalog_DegradedOnFirstRefreshHasNoHistory 新实例启动时 Redis 就不可用:没有 last-known
// 可沿用,保持乐观默认满分(不能让新实例启动即把全渠道判死)。
func TestCatalog_首次刷新即降级无历史可沿用(t *testing.T) {
func TestCatalog_DegradedOnFirstRefreshHasNoHistory(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary", CostRatio: 1},
}
@@ -191,9 +191,9 @@ func TestRampElapsed(t *testing.T) {
assert.Equal(t, int64(-1), rampElapsed(now, now-10, 0, 0))
}
// TestCatalog_渠道运行状态聚合 验证 ChannelRuntimeStates 按渠道去重、渠道级熔断取同值、
// TestCatalog_ChannelRuntimeStateAggregation 验证 ChannelRuntimeStates 按渠道去重、渠道级熔断取同值、
// 模型级熔断按模型计数。
func TestCatalog_渠道运行状态聚合(t *testing.T) {
func TestCatalog_ChannelRuntimeStateAggregation(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary"},
{ChannelID: 1, ChannelName: "A", ModelName: "claude-sonnet", Weight: 10, Tier: "primary"},
@@ -227,16 +227,16 @@ func TestCatalog_渠道运行状态聚合(t *testing.T) {
assert.Equal(t, 0, got[3].BreakerModels)
}
// TestCatalog_运行状态_未Rebuild返回空 验证目录未构建时返回空 map(而非 nil),
// TestCatalog_RuntimeState_EmptyBeforeRebuild 验证目录未构建时返回空 map(而非 nil),
// 调用方遍历时安全。
func TestCatalog_运行状态_未Rebuild返回空(t *testing.T) {
func TestCatalog_RuntimeState_EmptyBeforeRebuild(t *testing.T) {
c := NewCatalog(func() *dispatch.RoutingPolicy { return dispatch.DefaultRoutingPolicy() }, nil, nil)
assert.Empty(t, c.ChannelRuntimeStates())
}
// TestCatalog_最差模型_单模型故障不被均值摊薄 渠道健康分是均值,10 个健康模型 + 1 个
// TestCatalog_WorstModel_SingleFailureNotDilutedByAverage 渠道健康分是均值,10 个健康模型 + 1 个
// 全挂仍能算出 90 分绿灯;最差项要如实报出那个挂掉的模型。
func TestCatalog_最差模型_单模型故障不被均值摊薄(t *testing.T) {
func TestCatalog_WorstModel_SingleFailureNotDilutedByAverage(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "healthy-a", Weight: 10, Tier: "primary"},
{ChannelID: 1, ChannelName: "A", ModelName: "healthy-b", Weight: 10, Tier: "primary"},
@@ -256,9 +256,9 @@ func TestCatalog_最差模型_单模型故障不被均值摊薄(t *testing.T) {
assert.InDelta(t, 1.0, got[1].WorstModelScore, 1e-6)
}
// TestCatalog_最差模型_冷模型不被误报 无真实上报的模型读作满分 1.0,取最小值天然
// TestCatalog_WorstModel_ColdModelNotReported 无真实上报的模型读作满分 1.0,取最小值天然
// 不会把它选成最差项——这正是拖垮渠道均值的缺陷在 min 聚合下不存在的原因。
func TestCatalog_最差模型_冷模型不被误报(t *testing.T) {
func TestCatalog_WorstModel_ColdModelNotReported(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "cold", Weight: 10, Tier: "primary"},
{ChannelID: 1, ChannelName: "A", ModelName: "warm", Weight: 10, Tier: "primary"},
@@ -275,9 +275,9 @@ func TestCatalog_最差模型_冷模型不被误报(t *testing.T) {
assert.InDelta(t, 64.0, got[1].WorstModelScore, 1e-6) // 0.8² × 100
}
// TestCatalog_最差模型_同分时结果稳定 byModel 是 map,遍历顺序随机;同分不做确定性
// TestCatalog_WorstModel_StableOnTiedScores byModel 是 map,遍历顺序随机;同分不做确定性
// 打破平局的话,全部模型满分时每次请求返回的模型名都会跳变。
func TestCatalog_最差模型_同分时结果稳定(t *testing.T) {
func TestCatalog_WorstModel_StableOnTiedScores(t *testing.T) {
rows := []catalogRow{
{ChannelID: 1, ChannelName: "A", ModelName: "zeta", Weight: 10, Tier: "primary"},
{ChannelID: 1, ChannelName: "A", ModelName: "alpha", Weight: 10, Tier: "primary"},
+9 -9
View File
@@ -24,7 +24,7 @@ func staticReader(m map[string]RuntimeReadout) func(string) RuntimeReadout {
}
}
func TestAggregateChannelHealth_只统计有真实上报的模型(t *testing.T) {
func TestAggregateChannelHealth_CountsOnlyModelsWithRealReports(t *testing.T) {
// 20 个模型的渠道死掉 1 个:旧实现把 19 个无数据模型按满分计入均值,
// avgSucc≈0.95 → 91 分绿灯,完全掩盖故障。
models := make([]string, 0, 20)
@@ -43,7 +43,7 @@ func TestAggregateChannelHealth_只统计有真实上报的模型(t *testing.T)
assert.Contains(t, agg.Detail, "b=无数据")
}
func TestAggregateChannelHealth_多模型均值(t *testing.T) {
func TestAggregateChannelHealth_MultiModelAverage(t *testing.T) {
agg := aggregateChannelHealth([]string{"m1", "m2", "m3"}, staticReader(map[string]RuntimeReadout{
"m1": readoutOf(1.0, 100),
"m2": readoutOf(0.6, 300),
@@ -55,7 +55,7 @@ func TestAggregateChannelHealth_多模型均值(t *testing.T) {
assert.InDelta(t, 200, agg.AvgLat, 1e-9)
}
func TestAggregateChannelHealth_全部无数据不产生均值(t *testing.T) {
func TestAggregateChannelHealth_NoAverageWhenAllModelsLackData(t *testing.T) {
agg := aggregateChannelHealth([]string{"m1", "m2"}, staticReader(nil))
assert.False(t, agg.Degraded)
@@ -63,7 +63,7 @@ func TestAggregateChannelHealth_全部无数据不产生均值(t *testing.T) {
assert.Zero(t, agg.AvgSucc, "不得回落成默认满分 1.0 造成 100 分落盘")
}
func TestAggregateChannelHealth_读失败整体降级(t *testing.T) {
func TestAggregateChannelHealth_ReadFailureDegradesWhole(t *testing.T) {
// Redis 故障时读值既非实测也非"确实无数据",必须整体作废而非按满分聚合
agg := aggregateChannelHealth([]string{"m1", "m2", "m3"}, staticReader(map[string]RuntimeReadout{
"m1": readoutOf(0.9, 100),
@@ -75,7 +75,7 @@ func TestAggregateChannelHealth_读失败整体降级(t *testing.T) {
assert.Zero(t, agg.Counted, "降级时不得给出任何聚合结论")
}
func TestAggregateChannelHealth_首模型读值供自动禁用判定(t *testing.T) {
func TestAggregateChannelHealth_FirstModelReadoutFeedsAutoDisable(t *testing.T) {
first := readoutOf(0.5, 100)
first.FirstOpenedMs = 12345
agg := aggregateChannelHealth([]string{"m1", "m2"}, staticReader(map[string]RuntimeReadout{
@@ -86,9 +86,9 @@ func TestAggregateChannelHealth_首模型读值供自动禁用判定(t *testing.
assert.Equal(t, int64(12345), agg.ChannelRt.FirstOpenedMs, "渠道级熔断读值取首个模型")
}
// TestRequestHealthSnapshot_不阻塞且满时丢弃 调用方是管理后台请求线程(渠道测试 /
// TestRequestHealthSnapshot_NonBlockingDropsWhenFull 调用方是管理后台请求线程(渠道测试 /
// 重置健康度),队列满时必须丢弃而非阻塞——下一轮定时维护会兜底重算。
func TestRequestHealthSnapshot_不阻塞且满时丢弃(t *testing.T) {
func TestRequestHealthSnapshot_NonBlockingDropsWhenFull(t *testing.T) {
drain := func() {
for {
select {
@@ -119,9 +119,9 @@ func TestRequestHealthSnapshot_不阻塞且满时丢弃(t *testing.T) {
assert.Len(t, healthRefresh, cap(healthRefresh), "满队列不应再增长")
}
// TestRefreshChannelHealth_未组装时静默返回 管理后台可能在首次 relay 请求前就触发重算,
// TestRefreshChannelHealth_SilentWhenNotWired 管理后台可能在首次 relay 请求前就触发重算,
// 此时调度引擎尚未组装(catalog / redisState 为 nil),不得 panic。
func TestRefreshChannelHealth_未组装时静默返回(t *testing.T) {
func TestRefreshChannelHealth_SilentWhenNotWired(t *testing.T) {
require.Nil(t, catalog, "前置条件:本包测试不组装调度引擎")
assert.NotPanics(t, func() { refreshChannelHealth(context.Background(), 1) })
}
+7
View File
@@ -106,6 +106,13 @@ func (l *localState) coolCred(keyID int64, ttl time.Duration) {
l.credCD[keyID] = time.Now().Add(ttl)
}
// clearCred 解除本地凭证冷却镜像(更换 Key / 管理员重置时调用)。
func (l *localState) clearCred(keyID int64) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.credCD, keyID)
}
func (l *localState) isCredCooled(keyID int64) bool {
l.mu.Lock()
defer l.mu.Unlock()
+15
View File
@@ -571,6 +571,21 @@ func (s *RedisState) CoolCredential(ctx context.Context, keyID int64, ttl time.D
}
}
// ClearCredentialCooldown 解除渠道 Key 的凭证冷却(更换 Key / 管理员重置健康度时调用)。
// 必须连同本地镜像一起清:只删 Redis 的话,Redis 故障降级期间本实例仍会按镜像跳过该 Key。
func (s *RedisState) ClearCredentialCooldown(ctx context.Context, keyID int64) {
s.local.clearCred(keyID)
clearCredCooldownKey(ctx, keyID)
}
// clearCredCooldownKey 删除 Redis 冷却标记。独立于 RedisState:管理后台可能在调度引擎
// 组装前就更换 Key,而上个进程写下的标记仍在 Redis 里(TTL 未到),此时也必须能清掉。
func clearCredCooldownKey(ctx context.Context, keyID int64) {
if _, err := g.Redis().Do(ctx, "DEL", keyCredCD+strconv.FormatInt(keyID, 10)); err != nil {
g.Log().Warningf(ctx, "[Dispatch] 凭证冷却解除失败: key=%d err=%v", keyID, err)
}
}
// CredentialCooldownRemaining 返回渠道 Key 的凭证冷却剩余秒数(0 = 未在冷却)。
// 凭证冷却是调度器内部的瞬态状态(Redis credcd 标记 + TTL),无任何管理页面出口;
// 本函数供管理后台 Key 列表/渠道详情展示。读 TTL 而非 EXISTS,让前端能显示剩余时间。
+70 -41
View File
@@ -45,9 +45,9 @@ func newTestState(t *testing.T, mutate func(*dispatch.RoutingPolicy)) *RedisStat
return NewRedisState(func() *dispatch.RoutingPolicy { return pol }, nil)
}
// Test并发健康EWMA原子性 H1 修复验证:100 并发失败经 Lua 原子读-算-写,
// TestHealthEwma_ConcurrentAtomicity H1 修复验证:100 并发失败经 Lua 原子读-算-写,
// EWMA 精确等于 0.93^100,熔断窗口计数精确 =100(读改写竞态会导致丢失更新)。
func Test并发健康EWMA原子性(t *testing.T) {
func TestHealthEwma_ConcurrentAtomicity(t *testing.T) {
ctx := context.Background()
s := newTestState(t, func(p *dispatch.RoutingPolicy) {
p.Breaker.FailThreshold = 1000 // 阈值调高,只验证计数不触发熔断
@@ -76,7 +76,7 @@ func Test并发健康EWMA原子性(t *testing.T) {
assert.Equal(t, 100, v.Int(), "熔断窗口失败计数必须精确 =100")
}
func Test健康衰减分档(t *testing.T) {
func TestHealthDecay_Tiers(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -104,10 +104,10 @@ func Test健康衰减分档(t *testing.T) {
assert.InEpsilon(t, 800, rt.LatEwmaMs, 1e-9)
}
// Test探测失败不衰减健康EWMA 探测类(Probe)失败只喂熔断窗口,健康分不变——
// TestProbeFailure_DoesNotDecayHealthEwma 探测类(Probe)失败只喂熔断窗口,健康分不变——
// 防止每 5 分钟的自动探测持续失败把无流量渠道健康分指数拖垮(0.93^N → 个位数);
// 探测成功照常回升健康并记录延迟。
func Test探测失败不衰减健康EWMA(t *testing.T) {
func TestProbeFailure_DoesNotDecayHealthEwma(t *testing.T) {
ctx := context.Background()
s := newTestState(t, func(p *dispatch.RoutingPolicy) {
p.Breaker.FailThreshold = 1000 // 阈值调高,只验证窗口计数不触发熔断
@@ -139,10 +139,10 @@ func Test探测失败不衰减健康EWMA(t *testing.T) {
assert.InEpsilon(t, 500, rt.LatEwmaMs, 1e-9)
}
// Test缺失健康键读默认值 gf 驱动对 HMGET 缺失字段返回空字符串而非 nil(IsNil()==false)。
// TestMissingHealthKey_ReadsDefault gf 驱动对 HMGET 缺失字段返回空字符串而非 nil(IsNil()==false)。
// ReadRuntime 必须按无数据处理保留乐观默认(succ=1):无流量模型或 TTL(24h)过期键
// 若读成 succ=0,健康快照取平均会把渠道健康度拖到个位数。
func Test缺失健康键读默认值(t *testing.T) {
func TestMissingHealthKey_ReadsDefault(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -153,7 +153,7 @@ func Test缺失健康键读默认值(t *testing.T) {
assert.Equal(t, dispatch.BreakerClosed, rt.ModelBreaker)
}
func Test限流不喂熔断窗口(t *testing.T) {
func TestRateLimit_DoesNotFeedBreakerWindow(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -168,7 +168,7 @@ func Test限流不喂熔断窗口(t *testing.T) {
assert.Equal(t, dispatch.BreakerClosed, rt.Breaker)
}
func Test熔断转移与探测令牌(t *testing.T) {
func TestBreaker_TransitionAndProbeToken(t *testing.T) {
ctx := context.Background()
s := newTestState(t, func(p *dispatch.RoutingPolicy) {
p.Breaker.ModelFailThreshold = 100 // 调高模型级阈值,聚焦验证渠道级 8 次语义
@@ -201,7 +201,7 @@ func Test熔断转移与探测令牌(t *testing.T) {
assert.Greater(t, rt.RecoveredMs, int64(0), "恢复时间必须记录")
}
func Test熔断探测失败冷却翻倍(t *testing.T) {
func TestBreaker_ProbeFailureDoublesCooldown(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -225,9 +225,9 @@ func Test熔断探测失败冷却翻倍(t *testing.T) {
// 模型级隔离(错误作用域分流 + 去重守卫)
// ---------------------------------------------------------------------------
// Test模型致命一次直达模型级 404/模型不存在:模型级 fatal 直达 OPEN,
// TestModelFatal_SingleHitOpensModelBreaker 404/模型不存在:模型级 fatal 直达 OPEN,
// 渠道级仅窗口计 1 票;模型级已 OPEN 后同模型后续失败不再污染渠道级。
func Test模型致命一次直达模型级(t *testing.T) {
func TestModelFatal_SingleHitOpensModelBreaker(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -248,9 +248,9 @@ func Test模型致命一次直达模型级(t *testing.T) {
assert.Equal(t, 1, v.Int(), "模型 OPEN 后渠道级不再计数")
}
// Test模型级窗口阈值与渠道级隔离 单模型慢性故障(瞬时类):模型级阈值 4 先打开,
// TestModelBreaker_WindowThresholdIsolatedFromChannel 单模型慢性故障(瞬时类):模型级阈值 4 先打开,
// 渠道级贡献封顶在 4 票(< 8),渠道级永不熔断——单模型故障不误伤其它模型。
func Test模型级窗口阈值与渠道级隔离(t *testing.T) {
func TestModelBreaker_WindowThresholdIsolatedFromChannel(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil) // failThreshold=8, modelFailThreshold=4
@@ -274,9 +274,9 @@ func Test模型级窗口阈值与渠道级隔离(t *testing.T) {
assert.Equal(t, 4, v.Int(), "单模型对渠道级贡献封顶在 modelFailThreshold")
}
// Test跨模型共识渠道级熔断 两个模型各恶化 4 次(4+4=8)→ 渠道级 OPEN:
// TestChannelBreaker_CrossModelConsensus 两个模型各恶化 4 次(4+4=8)→ 渠道级 OPEN:
// 渠道级熔断保留给真正的多模型同时恶化(整渠道故障)。
func Test跨模型共识渠道级熔断(t *testing.T) {
func TestChannelBreaker_CrossModelConsensus(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -290,9 +290,9 @@ func Test跨模型共识渠道级熔断(t *testing.T) {
assert.Equal(t, dispatch.BreakerOpen, rt.ModelBreaker, "两模型各自也达阈值 4")
}
// Test渠道致命不喂模型级 402/Key 耗尽等渠道级信号:fatal 只打渠道级,模型级 key 不被触碰
// TestChannelFatal_DoesNotFeedModelBreaker 402/Key 耗尽等渠道级信号:fatal 只打渠道级,模型级 key 不被触碰
// (渠道恢复后模型无残留熔断)。
func Test渠道致命不喂模型级(t *testing.T) {
func TestChannelFatal_DoesNotFeedModelBreaker(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -305,9 +305,9 @@ func Test渠道致命不喂模型级(t *testing.T) {
assert.Equal(t, 0, v.Int(), "渠道级致命不创建/触碰模型级 key")
}
// Test模型级探测成功复位 模型级 OPEN 冷却期满 → HALF_OPEN → 成功 → CLOSED + recovered_ms
// TestModelBreaker_ProbeSuccessResets 模型级 OPEN 冷却期满 → HALF_OPEN → 成功 → CLOSED + recovered_ms
// (模型级熔断可靠流量探测恢复,不再死锁到 24h TTL)。
func Test模型级探测成功复位(t *testing.T) {
func TestModelBreaker_ProbeSuccessResets(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -321,9 +321,9 @@ func Test模型级探测成功复位(t *testing.T) {
assert.Greater(t, v.Int64(), int64(0), "模型级恢复时间必须记录")
}
// Test模型级探测令牌与探测失败翻倍 模型级令牌按渠道×模型 key 独立限流;
// TestModelBreaker_ProbeTokenAndFailureDoublesCooldown 模型级令牌按渠道×模型 key 独立限流;
// 冷却期满后的失败走 HALF_OPEN 探测失败分支(模型级回 OPEN 冷却翻倍,渠道级不计数)。
func Test模型级探测令牌与探测失败翻倍(t *testing.T) {
func TestModelBreaker_ProbeTokenAndFailureDoublesCooldown(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -346,7 +346,7 @@ func Test模型级探测令牌与探测失败翻倍(t *testing.T) {
assert.Equal(t, 1, v.Int(), "渠道级不因探测失败新增计数")
}
func Test绑定CAS与反向索引(t *testing.T) {
func TestBinding_CASAndReverseIndex(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -389,7 +389,7 @@ func Test绑定CAS与反向索引(t *testing.T) {
assert.False(t, ok)
}
func Test绑定续期(t *testing.T) {
func TestBinding_Touch(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -402,7 +402,7 @@ func Test绑定续期(t *testing.T) {
s.TouchBinding(ctx, "sk:test:none", time.Hour)
}
func Test容量租约(t *testing.T) {
func TestLease_Capacity(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -441,7 +441,7 @@ func Test容量租约(t *testing.T) {
assert.Equal(t, 20, rt.Inflight)
}
func Test租约过期自愈(t *testing.T) {
func TestLease_ExpirySelfHeals(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -459,7 +459,7 @@ func Test租约过期自愈(t *testing.T) {
assert.True(t, v.IsNil(), "已释放的租约不得被续租复活")
}
func Test凭证冷却(t *testing.T) {
func TestCredentialCooldown(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -472,7 +472,27 @@ func Test凭证冷却(t *testing.T) {
assert.False(t, s.IsCredentialCooled(ctx, 101), "冷却到期后必须恢复可用")
}
func TestReportOutcome异步消费(t *testing.T) {
// TestCredentialCooldown_ManualClear 覆盖「更换 Key / 重置健康度」的解除路径:冷却按 keyID 打标,
// 而换 Key 是原地改同一行(keyID 不变),不解除的话新 Key 会被整段跳过直到 TTL 到期。
func TestCredentialCooldown_ManualClear(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
s.CoolCredential(ctx, 202, 5*time.Minute)
require.True(t, s.IsCredentialCooled(ctx, 202))
require.Positive(t, CredentialCooldownRemaining(ctx, 202), "冷却中必须能读到剩余秒数")
s.ClearCredentialCooldown(ctx, 202)
assert.False(t, s.IsCredentialCooled(ctx, 202), "解除后必须立即可用,不等 TTL")
assert.Zero(t, CredentialCooldownRemaining(ctx, 202))
assert.False(t, s.local.isCredCooled(202), "本地镜像必须一并清除,否则 Redis 降级时仍会跳过该 Key")
// 解除未冷却的 Key 是幂等空操作
s.ClearCredentialCooldown(ctx, 999)
assert.False(t, s.IsCredentialCooled(ctx, 999))
}
func TestReportOutcome_AsyncConsumption(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
s.Start(ctx)
@@ -486,11 +506,11 @@ func TestReportOutcome异步消费(t *testing.T) {
}, 3*time.Second, 20*time.Millisecond, "后台 worker 必须消费上报事件")
}
// TestReadRuntime无数据回落默认值 从未被访问过的渠道×模型(Redis key 不存在)必须回落
// TestReadRuntime_FallsBackToDefaultWhenNoData 从未被访问过的渠道×模型(Redis key 不存在)必须回落
// 默认满分 1.0。HMGET 缺失字段经 gredis 包装成非 nil 空 gvar(IsNil() 恒为 false、
// Float64() 静默返回 0),旧代码用 IsNil() 判空失效,把全部冷模型读成 0 分,
// 拖垮渠道聚合健康分与调度 healthFactor。
func TestReadRuntime无数据回落默认值(t *testing.T) {
func TestReadRuntime_FallsBackToDefaultWhenNoData(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -507,8 +527,8 @@ func TestReadRuntime无数据回落默认值(t *testing.T) {
assert.Equal(t, 100.0, rt.LatEwmaMs)
}
// TestReadRuntime异常值防御 succ_ewma 为空串/非法值/精确 0 时视为无数据(保持默认 1.0)。
func TestReadRuntime异常值防御(t *testing.T) {
// TestReadRuntime_GuardsAgainstOutOfRangeValues succ_ewma 为空串/非法值/精确 0 时视为无数据(保持默认 1.0)。
func TestReadRuntime_GuardsAgainstOutOfRangeValues(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -533,10 +553,10 @@ func TestReadRuntime异常值防御(t *testing.T) {
assert.Equal(t, 0.0, rt.LatEwmaMs)
}
// TestReadRuntime区分无数据与读失败 「没读到」与「读失败」必须可区分:前者是真实的
// TestReadRuntime_DistinguishesNoDataFromReadFailure 「没读到」与「读失败」必须可区分:前者是真实的
// 「该模型没有流量」,后者是 Redis 不可用。二者都会回落成默认满分,若调用方无法区分,
// Redis 抖动期间维护快照会把全渠道健康分刷成 100,事故现场被销毁。
func TestReadRuntime区分无数据与读失败(t *testing.T) {
func TestReadRuntime_DistinguishesNoDataFromReadFailure(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -563,9 +583,9 @@ func TestReadRuntime区分无数据与读失败(t *testing.T) {
assert.Equal(t, 1.0, rt.SuccEwma)
}
// TestReadRuntime脏值不算有效数据 精确 0 / 非法值视为无数据,HasHealth 保持 false,
// TestReadRuntime_DirtyValueIsNotValidData 精确 0 / 非法值视为无数据,HasHealth 保持 false,
// 避免脏数据以「实测值」身份参与渠道聚合(2026-08-27 全渠道健康分坍缩事故的读侧防线)。
func TestReadRuntime脏值不算有效数据(t *testing.T) {
func TestReadRuntime_DirtyValueIsNotValidData(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -589,7 +609,7 @@ func TestLocalFallbackLimit(t *testing.T) {
assert.Equal(t, 10, localFallbackLimit(10, 0), "副本数缺省按 1")
}
func TestLocal严格容量租约(t *testing.T) {
func TestLocal_StrictCapacityLease(t *testing.T) {
l := newLocalState()
require.True(t, l.acquireLease(1, "a", 2, 60_000))
require.True(t, l.acquireLease(1, "b", 2, 60_000))
@@ -602,7 +622,7 @@ func TestLocal严格容量租约(t *testing.T) {
require.False(t, l.acquireLease(2, "x", 0, 60_000))
}
func TestLocal探测限流(t *testing.T) {
func TestLocal_ProbeThrottle(t *testing.T) {
l := newLocalState()
assert.True(t, l.tryProbe(1, "", 10_000))
assert.False(t, l.tryProbe(1, "", 10_000), "同窗口本实例只放行一个")
@@ -610,7 +630,7 @@ func TestLocal探测限流(t *testing.T) {
assert.True(t, l.tryProbe(1, "gpt-4o", 10_000), "同渠道的模型级令牌与渠道级互不影响")
}
func TestLocal凭证冷却过期(t *testing.T) {
func TestLocal_CredentialCooldownExpiry(t *testing.T) {
l := newLocalState()
l.coolCred(9, 50*time.Millisecond)
assert.True(t, l.isCredCooled(9))
@@ -618,7 +638,16 @@ func TestLocal凭证冷却过期(t *testing.T) {
assert.False(t, l.isCredCooled(9))
}
func Test429估计器水位收敛(t *testing.T) {
func TestLocal_CredentialCooldownClear(t *testing.T) {
l := newLocalState()
l.coolCred(9, time.Hour)
require.True(t, l.isCredCooled(9))
l.clearCred(9)
assert.False(t, l.isCredCooled(9), "解除后不等 TTL 立即可用")
l.clearCred(9) // 幂等
}
func TestRateLimit429Estimator_OnsetConverges(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
@@ -648,7 +677,7 @@ func TestEffectiveSoftLimit(t *testing.T) {
assert.Equal(t, 4, effectiveSoftLimit(0, 2), "下限保护 max(4, ...)")
}
func Test手动恢复复位熔断并开启爬坡(t *testing.T) {
func TestManualRecovery_ResetsBreakerAndStartsRamp(t *testing.T) {
ctx := context.Background()
s := newTestState(t, nil)
+39
View File
@@ -9,6 +9,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/relaykit/dispatch"
)
@@ -141,6 +142,44 @@ func ResetModelHealth(ctx context.Context, channelID int64, modelName string) {
redisState.ResetHealth(ctx, channelID, []string{modelName})
}
// ClearChannelCredentialCooldown 解除渠道下所有 Key 的凭证冷却,返回被解除的 Key 数量。
//
// 更换 Key / 重置健康度后必须调用。凭证冷却按 keyID 打标(dispatch:v1:credcd:<keyID>,
// 401/403 时写入、默认 300s TTL),而管理后台更换 Key 是原地改同一行、keyID 不变:
// 不清标记的话调度器仍认为该 Key 在冷却中,渠道全部 Key 冷却即整体不可用
// (日志表现为「无可用渠道 … 凭证全部冷却×N」),且期间从不发起上游请求,
// 新 Key 一次都得不到验证,只能干等 TTL 自愈。
//
// 不按 status 过滤:被禁用后重新启用的 Key 同样需要解除。
func ClearChannelCredentialCooldown(ctx context.Context, channelID int64) int {
var keys []struct {
ID int64 `json:"id"`
}
if err := dao.ChnChannelKeys.Ctx(ctx).
Where("channel_id", channelID).
Fields("id").
Scan(&keys); err != nil {
g.Log().Warningf(ctx, "[Dispatch] 解除凭证冷却时查询渠道 Key 失败: channel=%d err=%v", channelID, err)
return 0
}
for _, k := range keys {
ClearCredentialCooldownByKey(ctx, k.ID)
}
return len(keys)
}
// ClearCredentialCooldownByKey 解除单个 Key 的凭证冷却。
// 凭证被就地更新(换 Key / OAuth 令牌刷新)后必须调用:keyID 不变,
// 旧凭证留下的冷却标记会继续把新凭证挡在调度之外,直到 TTL 自然到期。
func ClearCredentialCooldownByKey(ctx context.Context, keyID int64) {
if redisState != nil {
redisState.ClearCredentialCooldown(ctx, keyID) // 含本地镜像
return
}
// 调度引擎尚未组装:没有本地镜像可清,只需删 Redis 标记
clearCredCooldownKey(ctx, keyID)
}
// InvalidateChannel 渠道禁用/删除时的联动清理:清绑定 + 跨实例目录失效。
// 管理后台渠道写操作(更新/删除/Key 变更/能力变更)后调用。
func InvalidateChannel(ctx context.Context, channelID int64) {
+17
View File
@@ -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)
}
+28 -9
View File
@@ -456,16 +456,26 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (*
if err != nil {
return nil, gerror.Wrapf(err, "encrypt api key failed")
}
// 用 g.Map 而非 do 结构体:需要把 token_expires_at 写回 NULL(do 的零值会被 gf 忽略)。
// 由 OAuth 改回静态 Key 时旧的 access_token 过期时间必须清掉,否则详情页会显示
// 一个与当前 Key 无关的过期时间;last_error 同理属于旧 Key 的历史。
_, err = dao.ChnChannelKeys.Ctx(ctx).
Where("channel_id", req.ID).
Data(do.ChnChannelKeys{
EncryptedKey: encrypted,
KeyType: "apikey",
Data(g.Map{
"encrypted_key": encrypted,
"key_type": "apikey",
"token_expires_at": nil,
"last_error": "",
}).
Update()
if err != nil {
return nil, err
}
// 换 Key 必须解除凭证冷却:冷却按 keyID 打标而这里是原地改同一行(keyID 不变),
// 不解除的话新 Key 会被调度器整段跳过直到 TTL 到期,期间连试都不试。
if n := dispatchadapter.ClearChannelCredentialCooldown(ctx, req.ID); n > 0 {
g.Log().Infof(ctx, "[Channel] 渠道 %d 更换 Key,已解除 %d 个 Key 的凭证冷却", req.ID, n)
}
}
dispatchadapter.InvalidateChannel(ctx, req.ID)
if req.Status == "active" {
@@ -545,6 +555,7 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq)
// 查询 Key 信息
var keyInfo *struct {
ID int64 `json:"id"`
KeyType string `json:"key_type"`
Status string `json:"status"`
Name string `json:"name"`
@@ -552,7 +563,7 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq)
}
err = dao.ChnChannelKeys.Ctx(ctx).
Where("channel_id", req.ID).
Fields("key_type, status, name, token_expires_at").
Fields("id, key_type, status, name, token_expires_at").
Scan(&keyInfo)
if err != nil {
return nil, err
@@ -592,6 +603,7 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq)
KeyType: keyInfo.KeyType,
KeyStatus: keyInfo.Status,
KeyName: keyInfo.Name,
KeyCooldownRemaining: dispatchadapter.CredentialCooldownRemaining(ctx, keyInfo.ID),
}
if rs, ok := channelRuntimeStates()[req.ID]; ok {
res.BreakerState = int(rs.Breaker)
@@ -685,11 +697,12 @@ func (s *sAdmin) GetChannelKeys(ctx context.Context, req *v1.ChannelKeyListReq)
list := make([]v1.ChannelKeyItem, len(keys))
for i, k := range keys {
item := v1.ChannelKeyItem{
ID: k.ID,
Name: k.Name,
Status: k.Status,
KeyType: k.KeyType,
CreatedAt: k.CreatedAt.String(),
ID: k.ID,
Name: k.Name,
Status: k.Status,
KeyType: k.KeyType,
CreatedAt: k.CreatedAt.String(),
CooldownRemaining: dispatchadapter.CredentialCooldownRemaining(ctx, k.ID),
}
if item.KeyType == "" {
item.KeyType = "apikey"
@@ -1009,6 +1022,12 @@ func (s *sAdmin) ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHea
// 调度层:复位渠道级/模型级熔断 + 成功率恢复,渠道立即恢复被选择能力
dispatchadapter.ResetChannelHealth(ctx, req.ID)
// 凭证冷却同属"渠道不可用"状态,且只靠 TTL 自愈、无其它人工出口:
// 管理员点重置的语义就是"这个渠道我修好了",只复位熔断不解除冷却等于重置了一半
// ——渠道会继续以「凭证全部冷却」被整体跳过,界面上却显示健康已恢复。
if n := dispatchadapter.ClearChannelCredentialCooldown(ctx, req.ID); n > 0 {
g.Log().Infof(ctx, "[Channel] 渠道 %d 重置健康度,已解除 %d 个 Key 的凭证冷却", req.ID, n)
}
// 请求按 Redis 实际读值重算落盘:上面写死的 80 只是调度引擎未组装时的兜底,
// 重算后会收敛到与调度同源的 0.9^α×100
dispatchadapter.RequestHealthSnapshot(req.ID)
+3
View File
@@ -12,6 +12,7 @@ import (
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/dispatchadapter"
"github.com/qianfree/team-api/internal/logic/common/oauth"
relayLogic "github.com/qianfree/team-api/internal/logic/relay"
"github.com/qianfree/team-api/internal/model/do"
@@ -246,6 +247,8 @@ func (s *sAdmin) ChannelOAuthRefresh(ctx context.Context, req *v1.ChannelOAuthRe
if err != nil {
return nil, gerror.Wrap(err, "更新 OAuth 凭证失败")
}
// 令牌已换新,解除旧令牌 401 留下的凭证冷却(keyID 不变,标记不会自己跟着凭证走)
dispatchadapter.ClearCredentialCooldownByKey(ctx, key.ID)
return &v1.ChannelOAuthRefreshRes{
ExpiresAt: expiresAt.String(),
+26 -13
View File
@@ -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,
+1 -1
View File
@@ -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")
+3 -1
View File
@@ -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 {
+23
View File
@@ -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 {
+18 -129
View File
@@ -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
}
+1 -1
View File
@@ -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× 输入价)
+4 -4
View File
@@ -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),
}
+4 -4
View File
@@ -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),
}
}
+11 -10
View File
@@ -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)")
}
+14
View File
@@ -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
+12 -12
View File
@@ -8,10 +8,10 @@ import (
"github.com/stretchr/testify/require"
)
// TestRunHandlerSafely_Panic转为错误 robfig/cron v3 的 cron.New() 不带 Recover 链,
// TestRunHandlerSafely_PanicBecomesError robfig/cron v3 的 cron.New() 不带 Recover 链,
// startJob 是裸的 `go j.Run()`——任务 panic 会崩掉整个进程。此处必须兜住并转成
// 普通 error,才能既保住进程、又让失败落进 sys_cron_jobs 供管理后台查看。
func TestRunHandlerSafely_Panic转为错误(t *testing.T) {
func TestRunHandlerSafely_PanicBecomesError(t *testing.T) {
job := &CronJob{
Name: "panicking_job",
Handler: func(_ context.Context) error {
@@ -27,7 +27,7 @@ func TestRunHandlerSafely_Panic转为错误(t *testing.T) {
assert.Contains(t, err.Error(), "boom", "错误信息应保留 panic 内容供排查")
}
func TestRunHandlerSafely_正常返回值透传(t *testing.T) {
func TestRunHandlerSafely_PassesThroughReturnValue(t *testing.T) {
ctx := context.Background()
okJob := &CronJob{Name: "ok", Handler: func(_ context.Context) error { return nil }}
@@ -39,9 +39,9 @@ func TestRunHandlerSafely_正常返回值透传(t *testing.T) {
assert.ErrorIs(t, runHandlerSafely(ctx, failJob), assert.AnError, "普通错误必须原样返回")
}
// TestReschedule_未启动时只更新记录 StartBackground 之前没有 cron 条目,
// TestReschedule_OnlyUpdatesRecordWhenNotStarted StartBackground 之前没有 cron 条目,
// 此时重排只改 job.Schedule,供随后的 StartBackground 按新表达式注册。
func TestReschedule_未启动时只更新记录(t *testing.T) {
func TestReschedule_OnlyUpdatesRecordWhenNotStarted(t *testing.T) {
cs := NewCronScheduler()
cs.Register("job_a", "任务A", "*/5 * * * *", func(_ context.Context) error { return nil })
@@ -52,14 +52,14 @@ func TestReschedule_未启动时只更新记录(t *testing.T) {
assert.Equal(t, "@every 30m", jobs[0].Schedule)
}
func TestReschedule_未注册任务报错(t *testing.T) {
func TestReschedule_UnregisteredTaskErrors(t *testing.T) {
cs := NewCronScheduler()
assert.Error(t, cs.Reschedule(context.Background(), "nonexistent", "@every 5m"))
}
// TestReschedule_非法表达式保持原调度 一次错误配置不能把任务从调度器里彻底摘掉,
// TestReschedule_InvalidExprKeepsExistingSchedule 一次错误配置不能把任务从调度器里彻底摘掉,
// 因此实现是「先加新条目、成功后再摘旧条目」。
func TestReschedule_非法表达式保持原调度(t *testing.T) {
func TestReschedule_InvalidExprKeepsExistingSchedule(t *testing.T) {
ctx := context.Background()
cs := NewCronScheduler()
cs.Register("job_b", "任务B", "@every 5m", func(_ context.Context) error { return nil })
@@ -74,9 +74,9 @@ func TestReschedule_非法表达式保持原调度(t *testing.T) {
assert.Len(t, cs.cron.Entries(), 1)
}
// TestReschedule_有效表达式替换条目 同时验证 @every 语法确实被 cron.New() 的
// TestReschedule_ValidExprReplacesEntry 同时验证 @every 语法确实被 cron.New() 的
// 默认解析器接受——探测间隔配置正是靠它支持任意分钟数(*/N 在 N>=60 时会静默失真)。
func TestReschedule_有效表达式替换条目(t *testing.T) {
func TestReschedule_ValidExprReplacesEntry(t *testing.T) {
ctx := context.Background()
cs := NewCronScheduler()
cs.Register("job_c", "任务C", "@every 5m", func(_ context.Context) error { return nil })
@@ -93,9 +93,9 @@ func TestReschedule_有效表达式替换条目(t *testing.T) {
assert.NotEqual(t, oldID, cs.entryIDs["job_c"], "条目 ID 应已更新")
}
// TestOnSettingsChanged_回调panic不影响其余回调 回调在订阅 goroutine 内串行执行,
// TestOnSettingsChanged_CallbackPanicDoesNotAffectOthers 回调在订阅 goroutine 内串行执行,
// 一个回调 panic 不能带崩订阅循环或阻断后续回调。
func TestOnSettingsChanged_回调panic不影响其余回调(t *testing.T) {
func TestOnSettingsChanged_CallbackPanicDoesNotAffectOthers(t *testing.T) {
settingsHookMu.Lock()
saved := settingsHooks
settingsHooks = nil
+3 -3
View File
@@ -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 代码(如提示充值后不支持退款、指定渠道折扣活动等)。纯文本换行需用 <br>,富文本请直接粘贴 HTML"},
+12 -3
View File
@@ -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)
}
@@ -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 {
+23 -9
View File
@@ -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 加款是资金提交点(实时余额立即生效),在行锁保护内执行,并发回调串行化、天然幂等;
+3 -4
View File
@@ -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 {
+4
View File
@@ -1238,6 +1238,10 @@ func refreshOAuthKeyLocked(ctx context.Context, keyID int64, encKey []byte) (str
if err != nil {
return "", err
}
// 凭证已换新即解除冷却(与管理后台换 Key / cron 刷新同一不变量):冷却按 keyID 打标,
// 就地刷新不改 keyID,标记不会自己跟着凭证走。本路径下本请求已被调度选中(说明当时
// 未冷却),解除主要防的是并发实例刚因旧令牌 401 冷却、新令牌却要陪等 TTL。
dispatchadapter.ClearCredentialCooldownByKey(ctx, keyID)
return string(jsonData), nil
}
+1 -1
View File
@@ -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"
@@ -9,6 +9,7 @@ import (
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/dispatchadapter"
"github.com/qianfree/team-api/internal/logic/common/oauth"
"github.com/qianfree/team-api/internal/logic/relay"
"github.com/qianfree/team-api/internal/model/do"
@@ -131,6 +132,10 @@ func RefreshExpiringOAuthTokens(ctx context.Context) error {
g.Log().Warningf(ctx, "[OAuthRefresh] Update failed for key %d: %v", key.ID, err)
continue
}
// 令牌已换新,旧令牌 401 留下的凭证冷却必须一并解除:冷却按 keyID 打标而刷新是
// 就地更新(keyID 不变),不解除的话新令牌仍会被调度器跳过直到 TTL 到期
// ——OAuth 渠道每次令牌过期都会踩到,表现为刷新成功但渠道仍「无可用渠道」。
dispatchadapter.ClearCredentialCooldownByKey(ctx, key.ID)
refreshed++
}
+1 -1
View File
@@ -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")
+2 -2
View File
@@ -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
}
+18 -2
View File
@@ -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
}
+21
View File
@@ -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 版本:下划线转构建元数据
+7 -7
View File
@@ -44,7 +44,7 @@ func createTarball(t *testing.T, files map[string]string) string {
return path
}
func TestExtractBinary_正常提取(t *testing.T) {
func TestExtractBinary_Success(t *testing.T) {
tarball := createTarball(t, map[string]string{
"team-api-1.0.0-linux-amd64/team-api": "new-binary-content",
"team-api-1.0.0-linux-amd64/README.md": "docs",
@@ -79,7 +79,7 @@ func TestExtractBinary_正常提取(t *testing.T) {
}
}
func TestExtractBinary_归档中无二进制(t *testing.T) {
func TestExtractBinary_NoBinaryInArchive(t *testing.T) {
tarball := createTarball(t, map[string]string{
"team-api-1.0.0-linux-amd64/README.md": "docs",
})
@@ -90,7 +90,7 @@ func TestExtractBinary_归档中无二进制(t *testing.T) {
}
}
func TestReplaceBinary_正常替换(t *testing.T) {
func TestReplaceBinary_Success(t *testing.T) {
dir := t.TempDir()
current := filepath.Join(dir, "team-api")
newBin := filepath.Join(dir, "team-api.new")
@@ -125,7 +125,7 @@ func TestReplaceBinary_正常替换(t *testing.T) {
}
}
func TestReplaceBinary_残留old文件被清理(t *testing.T) {
func TestReplaceBinary_StaleOldFileCleaned(t *testing.T) {
dir := t.TempDir()
current := filepath.Join(dir, "team-api")
newBin := filepath.Join(dir, "team-api.new")
@@ -146,7 +146,7 @@ func TestReplaceBinary_残留old文件被清理(t *testing.T) {
}
}
func TestReplaceBinary_失败时自救恢复(t *testing.T) {
func TestReplaceBinary_RollbackOnFailure(t *testing.T) {
dir := t.TempDir()
current := filepath.Join(dir, "team-api")
@@ -169,7 +169,7 @@ func TestReplaceBinary_失败时自救恢复(t *testing.T) {
}
}
func TestCopyFile_内容与权限一致(t *testing.T) {
func TestCopyFile_PreservesContentAndPermissions(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
dst := filepath.Join(dir, "dst")
@@ -193,7 +193,7 @@ func TestCopyFile_内容与权限一致(t *testing.T) {
}
}
func TestCopyFile_源文件不存在(t *testing.T) {
func TestCopyFile_SourceMissing(t *testing.T) {
if err := copyFile(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dst")); err == nil {
t.Fatal("源文件不存在时应返回错误")
}
+7 -7
View File
@@ -10,7 +10,7 @@ func newTestManager() *UpdateManager {
return &UpdateManager{}
}
func TestUpdateManager_默认状态(t *testing.T) {
func TestUpdateManager_DefaultState(t *testing.T) {
m := newTestManager()
status := m.GetStatus()
@@ -28,7 +28,7 @@ func TestUpdateManager_默认状态(t *testing.T) {
}
}
func TestUpdateManager_进度状态机(t *testing.T) {
func TestUpdateManager_ProgressStateMachine(t *testing.T) {
m := newTestManager()
m.setProgress(PhaseDownloading, "正在下载...", 50)
@@ -47,7 +47,7 @@ func TestUpdateManager_进度状态机(t *testing.T) {
}
}
func TestUpdateManager_失败收尾(t *testing.T) {
func TestUpdateManager_FailureFinalizes(t *testing.T) {
m := newTestManager()
m.setProgress(PhaseDownloading, "正在下载...", 50)
@@ -71,7 +71,7 @@ func TestUpdateManager_失败收尾(t *testing.T) {
}
}
func TestUpdateManager_更新锁互斥(t *testing.T) {
func TestUpdateManager_UpdateLockMutualExclusion(t *testing.T) {
m := newTestManager()
if !m.updating.CompareAndSwap(false, true) {
@@ -90,7 +90,7 @@ func TestUpdateManager_更新锁互斥(t *testing.T) {
}
}
func TestGetStatus_终态进度过期自动失效(t *testing.T) {
func TestGetStatus_TerminalProgressExpiresAutomatically(t *testing.T) {
m := newTestManager()
// 上次升级留下的过期 complete 快照:不应再下发
@@ -119,7 +119,7 @@ func TestGetStatus_终态进度过期自动失效(t *testing.T) {
}
}
func TestGetStatus_终态进度窗口内保留(t *testing.T) {
func TestGetStatus_TerminalProgressKeptWithinWindow(t *testing.T) {
m := newTestManager()
// 刚完成的 complete:管理员仍在轮询确认窗口内,应正常返回
@@ -135,7 +135,7 @@ func TestGetStatus_终态进度窗口内保留(t *testing.T) {
}
}
func TestGetStatus_非终态进度不过期(t *testing.T) {
func TestGetStatus_NonTerminalProgressDoesNotExpire(t *testing.T) {
m := newTestManager()
// 进行中的进度(无 FinishedAt)不受过期机制影响
+5 -5
View File
@@ -33,7 +33,7 @@ func serveChecksums(t *testing.T, status int, body string) *httptest.Server {
return srv
}
func TestVerifyChecksum_成功(t *testing.T) {
func TestVerifyChecksum_Success(t *testing.T) {
content := "fake-binary-content"
filePath := writeTempFile(t, "team-api-1.0.0-linux-amd64.tar.gz", content)
@@ -47,7 +47,7 @@ func TestVerifyChecksum_成功(t *testing.T) {
}
}
func TestVerifyChecksum_哈希不一致须报错(t *testing.T) {
func TestVerifyChecksum_HashMismatchErrors(t *testing.T) {
filePath := writeTempFile(t, "asset.tar.gz", "actual-content")
wrongHash := strings.Repeat("0", 64)
@@ -62,7 +62,7 @@ func TestVerifyChecksum_哈希不一致须报错(t *testing.T) {
}
}
func TestVerifyChecksum_缺少校验文件地址须拒绝(t *testing.T) {
func TestVerifyChecksum_MissingChecksumURLRejected(t *testing.T) {
filePath := writeTempFile(t, "asset.tar.gz", "content")
err := VerifyChecksum(context.Background(), filePath, "", "asset.tar.gz")
@@ -74,7 +74,7 @@ func TestVerifyChecksum_缺少校验文件地址须拒绝(t *testing.T) {
}
}
func TestVerifyChecksum_校验文件拉取失败须拒绝(t *testing.T) {
func TestVerifyChecksum_ChecksumFetchFailureRejected(t *testing.T) {
filePath := writeTempFile(t, "asset.tar.gz", "content")
// 服务返回 500,模拟校验文件不可用
@@ -86,7 +86,7 @@ func TestVerifyChecksum_校验文件拉取失败须拒绝(t *testing.T) {
}
}
func TestFetchExpectedHash_解析格式(t *testing.T) {
func TestFetchExpectedHash_ParsesFormat(t *testing.T) {
content := "aaa file-a.tar.gz\nbbb *file-b.tar.gz\n"
srv := serveChecksums(t, http.StatusOK, content)
ctx := context.Background()
+49 -2
View File
@@ -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">
</div>
<!-- Base Currency -->
<div class="animate-fade-up delay-6">
<label class="block text-sm font-medium text-gray-700 mb-1.5">
本位币
<span class="text-amber-600 font-normal text-xs ml-1">初始化后不可更改</span>
</label>
<div class="grid grid-cols-2 gap-2.5" id="currencyGroup">
<label class="currency-option cursor-pointer flex items-start gap-2.5 px-3.5 py-2.5 bg-gray-50 border border-gray-200 rounded-lg transition-colors" data-value="USD">
<input type="radio" name="currency" value="USD" checked class="mt-0.5 accent-[#2563eb]">
<span>
<span class="block text-sm font-medium text-gray-900">USD(美元)</span>
<span class="block text-xs text-gray-400 mt-0.5">与上游模型报价对齐</span>
</span>
</label>
<label class="currency-option cursor-pointer flex items-start gap-2.5 px-3.5 py-2.5 bg-gray-50 border border-gray-200 rounded-lg transition-colors" data-value="CNY">
<input type="radio" name="currency" value="CNY" class="mt-0.5 accent-[#2563eb]">
<span>
<span class="block text-sm font-medium text-gray-900">CNY(人民币)</span>
<span class="block text-xs text-gray-400 mt-0.5">面向国内运营</span>
</span>
</label>
</div>
<p class="text-xs text-gray-400 mt-1.5">决定系统内部记账与全站显示货币,模型定价按本位币配置</p>
</div>
</div>
<!-- Error -->
@@ -168,7 +200,7 @@
</div>
<!-- Submit -->
<div class="pt-1.5 animate-fade-up delay-6">
<div class="pt-1.5 animate-fade-up delay-7">
<button
type="submit" id="submitBtn"
class="btn-primary w-full text-white py-2.5 rounded-lg text-sm font-medium">
@@ -208,6 +240,19 @@
}
}
// 本位币选择卡片高亮联动
var currencyOptions = document.querySelectorAll('.currency-option');
function syncCurrencyHighlight() {
var selected = document.querySelector('input[name="currency"]:checked');
currencyOptions.forEach(function(opt) {
opt.classList.toggle('selected', opt.querySelector('input').value === selected.value);
});
}
currencyOptions.forEach(function(opt) {
opt.querySelector('input').addEventListener('change', syncCurrencyHighlight);
});
syncCurrencyHighlight();
form.addEventListener('submit', function(e) {
e.preventDefault();
hideError();
@@ -216,6 +261,7 @@
var displayName = document.getElementById('displayName').value.trim();
var password = document.getElementById('password').value;
var confirmPassword = document.getElementById('confirmPassword').value;
var currency = document.querySelector('input[name="currency"]:checked').value;
if (!username) {
showError('请输入用户名');
@@ -242,7 +288,8 @@
username: username,
displayName: displayName,
password: password,
confirmPassword: confirmPassword
confirmPassword: confirmPassword,
currency: currency
})
})
.then(function(res) { return res.json(); })
+13
View File
@@ -0,0 +1,13 @@
-- +goose Up
-- 货币显示动态切换:将汇率配置公开(前端展示层折算与充值页折算需要读取)。
-- 已落库且 is_public=false 的行不会被 PublicSettingsGet 返回实际值(只会补注册表默认值),
-- 因此必须刷存量行;billing_currency 为新增 key,由注册表默认值兜底(USD),无需插入。
-- +goose StatementBegin
UPDATE sys_options SET is_public = true WHERE key = 'payment_exchange_rate_cny_to_usd';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
UPDATE sys_options SET is_public = false WHERE key = 'payment_exchange_rate_cny_to_usd';
-- +goose StatementEnd
+6 -6
View File
@@ -8,7 +8,7 @@ import (
func breakerPol() BreakerPolicy { return DefaultRoutingPolicy().Breaker }
func TestEffectiveBreakerState_惰性转移(t *testing.T) {
func TestEffectiveBreakerState_LazyTransition(t *testing.T) {
p := breakerPol()
s := BreakerSnapshot{State: BreakerOpen, OpenedAtMs: 0, CooldownMs: int64(p.CooldownSeconds) * 1000}
@@ -17,7 +17,7 @@ func TestEffectiveBreakerState_惰性转移(t *testing.T) {
assert.Equal(t, BreakerClosed, EffectiveBreakerState(BreakerSnapshot{State: BreakerClosed}, 0))
}
func TestBreakerOnFailure_窗口阈值熔断(t *testing.T) {
func TestBreakerOnFailure_WindowThresholdOpens(t *testing.T) {
p := breakerPol() // failThreshold=8
s := BreakerSnapshot{State: BreakerClosed, FailWindowCount: 7}
@@ -31,14 +31,14 @@ func TestBreakerOnFailure_窗口阈值熔断(t *testing.T) {
assert.Equal(t, int64(30_000), s.CooldownMs)
}
func TestBreakerOnFailure_致命一次直达(t *testing.T) {
func TestBreakerOnFailure_FatalOpensImmediately(t *testing.T) {
p := breakerPol()
s := BreakerSnapshot{State: BreakerClosed, FailWindowCount: 0}
s = BreakerOnFailure(s, true, 1000, p)
assert.Equal(t, BreakerOpen, s.State, "CHANNEL_FATAL 一次直达熔断")
}
func TestBreakerOnFailure_探测失败冷却翻倍(t *testing.T) {
func TestBreakerOnFailure_ProbeFailureDoublesCooldown(t *testing.T) {
p := breakerPol() // cooldown 30s, max 300s
// OPEN 冷却期满(有效态 HALF_OPEN)时失败 = 探测失败 → 回 OPEN,冷却翻倍
@@ -60,7 +60,7 @@ func TestBreakerOnFailure_探测失败冷却翻倍(t *testing.T) {
assert.Equal(t, s2, got)
}
func TestBreakerOnSuccess_探测成功复位(t *testing.T) {
func TestBreakerOnSuccess_ProbeSuccessResets(t *testing.T) {
// HALF_OPEN(有效态)成功 → CLOSED 并复位
s := BreakerSnapshot{State: BreakerOpen, OpenedAtMs: 0, CooldownMs: 30_000, FailWindowCount: 9}
s = BreakerOnSuccess(s, 30_000)
@@ -79,7 +79,7 @@ func TestBreakerOnSuccess_探测成功复位(t *testing.T) {
assert.Zero(t, s.FailWindowCount, "成功清失败计数")
}
func TestBreaker_全转移路径(t *testing.T) {
func TestBreaker_AllTransitionPaths(t *testing.T) {
p := breakerPol()
// CLOSED → OPEN → HALF_OPEN(探测失败) → OPEN → HALF_OPEN(探测成功) → CLOSED
+7 -7
View File
@@ -11,7 +11,7 @@ import (
"github.com/qianfree/team-api/relaykit/types"
)
func TestClassify_状态码全表(t *testing.T) {
func TestClassify_StatusCodeTable(t *testing.T) {
tests := []struct {
code int
want ErrorClass
@@ -41,7 +41,7 @@ func TestClassify_状态码全表(t *testing.T) {
}
}
func TestClassify_NewAPIError错误码(t *testing.T) {
func TestClassify_NewAPIErrorCodes(t *testing.T) {
tests := []struct {
name string
code types.ErrorCode
@@ -66,32 +66,32 @@ func TestClassify_NewAPIError错误码(t *testing.T) {
}
}
func TestClassify_SkipRetry语义(t *testing.T) {
func TestClassify_SkipRetrySemantics(t *testing.T) {
// SkipRetry 标记优先于一切:即使状态码本可重试也归客户端类(不重试)
e := types.NewErrorWithStatusCode(errors.New("x"), types.ErrorCodeBadResponse, 502, types.ErrOptionWithSkipRetry())
assert.Equal(t, ErrClassClient, Classify(502, e, DeliveryResponseReceived))
}
func TestClassify_NewAPIError状态码兜底(t *testing.T) {
func TestClassify_NewAPIErrorStatusCodeFallback(t *testing.T) {
// 错误码不能定类时用其携带的状态码
e := types.NewErrorWithStatusCode(errors.New("x"), types.ErrorCodeBadResponse, 429)
assert.Equal(t, ErrClassRateLimit, Classify(0, e, DeliveryResponseReceived))
}
func TestClassify_上下文错误(t *testing.T) {
func TestClassify_ContextErrors(t *testing.T) {
assert.Equal(t, ErrClassTimeout, Classify(0, context.DeadlineExceeded, DeliveryMaybeSent))
assert.Equal(t, ErrClassClient, Classify(0, context.Canceled, DeliveryMaybeSent), "客户端取消不重试不计健康")
wrapped := fmt.Errorf("do request: %w", context.DeadlineExceeded)
assert.Equal(t, ErrClassTimeout, Classify(0, wrapped, DeliveryNotSent))
}
func TestClassify_纯网络错误(t *testing.T) {
func TestClassify_PureNetworkError(t *testing.T) {
err := errors.New("connection refused")
assert.Equal(t, ErrClassTransient, Classify(0, err, DeliveryNotSent))
assert.Equal(t, ErrClassTransient, Classify(0, err, DeliveryMaybeSent), "MaybeSent 仍归 TRANSIENT,禁止原地由 FSM 硬规则处理")
}
func TestClassify_无错误(t *testing.T) {
func TestClassify_NoError(t *testing.T) {
assert.Equal(t, ErrClassNone, Classify(0, nil, DeliveryNotSent))
assert.Equal(t, ErrClassNone, Classify(200, nil, DeliveryResponseReceived))
}
+28 -1
View File
@@ -3,6 +3,8 @@ package dispatch
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -92,6 +94,9 @@ type NoChannelDiag struct {
LeaseFull int // 容量租约满(渠道并发已达 softLimit / max_concurrency)
RequestExcluded int // 本请求内先前已失败而被排除的渠道
CredUnavailable int // 渠道全部 Key 处于冷却/已排除
// CredCooledKeys 上述渠道中不可用的 Key ID(去重、升序)。凭证冷却只存在于 Redis
// 且只靠 TTL 自愈,没有 keyID 就无从判断该等 TTL 还是该去解除,故直接打进日志。
CredCooledKeys []int64
}
// Summary 人类可读的原因摘要(仅列出非零项),供无可用渠道时的日志定位。
@@ -110,7 +115,15 @@ func (d NoChannelDiag) Summary() string {
parts = append(parts, fmt.Sprintf("容量租约满×%d", d.LeaseFull))
}
if d.CredUnavailable > 0 {
parts = append(parts, fmt.Sprintf("凭证全部冷却×%d", d.CredUnavailable))
part := fmt.Sprintf("凭证全部冷却×%d", d.CredUnavailable)
if len(d.CredCooledKeys) > 0 {
ids := make([]string, len(d.CredCooledKeys))
for i, id := range d.CredCooledKeys {
ids[i] = strconv.FormatInt(id, 10)
}
part += "(key=" + strings.Join(ids, ",") + ")"
}
parts = append(parts, part)
}
if d.RequestExcluded > 0 {
parts = append(parts, fmt.Sprintf("本请求已排除×%d", d.RequestExcluded))
@@ -259,6 +272,7 @@ func (s *RouteSession) selectChannel(ctx context.Context) *Decision {
// 本轮选择中被探测令牌 / 租约 / 凭证拒绝的渠道(不进入 excludedChans,不影响绑定守卫语义)
roundExcluded := make(map[int64]struct{})
var probeDenied, leaseDenied, credDenied int
var credCooledKeys []int64
for range len(snapshot) + 3 { // 每轮至少排除一个渠道,防御性上限
scored, halfOpen, exclRound := s.buildCandidates(snapshot, roundExcluded, pol)
@@ -275,6 +289,7 @@ func (s *RouteSession) selectChannel(ctx context.Context) *Decision {
LeaseFull: leaseDenied,
RequestExcluded: exclRound.Request,
CredUnavailable: credDenied,
CredCooledKeys: credCooledKeys,
}
if len(scored) == 0 {
@@ -303,6 +318,7 @@ func (s *RouteSession) selectChannel(ctx context.Context) *Decision {
if !keyOK {
roundExcluded[chosen.ID] = struct{}{}
credDenied++
credCooledKeys = appendMissingIDs(credCooledKeys, chosen.Channel.KeyIDs)
continue
}
@@ -484,6 +500,17 @@ func (s *RouteSession) alternateKeyAvailable(ctx context.Context) bool {
return false
}
// appendMissingIDs 把 src 中尚未出现的 ID 并入 dst 并保持升序(诊断用,规模仅候选渠道的 Key 数)。
func appendMissingIDs(dst, src []int64) []int64 {
for _, id := range src {
if !slices.Contains(dst, id) {
dst = append(dst, id)
}
}
slices.Sort(dst)
return dst
}
func (s *RouteSession) excludeCurrent(ctx context.Context) {
if s.current != nil {
s.excludedChans[s.current.Channel.ID] = struct{}{}
+56 -32
View File
@@ -113,7 +113,7 @@ func testProfile() RequestProfile {
// 场景测试
// ---------------------------------------------------------------------------
func TestCoordinator_首次选择与绑定命中(t *testing.T) {
func TestCoordinator_FirstSelectionAndBindingHit(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
@@ -143,7 +143,7 @@ func TestCoordinator_首次选择与绑定命中(t *testing.T) {
assert.Equal(t, d1.Channel.ID, d2.Channel.ID)
}
func TestCoordinator_守卫_健康跌破重绑(t *testing.T) {
func TestCoordinator_Guard_RebindsOnHealthDrop(t *testing.T) {
ctx := context.Background()
state := newFakeState()
@@ -160,7 +160,7 @@ func TestCoordinator_守卫_健康跌破重绑(t *testing.T) {
assert.Equal(t, 1, state.setBinds, "重绑写入新绑定")
}
func TestCoordinator_守卫_饱和渠道重绑(t *testing.T) {
func TestCoordinator_Guard_RebindsOnSaturatedChannel(t *testing.T) {
ctx := context.Background()
state := newFakeState()
@@ -178,9 +178,9 @@ func TestCoordinator_守卫_饱和渠道重绑(t *testing.T) {
assert.NotEqual(t, ReasonBind, d.Reason)
}
// TestCoordinator_守卫_glm缺陷回归 溢出到 secondary 建立的绑定必须保持稳定:
// TestCoordinator_Guard_GLMDefectRegression 溢出到 secondary 建立的绑定必须保持稳定:
// 守卫判据与 tier/cost 解耦,不得因 tierFactor 低而每请求重绑(规避 keepThreshold 缺陷)。
func TestCoordinator_守卫_glm缺陷回归(t *testing.T) {
func TestCoordinator_Guard_GLMDefectRegression(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
@@ -201,7 +201,7 @@ func TestCoordinator_守卫_glm缺陷回归(t *testing.T) {
assert.Zero(t, state.setBinds, "不得发生任何重绑")
}
func TestCoordinator_502原地重试与预算耗尽(t *testing.T) {
func TestCoordinator_502InPlaceRetryUntilBudgetExhausted(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
@@ -245,7 +245,7 @@ func TestCoordinator_502原地重试与预算耗尽(t *testing.T) {
}
}
func TestCoordinator_401凭证轮换链路(t *testing.T) {
func TestCoordinator_401CredentialRotationPath(t *testing.T) {
ctx := context.Background()
state := newFakeState()
@@ -284,7 +284,7 @@ func TestCoordinator_401凭证轮换链路(t *testing.T) {
assert.Equal(t, int64(21), d.KeyID)
}
func TestCoordinator_单Key渠道401直接failover(t *testing.T) {
func TestCoordinator_SingleKeyChannel401FailsOverDirectly(t *testing.T) {
ctx := context.Background()
state := newFakeState()
@@ -301,7 +301,7 @@ func TestCoordinator_单Key渠道401直接failover(t *testing.T) {
assert.True(t, state.cooled[11])
}
func TestCoordinator_全Key冷却渠道跳过(t *testing.T) {
func TestCoordinator_SkipsChannelWithAllKeysCooled(t *testing.T) {
ctx := context.Background()
state := newFakeState()
state.cooled[11] = true
@@ -320,7 +320,31 @@ func TestCoordinator_全Key冷却渠道跳过(t *testing.T) {
assert.Equal(t, 1, d.Excluded.Request)
}
func TestCoordinator_租约拒绝不扣预算(t *testing.T) {
// TestCoordinator_NoChannelDiagIncludesCooledKeyIDs 凭证冷却只存在于 Redis 且只靠 TTL 自愈,
// 诊断不给出 keyID 的话运维无从判断该等 TTL 还是该去解除(换 Key 不改 keyID,
// 陈旧标记会让新 Key 被整段跳过,且期间连上游请求都不会发)。
func TestCoordinator_NoChannelDiagIncludesCooledKeyIDs(t *testing.T) {
ctx := context.Background()
state := newFakeState()
state.cooled[11] = true
state.cooled[21] = true
ch1 := healthyChannel(1, TierPrimary, 100)
ch1.KeyIDs = []int64{11}
ch2 := healthyChannel(2, TierPrimary, 50)
ch2.KeyIDs = []int64{21}
co, _ := newTestCoordinator(state, ch1, ch2)
s := co.Route(ctx, testProfile())
require.Nil(t, s.Next(ctx), "两个渠道的 Key 全部冷却 → 无可用渠道")
diag := s.NoChannelDiagnosis()
assert.Equal(t, 2, diag.CredUnavailable)
assert.Equal(t, []int64{11, 21}, diag.CredCooledKeys, "去重升序")
assert.Contains(t, diag.Summary(), "凭证全部冷却×2(key=11,21)")
}
func TestCoordinator_LeaseDenialDoesNotConsumeBudget(t *testing.T) {
ctx := context.Background()
state := newFakeState()
state.leaseDeny[1] = true
@@ -340,7 +364,7 @@ func TestCoordinator_租约拒绝不扣预算(t *testing.T) {
assert.Zero(t, s.attempt.FailoverUsed, "租约失败不扣 failover 预算")
}
func TestCoordinator_熔断排除与探测放行(t *testing.T) {
func TestCoordinator_BreakerExclusionAndProbeAdmission(t *testing.T) {
ctx := context.Background()
open := healthyChannel(1, TierPrimary, 100)
@@ -369,7 +393,7 @@ func TestCoordinator_熔断排除与探测放行(t *testing.T) {
assert.Equal(t, ReasonProbe, d2.Reason)
}
func TestCoordinator_模型级熔断排除(t *testing.T) {
func TestCoordinator_ModelBreakerExclusion(t *testing.T) {
ctx := context.Background()
state := newFakeState()
@@ -383,9 +407,9 @@ func TestCoordinator_模型级熔断排除(t *testing.T) {
assert.Equal(t, int64(2), d.Channel.ID)
}
// TestCoordinator_模型级半开探测 模型级熔断冷却期满(HALF_OPEN)走模型级探测令牌:
// TestCoordinator_ModelHalfOpenProbe 模型级熔断冷却期满(HALF_OPEN)走模型级探测令牌:
// 渠道级熔断 CLOSED 时不再被渠道级令牌误拒(修复模型级熔断无法靠流量恢复的死锁)。
func TestCoordinator_模型级半开探测(t *testing.T) {
func TestCoordinator_ModelHalfOpenProbe(t *testing.T) {
ctx := context.Background()
modelHalf := healthyChannel(1, TierPrimary, 100)
@@ -412,9 +436,9 @@ func TestCoordinator_模型级半开探测(t *testing.T) {
assert.Equal(t, 1, d2.Excluded.Breaker, "模型级探测拒绝计入 Breaker 排除")
}
// TestCoordinator_双级别半开组合 渠道级与模型级同时 HALF_OPEN:两枚令牌都取,
// TestCoordinator_DualLevelHalfOpenCombination 渠道级与模型级同时 HALF_OPEN:两枚令牌都取,
// 任一被拒即排除;模型级令牌先取(被拒不消耗渠道级令牌)。
func TestCoordinator_双级别半开组合(t *testing.T) {
func TestCoordinator_DualLevelHalfOpenCombination(t *testing.T) {
ctx := context.Background()
both := healthyChannel(1, TierPrimary, 100)
@@ -443,7 +467,7 @@ func TestCoordinator_双级别半开组合(t *testing.T) {
assert.Equal(t, ReasonProbe, d2.Reason)
}
func TestCoordinator_tier扩组兜底(t *testing.T) {
func TestCoordinator_TierExpansionFallback(t *testing.T) {
ctx := context.Background()
state := newFakeState()
@@ -464,7 +488,7 @@ func TestCoordinator_tier扩组兜底(t *testing.T) {
assert.Equal(t, int64(2), d.Channel.ID)
}
func TestCoordinator_溢出reason(t *testing.T) {
func TestCoordinator_OverflowReason(t *testing.T) {
ctx := context.Background()
state := newFakeState()
@@ -480,7 +504,7 @@ func TestCoordinator_溢出reason(t *testing.T) {
assert.Equal(t, ReasonOverflow, d.Reason, "primary 在场但饱和 → 溢出")
}
func TestCoordinator_ReplayUnsafe_MaybeSent必Abort(t *testing.T) {
func TestCoordinator_ReplayUnsafe_MaybeSentMustAbort(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
@@ -495,7 +519,7 @@ func TestCoordinator_ReplayUnsafe_MaybeSent必Abort(t *testing.T) {
assert.Nil(t, s.Next(ctx))
}
func TestCoordinator_504不原地重试(t *testing.T) {
func TestCoordinator_504NoInPlaceRetry(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
@@ -514,7 +538,7 @@ func TestCoordinator_504不原地重试(t *testing.T) {
assert.NotEqual(t, first, d.Channel.ID)
}
func TestCoordinator_429RetryAfter原地等待(t *testing.T) {
func TestCoordinator_429RetryAfterWaitsInPlace(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
@@ -527,7 +551,7 @@ func TestCoordinator_429RetryAfter原地等待(t *testing.T) {
assert.Equal(t, 1500*time.Millisecond, backoff, "遵从 Retry-After(entropy=0 无 jitter)")
}
func TestCoordinator_客户端错误Abort且不上报(t *testing.T) {
func TestCoordinator_ClientErrorAbortsWithoutReporting(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
@@ -540,7 +564,7 @@ func TestCoordinator_客户端错误Abort且不上报(t *testing.T) {
assert.Empty(t, state.outcomes, "客户端错误不计渠道健康")
}
func TestCoordinator_总时限耗尽Abort(t *testing.T) {
func TestCoordinator_TotalDeadlineExhaustedAborts(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, clock := newTestCoordinator(state,
@@ -556,7 +580,7 @@ func TestCoordinator_总时限耗尽Abort(t *testing.T) {
assert.Equal(t, DecisionAbort, dec)
}
func TestCoordinator_全渠道耗尽返回nil(t *testing.T) {
func TestCoordinator_ReturnsNilWhenAllChannelsExhausted(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
@@ -581,7 +605,7 @@ func TestCoordinator_全渠道耗尽返回nil(t *testing.T) {
s.Finish(ctx, false, 0)
}
func TestCoordinator_jitter叠加(t *testing.T) {
func TestCoordinator_JitterApplied(t *testing.T) {
ctx := context.Background()
state := newFakeState()
clock := &fakeClock{t: time.UnixMilli(0)}
@@ -598,7 +622,7 @@ func TestCoordinator_jitter叠加(t *testing.T) {
assert.Equal(t, 150*time.Millisecond, backoff, "100ms + 50% jitter")
}
func TestCoordinator_RefreshLease透传(t *testing.T) {
func TestCoordinator_RefreshLeasePassThrough(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
@@ -610,7 +634,7 @@ func TestCoordinator_RefreshLease透传(t *testing.T) {
assert.Equal(t, 1, state.refreshed[d.Channel.ID])
}
func TestCoordinator_Finish幂等且失败不续绑(t *testing.T) {
func TestCoordinator_FinishIdempotentAndNoTouchOnFailure(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state, healthyChannel(1, TierPrimary, 10))
@@ -627,7 +651,7 @@ func TestCoordinator_Finish幂等且失败不续绑(t *testing.T) {
assert.Nil(t, s.Next(ctx), "Finish 后不再返回决策")
}
func TestCoordinator_策略热更新(t *testing.T) {
func TestCoordinator_PolicyHotReload(t *testing.T) {
co, _ := newTestCoordinator(newFakeState(), healthyChannel(1, TierPrimary, 10))
p2 := DefaultRoutingPolicy()
p2.Version = 2
@@ -637,9 +661,9 @@ func TestCoordinator_策略热更新(t *testing.T) {
assert.Equal(t, 2, co.Policy().Version, "nil 策略不生效")
}
// TestCoordinator_防抖_headroom抖动 绑定守卫防抖:被绑渠道余量在守卫线上方抖动时,
// TestCoordinator_Debounce_HeadroomJitter 绑定守卫防抖:被绑渠道余量在守卫线上方抖动时,
// 绑定必须保持稳定(重绑次数为 0)——纯 HRW 会横跳,守卫是防抖层。
func TestCoordinator_防抖_headroom抖动(t *testing.T) {
func TestCoordinator_Debounce_HeadroomJitter(t *testing.T) {
ctx := context.Background()
state := newFakeState()
@@ -675,8 +699,8 @@ func TestCoordinator_防抖_headroom抖动(t *testing.T) {
assert.Equal(t, rebinds, state.setBinds, "抖动期间重绑次数必须为 0")
}
// TestCoordinator_租户级策略覆盖 profile.Policy 非空时本会话使用租户策略而非全局策略。
func TestCoordinator_租户级策略覆盖(t *testing.T) {
// TestCoordinator_TenantPolicyOverride profile.Policy 非空时本会话使用租户策略而非全局策略。
func TestCoordinator_TenantPolicyOverride(t *testing.T) {
ctx := context.Background()
state := newFakeState()
co, _ := newTestCoordinator(state,
+6 -6
View File
@@ -17,7 +17,7 @@ func scoredSet(weights map[int64]float64) []ScoredChannel {
return out
}
func TestPickHRW_粘性稳定(t *testing.T) {
func TestPickHRW_StickyStability(t *testing.T) {
cands := scoredSet(map[int64]float64{1: 5, 2: 3, 3: 2})
first := PickHRW(cands, "session-abc")
require.NotNil(t, first)
@@ -27,7 +27,7 @@ func TestPickHRW_粘性稳定(t *testing.T) {
}
}
func TestPickHRW_候选顺序无关(t *testing.T) {
func TestPickHRW_CandidateOrderIndependent(t *testing.T) {
a := []ScoredChannel{
{Channel: Channel{ID: 1}, Weight: 5},
{Channel: Channel{ID: 2}, Weight: 3},
@@ -41,7 +41,7 @@ func TestPickHRW_候选顺序无关(t *testing.T) {
}
}
func TestPickHRW_权重比例分布(t *testing.T) {
func TestPickHRW_WeightProportionalDistribution(t *testing.T) {
// 权重 5:3:2 → 期望占比 50%/30%/20%,10 万采样误差 < 2 个百分点
cands := scoredSet(map[int64]float64{1: 5, 2: 3, 3: 2})
const n = 100_000
@@ -57,7 +57,7 @@ func TestPickHRW_权重比例分布(t *testing.T) {
}
}
func TestPickHRW_权重非正不参与(t *testing.T) {
func TestPickHRW_NonPositiveWeightExcluded(t *testing.T) {
cands := scoredSet(map[int64]float64{1: 0, 2: -1, 3: 2})
for i := range 1000 {
p := PickHRW(cands, fmt.Sprintf("s-%d", i))
@@ -68,7 +68,7 @@ func TestPickHRW_权重非正不参与(t *testing.T) {
assert.Nil(t, PickHRW(nil, "s"))
}
func TestPickHRW_单调性(t *testing.T) {
func TestPickHRW_Monotonicity(t *testing.T) {
// 增加一个渠道只迁移有限比例会话:不含新渠道时选中 A 的会话,
// 加入新渠道后要么仍选 A,要么选新渠道,绝不迁到其它旧渠道。
oldSet := scoredSet(map[int64]float64{1: 5, 2: 3, 3: 2})
@@ -87,7 +87,7 @@ func TestPickHRW_单调性(t *testing.T) {
assert.InDelta(t, 3.0/13.0, float64(moved)/n, 0.02)
}
func TestUniformHash_值域(t *testing.T) {
func TestUniformHash_Range(t *testing.T) {
for i := range 10_000 {
u := uniformHash(fmt.Sprintf("k-%d", i), int64(i))
require.Greater(t, u, 0.0)
+15 -15
View File
@@ -10,7 +10,7 @@ import (
func retryPol() RetryPolicy { return DefaultRoutingPolicy().Retry }
func TestDecide_硬规则优先(t *testing.T) {
func TestDecide_HardRulesTakePrecedence(t *testing.T) {
p := retryPol()
// 已向客户端写出响应:任何类别都终止
@@ -33,7 +33,7 @@ func TestDecide_硬规则优先(t *testing.T) {
assert.Equal(t, DecisionAbort, d)
}
func TestDecide_客户端错误不重试(t *testing.T) {
func TestDecide_ClientErrorNoRetry(t *testing.T) {
p := retryPol()
d, _ := Decide(ErrClassClient, DeliveryResponseReceived, ReplaySafe, 0, AttemptState{}, p)
assert.Equal(t, DecisionAbort, d)
@@ -41,7 +41,7 @@ func TestDecide_客户端错误不重试(t *testing.T) {
assert.Equal(t, DecisionAbort, d)
}
func TestDecide_瞬时错误原地重试与退避(t *testing.T) {
func TestDecide_TransientInPlaceRetryWithBackoff(t *testing.T) {
p := retryPol() // inPlaceBudget=2, base=100ms, max=1000ms
d, backoff := Decide(ErrClassTransient, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{InPlaceUsed: 0}, p)
@@ -61,14 +61,14 @@ func TestDecide_瞬时错误原地重试与退避(t *testing.T) {
assert.Equal(t, DecisionAbort, d)
}
func TestDecide_MaybeSent禁止原地(t *testing.T) {
func TestDecide_MaybeSentForbidsInPlace(t *testing.T) {
p := retryPol()
// TRANSIENT + MaybeSent:可能已送达,原地重发有重复风险 → 直接 failover
d, _ := Decide(ErrClassTransient, DeliveryMaybeSent, ReplayCostly, 0, AttemptState{}, p)
assert.Equal(t, DecisionFailover, d)
}
func TestDecide_超时不原地重试(t *testing.T) {
func TestDecide_TimeoutNoInPlaceRetry(t *testing.T) {
p := retryPol()
// TIMEOUT(含 504)不原地重试
d, _ := Decide(ErrClassTimeout, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{}, p)
@@ -82,7 +82,7 @@ func TestDecide_超时不原地重试(t *testing.T) {
assert.Equal(t, DecisionAbort, d)
}
func TestDecide_限流RetryAfter(t *testing.T) {
func TestDecide_RateLimitRetryAfter(t *testing.T) {
p := retryPol() // rateLimitWaitMax=2000ms
// Retry-After 短 → 原地等待,退避 = Retry-After
@@ -99,7 +99,7 @@ func TestDecide_限流RetryAfter(t *testing.T) {
assert.Equal(t, DecisionFailover, d)
}
func TestDecide_凭证轮换(t *testing.T) {
func TestDecide_CredentialRotation(t *testing.T) {
p := retryPol() // credRotateBudget=1
// 有备用 Key 且预算未耗尽 → 轮换
@@ -120,13 +120,13 @@ func TestDecide_凭证轮换(t *testing.T) {
assert.Equal(t, DecisionAbort, d)
}
func TestDecide_渠道致命零原地(t *testing.T) {
func TestDecide_ChannelFatalZeroInPlace(t *testing.T) {
p := retryPol()
d, _ := Decide(ErrClassChannelFatal, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{}, p)
assert.Equal(t, DecisionFailover, d)
}
func TestDecide_模型致命直接failover(t *testing.T) {
func TestDecide_ModelFatalFailsOverDirectly(t *testing.T) {
p := retryPol()
// 模型致命与渠道致命同语义:零原地重试、立即 failover
d, _ := Decide(ErrClassModelFatal, DeliveryResponseReceived, ReplayCostly, 0, AttemptState{}, p)
@@ -137,7 +137,7 @@ func TestDecide_模型致命直接failover(t *testing.T) {
assert.Equal(t, DecisionAbort, d)
}
func TestDecide_ReplayUnsafe预算收紧(t *testing.T) {
func TestDecide_ReplayUnsafeTightensBudget(t *testing.T) {
p := retryPol() // unsafe 预算默认 0/0
// 已收到错误响应(非 NotSent)→ Abort
@@ -156,9 +156,9 @@ func TestDecide_ReplayUnsafe预算收紧(t *testing.T) {
assert.Equal(t, DecisionAbort, d, "非 NotSent 即使有预算也不允许 failover")
}
// TestDecide_属性_任意错误序列不突破预算 属性测试:模拟协调器的预算记账,
// TestDecide_Property_AnyErrorSequenceStaysWithinBudget 属性测试:模拟协调器的预算记账,
// 用随机错误序列驱动 FSM,断言任何序列都不会突破预算且必然终止。
func TestDecide_属性_任意错误序列不突破预算(t *testing.T) {
func TestDecide_Property_AnyErrorSequenceStaysWithinBudget(t *testing.T) {
p := retryPol()
classes := []ErrorClass{ErrClassClient, ErrClassTransient, ErrClassRateLimit, ErrClassCredential, ErrClassChannelFatal, ErrClassModelFatal, ErrClassTimeout}
deliveries := []DeliveryState{DeliveryNotSent, DeliveryMaybeSent, DeliveryResponseReceived}
@@ -203,7 +203,7 @@ func TestDecide_属性_任意错误序列不突破预算(t *testing.T) {
}
}
func TestBackoffMs_封顶(t *testing.T) {
func TestBackoffMs_Capped(t *testing.T) {
p := retryPol() // base=100, max=1000
assert.Equal(t, int64(100), backoffMs(0, p))
assert.Equal(t, int64(200), backoffMs(1, p))
@@ -213,11 +213,11 @@ func TestBackoffMs_封顶(t *testing.T) {
assert.Equal(t, int64(1000), backoffMs(60, p), "大次数不溢出")
}
func TestRoutingPolicy_默认值合法(t *testing.T) {
func TestRoutingPolicy_DefaultsAreValid(t *testing.T) {
assert.NoError(t, DefaultRoutingPolicy().Validate())
}
func TestRoutingPolicy_校验拒绝非法配置(t *testing.T) {
func TestRoutingPolicy_ValidateRejectsInvalidConfig(t *testing.T) {
mutate := func(f func(*RoutingPolicy)) *RoutingPolicy {
p := DefaultRoutingPolicy()
f(p)
+7 -7
View File
@@ -16,7 +16,7 @@ func healthyChannel(id int64, tier Tier, weight float64) Channel {
}
}
func TestEffectiveWeight_健康满分基准(t *testing.T) {
func TestEffectiveWeight_FullHealthBaseline(t *testing.T) {
pol := DefaultRoutingPolicy()
w, bd := EffectiveWeight(healthyChannel(1, TierPrimary, 10), pol)
assert.InDelta(t, 10.0, w, 1e-9)
@@ -27,7 +27,7 @@ func TestEffectiveWeight_健康满分基准(t *testing.T) {
assert.Equal(t, 1.0, bd.Ramp)
}
func TestEffectiveWeight_层级偏置(t *testing.T) {
func TestEffectiveWeight_TierBias(t *testing.T) {
pol := DefaultRoutingPolicy()
wp, _ := EffectiveWeight(healthyChannel(1, TierPrimary, 10), pol)
ws, _ := EffectiveWeight(healthyChannel(2, TierSecondary, 10), pol)
@@ -40,7 +40,7 @@ func TestEffectiveWeight_层级偏置(t *testing.T) {
assert.Zero(t, wu)
}
func TestEffectiveWeight_健康因子(t *testing.T) {
func TestEffectiveWeight_HealthFactor(t *testing.T) {
pol := DefaultRoutingPolicy() // alpha=2
c := healthyChannel(1, TierPrimary, 10)
@@ -73,7 +73,7 @@ func TestEffectiveWeight_健康因子(t *testing.T) {
assert.Equal(t, 1.0, bd.Health)
}
func TestEffectiveWeight_负载余量(t *testing.T) {
func TestEffectiveWeight_HeadroomFactor(t *testing.T) {
pol := DefaultRoutingPolicy() // gamma=2
c := healthyChannel(1, TierPrimary, 10)
@@ -99,7 +99,7 @@ func TestEffectiveWeight_负载余量(t *testing.T) {
assert.Equal(t, 1.0, bd.Headroom)
}
func TestEffectiveWeight_成本因子(t *testing.T) {
func TestEffectiveWeight_CostFactor(t *testing.T) {
pol := DefaultRoutingPolicy() // beta=0.5, clamp [0.5, 2.0]
c := healthyChannel(1, TierPrimary, 10)
@@ -120,7 +120,7 @@ func TestEffectiveWeight_成本因子(t *testing.T) {
assert.Equal(t, 1.0, bd.Cost)
}
func TestEffectiveWeight_爬坡因子(t *testing.T) {
func TestEffectiveWeight_RampFactor(t *testing.T) {
pol := DefaultRoutingPolicy() // window=120s, floor=0.05
c := healthyChannel(1, TierPrimary, 10)
@@ -142,7 +142,7 @@ func TestEffectiveWeight_爬坡因子(t *testing.T) {
assert.Equal(t, 1.0, bd.Ramp, "非爬坡期恒为 1")
}
func TestEffectiveWeight_基础权重非正(t *testing.T) {
func TestEffectiveWeight_NonPositiveBaseWeight(t *testing.T) {
pol := DefaultRoutingPolicy()
c := healthyChannel(1, TierPrimary, 0)
w, _ := EffectiveWeight(c, pol)
+6 -6
View File
@@ -15,7 +15,7 @@ func defaultSessionPolicy() SessionPolicy {
return DefaultRoutingPolicy().Session
}
func TestResolveSessionKey_解析链优先级(t *testing.T) {
func TestResolveSessionKey_ResolutionChainPriority(t *testing.T) {
pol := defaultSessionPolicy()
tests := []struct {
@@ -38,7 +38,7 @@ func TestResolveSessionKey_解析链优先级(t *testing.T) {
}
}
func TestResolveSessionKey_Anthropic会话段提取(t *testing.T) {
func TestResolveSessionKey_AnthropicSessionSegmentExtraction(t *testing.T) {
pol := defaultSessionPolicy()
// 含 session UUID:提取段后哈希;两个不同 user 前缀但相同 session → 相同键
@@ -56,7 +56,7 @@ func TestResolveSessionKey_Anthropic会话段提取(t *testing.T) {
assert.NotEqual(t, a.Key, c.Key)
}
func TestResolveSessionKey_策略开关(t *testing.T) {
func TestResolveSessionKey_PolicyToggles(t *testing.T) {
pol := defaultSessionPolicy()
pol.ParseAnthropicMetadata = false
got := ResolveSessionKey(profileWith(SessionSignals{AnthropicUserID: "user_x_session_11111111-2222-3333-4444-555555555555"}), pol)
@@ -68,7 +68,7 @@ func TestResolveSessionKey_策略开关(t *testing.T) {
assert.Equal(t, SourceIdentity, got.Source, "关闭 openai 解析后应回退身份级")
}
func TestResolveSessionKey_非法信号跳过(t *testing.T) {
func TestResolveSessionKey_SkipsInvalidSignal(t *testing.T) {
pol := defaultSessionPolicy()
tests := []struct {
@@ -87,7 +87,7 @@ func TestResolveSessionKey_非法信号跳过(t *testing.T) {
}
}
func TestResolveSessionKey_命名空间隔离(t *testing.T) {
func TestResolveSessionKey_NamespaceIsolation(t *testing.T) {
pol := defaultSessionPolicy()
sig := SessionSignals{HeaderSessionID: "same-session"}
@@ -101,7 +101,7 @@ func TestResolveSessionKey_命名空间隔离(t *testing.T) {
assert.NotEqual(t, ResolveSessionKey(p1, pol).Key, ResolveSessionKey(p3, pol).Key, "不同模型的绑定应互相独立")
}
func TestResolveSessionKey_确定性(t *testing.T) {
func TestResolveSessionKey_Deterministic(t *testing.T) {
pol := defaultSessionPolicy()
p := profileWith(SessionSignals{HeaderSessionID: "stable"})
first := ResolveSessionKey(p, pol)
+22 -18
View File
@@ -3,6 +3,10 @@ import { ref, reactive, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconCloudDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
import request from '@/utils/request'
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
// 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
const props = defineProps<{
visible: boolean
@@ -243,18 +247,18 @@ const previewBase = computed(() => {
function segmentPreview(seg: TimeSegmentRow): string {
const m = (seg.percent || 0) / 100
if (previewBase.value.kind === 'per_request') {
return `$${(previewBase.value.perRequest * m).toFixed(4)} /次`
return `${formatBilling(previewBase.value.perRequest * m, 4)} /次`
}
const prefix = previewBase.value.kind === 'tiered' ? '首档 ' : ''
return `${prefix}输入 $${(previewBase.value.input * m).toFixed(4)} · 输出 $${(previewBase.value.output * m).toFixed(4)}`
return `${prefix}输入 ${formatBilling(previewBase.value.input * m, 4)} · 输出 ${formatBilling(previewBase.value.output * m, 4)}`
}
function defaultPreviewText(): string {
if (previewBase.value.kind === 'per_request') {
return `$${previewBase.value.perRequest.toFixed(4)} /次`
return `${formatBilling(previewBase.value.perRequest, 4)} /次`
}
const prefix = previewBase.value.kind === 'tiered' ? '首档 ' : ''
return `${prefix}输入 $${previewBase.value.input.toFixed(4)} · 输出 $${previewBase.value.output.toFixed(4)}`
return `${prefix}输入 ${formatBilling(previewBase.value.input, 4)} · 输出 ${formatBilling(previewBase.value.output, 4)}`
}
function segmentDesc(seg: TimeSegmentRow): string {
@@ -500,19 +504,19 @@ watch(() => props.visible, (val) => {
<div class="official-prices">
<div class="official-price-row">
<span class="official-price-label">输入价格</span>
<span class="official-price-value">${{ src.pricing.input_price.toFixed(2) }} / 1M</span>
<span class="official-price-value">{{ formatBilling(src.pricing.input_price, 2) }} / 1M</span>
</div>
<div class="official-price-row">
<span class="official-price-label">输出价格</span>
<span class="official-price-value">${{ src.pricing.output_price.toFixed(2) }} / 1M</span>
<span class="official-price-value">{{ formatBilling(src.pricing.output_price, 2) }} / 1M</span>
</div>
<div class="official-price-row">
<span class="official-price-label">缓存读取</span>
<span class="official-price-value">{{ src.pricing.cache_read_price ? '$' + src.pricing.cache_read_price.toFixed(2) + ' / 1M' : '—' }}</span>
<span class="official-price-value">{{ src.pricing.cache_read_price ? formatBilling(src.pricing.cache_read_price, 2) + ' / 1M' : '—' }}</span>
</div>
<div class="official-price-row">
<span class="official-price-label">缓存创建</span>
<span class="official-price-value">{{ src.pricing.cache_creation_price ? '$' + src.pricing.cache_creation_price.toFixed(2) + ' / 1M' : '—' }}</span>
<span class="official-price-value">{{ src.pricing.cache_creation_price ? formatBilling(src.pricing.cache_creation_price, 2) + ' / 1M' : '—' }}</span>
</div>
</div>
<AButton type="primary" size="small" long @click="applyOfficialPricing(src.pricing)">应用此价格</AButton>
@@ -543,7 +547,7 @@ watch(() => props.visible, (val) => {
placeholder="每次调用价格"
class="w-full"
>
<template #suffix>$ / 次</template>
<template #suffix>{{ currencySymbol }} / 次</template>
</AInputNumber>
</AFormItem>
</div>
@@ -555,7 +559,7 @@ watch(() => props.visible, (val) => {
<div class="editor-section">
<div class="editor-section-header">
<h3>按量定价</h3>
<span class="section-hint">价格单位为 $ / 1M Token</span>
<span class="section-hint">价格单位为 {{ currencySymbol }} / 1M Token</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-x-4">
<AFormItem label="输入价格">
@@ -566,7 +570,7 @@ watch(() => props.visible, (val) => {
placeholder="0"
class="w-full"
>
<template #suffix>$ / 1M</template>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格">
@@ -577,7 +581,7 @@ watch(() => props.visible, (val) => {
placeholder="0"
class="w-full"
>
<template #suffix>$ / 1M</template>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存读取">
@@ -588,7 +592,7 @@ watch(() => props.visible, (val) => {
placeholder="0"
class="w-full"
>
<template #suffix>$ / 1M</template>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建">
@@ -599,7 +603,7 @@ watch(() => props.visible, (val) => {
placeholder="0"
class="w-full"
>
<template #suffix>$ / 1M</template>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
</div>
@@ -651,7 +655,7 @@ watch(() => props.visible, (val) => {
:precision="4"
class="w-full"
>
<template #suffix>$/1M</template>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格">
@@ -661,7 +665,7 @@ watch(() => props.visible, (val) => {
:precision="4"
class="w-full"
>
<template #suffix>$/1M</template>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
</div>
@@ -673,7 +677,7 @@ watch(() => props.visible, (val) => {
:precision="4"
class="w-full"
>
<template #suffix>$/1M</template>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建价格">
@@ -683,7 +687,7 @@ watch(() => props.visible, (val) => {
:precision="4"
class="w-full"
>
<template #suffix>$/1M</template>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
</div>
+71
View File
@@ -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<CurrencyCode>(() =>
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<void> {
return fetchSettings(true)
}
+15 -14
View File
@@ -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 }
}
@@ -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<PublicSettings>({
site_name: '',
site_description: '',
maintenance_mode: false,
maintenance_message: '',
maintenance_duration: '',
demo_mode: false,
demo_message: '',
})
let fetchPromise: Promise<void> | null = null
let lastFetchTime = 0
const CACHE_TTL = 30_000 // 30秒缓存
export function usePublicSettings() {
async function fetchSettings(force = false): Promise<void> {
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,
}
}
+2 -9
View File
@@ -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',
+4 -1
View File
@@ -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<boolean> {
}
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}`
}
-2
View File
@@ -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 },
],
},
]
+18 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import {
IconThunderbolt,
@@ -13,6 +13,7 @@ import {
import type { TableColumnData } from '@arco-design/web-vue'
import request from '@/utils/request'
import * as echarts from 'echarts'
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
const router = useRouter()
@@ -43,10 +44,9 @@ function calcGrowth(current: number, previous: number): { value: number; positiv
return { value: Math.round(growth * 10) / 10, positive: growth >= 0 }
}
// 金额格式化走全局本位币(bil 层数据直显 + 本位币符号)
function formatMoney(val: any) {
const n = Number(val)
if (!n && n !== 0) return '$0.00'
return '$' + n.toFixed(2)
return formatBilling(val, 2)
}
function formatNumber(val: any) {
@@ -253,18 +253,19 @@ function renderTrendChart() {
const revenues = trends.value.map((r: any) => Number(r.revenue) || 0)
const requests = trends.value.map((r: any) => Number(r.requests) || 0)
// 图例/轴名带币种代码,跟随本位币(由下方 watch 触发重渲染)
trendChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['收入 ($)', '请求数'], bottom: 0 },
legend: { data: [`收入 (${displayCurrency.value})`, '请求数'], bottom: 0 },
grid: { left: 60, right: 60, top: 15, bottom: 36 },
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '收入 ($)', axisLabel: { formatter: (v: number) => '$' + v } },
{ type: 'value', name: `收入 (${displayCurrency.value})`, axisLabel: { formatter: (v: number) => formatBilling(v) } },
{ type: 'value', name: '请求数', splitLine: { show: false } },
],
series: [
{
name: '收入 ($)',
name: `收入 (${displayCurrency.value})`,
type: 'bar',
data: revenues,
barWidth: 12,
@@ -331,7 +332,7 @@ function renderModelChart() {
modelChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 130, right: 50, top: 10, bottom: 36 },
xAxis: { type: 'value', name: '费用 ($)', nameLocation: 'middle', nameGap: 22 },
xAxis: { type: 'value', name: `费用 (${displayCurrency.value})`, nameLocation: 'middle', nameGap: 22 },
yAxis: {
type: 'category',
data: names,
@@ -361,7 +362,7 @@ function renderModelHourlyChart() {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (v: number) => '$' + Number(v).toFixed(4),
valueFormatter: (v: number) => formatBilling(v, 4),
},
legend: { data: data.models, bottom: 0, type: 'scroll' },
grid: { left: 60, right: 20, top: 15, bottom: 46 },
@@ -372,8 +373,8 @@ function renderModelHourlyChart() {
},
yAxis: {
type: 'value',
name: '费用 ($)',
axisLabel: { formatter: (v: number) => '$' + v },
name: `费用 (${displayCurrency.value})`,
axisLabel: { formatter: (v: number) => formatBilling(v) },
},
series: data.series.map((s: any) => ({
name: s.model,
@@ -385,6 +386,12 @@ function renderModelHourlyChart() {
}, true)
}
// 本位币变化(配置异步加载/后台切换)时重绘全部图表:echarts option 在命令式渲染函数中拼装,
// 不在 Vue 渲染树内,需显式 watch 触发重绘;metricCards/表格 render 经 formatBilling 自动建立依赖
watch(displayCurrency, () => {
renderAllCharts()
})
// === Resize ===
const resizeObserver = new ResizeObserver(() => {
trendChart?.resize()
+12
View File
@@ -15,6 +15,7 @@ const form = reactive({
displayName: '',
password: '',
confirmPassword: '',
currency: 'USD',
})
onMounted(async () => {
@@ -51,6 +52,7 @@ async function handleSetup() {
displayName: form.displayName.trim(),
password: form.password,
confirmPassword: form.confirmPassword,
currency: form.currency,
})
Message.success('初始化成功,正在跳转登录页...')
markSystemInitialized()
@@ -180,6 +182,16 @@ async function handleSetup() {
@keydown.enter="handleSetup"
/>
</AFormItem>
<AFormItem
field="currency"
label="本位币"
extra="决定系统内部记账与全站显示货币(模型定价按本位币配置),初始化后不可更改"
>
<ARadioGroup v-model="form.currency">
<ARadio value="USD">USD(美元)· 与上游模型报价对齐</ARadio>
<ARadio value="CNY">CNY(人民币)· 面向国内运营</ARadio>
</ARadioGroup>
</AFormItem>
<AFormItem>
<AButton
type="primary"
@@ -23,6 +23,13 @@ const breakerTagColor: Record<number, string> = { 0: 'green', 1: 'red', 2: 'oran
const breakerLabel: Record<number, string> = { 0: '正常', 1: '熔断', 2: '半开' }
const tierLabel: Record<string, string> = { primary: '首选', secondary: '备用', reserve: '保底' }
const tierTagColor: Record<string, string> = { primary: 'arcoblue', secondary: 'orange', reserve: 'gray' }
// 凭证冷却剩余秒数 → 人类可读(后端返回 Redis TTL,最长为策略里的 credCooldownSeconds)
function formatCooldown(seconds: number): string {
const s = Math.max(0, Math.floor(seconds || 0))
if (s < 60) return `${s} 秒`
return `${Math.floor(s / 60)} 分 ${String(s % 60).padStart(2, '0')} 秒`
}
const tierOptions = [
{ label: '首选(承接主要流量)', value: 'primary' },
{ label: '备用(零星保温流量,主力饱和时溢出承接)', value: 'secondary' },
@@ -1114,10 +1121,25 @@ function formatHeaders(headers: Record<string, string>): string {
{{ detail.key_status === 'active' ? '正常' : detail.key_status || 'N/A' }}
</ATag>
</ADescriptionsItem>
<ADescriptionsItem label="凭证冷却">
<ATag v-if="(detail.key_cooldown_remaining ?? 0) > 0" color="orangered" size="small">
冷却中 · 剩余 {{ formatCooldown(detail.key_cooldown_remaining) }}
</ATag>
<ATag v-else color="green" size="small">正常</ATag>
</ADescriptionsItem>
<ADescriptionsItem v-if="detail.token_expires_at" label="Token 过期时间">
{{ detail.token_expires_at }}
</ADescriptionsItem>
</ADescriptions>
<AAlert
v-if="(detail.key_cooldown_remaining ?? 0) > 0"
type="warning"
style="margin-top: 12px;"
>
该 Key 因认证失败(401/403)被调度器冷却,冷却期内本渠道会被整体跳过且不会发起上游请求
—— 此时渠道测试仍会成功(测试不走调度器),但实际调用会返回「无可用渠道」。
更新 Key 或点击「重置健康度」都会立即解除冷却。
</AAlert>
<div style="margin-top: 12px;">
<AButton type="outline" size="small" @click="showUpdateKeyModal = true">更新 Key</AButton>
</div>
+13 -6
View File
@@ -10,6 +10,7 @@ import ModelPricingModal from '@/components/ModelPricingModal.vue'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
import ResponsiveTable from '@/components/ResponsiveTable.vue'
import { formatBilling } from '@/composables/useCurrency'
const loading = ref(false)
const data = ref<any[]>([])
@@ -152,6 +153,12 @@ const statusTagLabel: Record<string, string> = {
offline: '下线',
}
const pricingModeTagColor: Record<string, string> = {
token: 'arcoblue',
per_request: 'orangered',
tiered: 'purple',
}
// ===== 移动端卡片自定义布局用的格式化(仅用于卡片插槽,不改动桌面端列 render)=====
// token 数紧凑格式化:128000 → 128K,1500000 → 1.5M,无值返回 '-'
@@ -165,13 +172,13 @@ function formatTokens(n: number | null | undefined): string {
return String(n)
}
// 定价主文本:$输入/$输出、$单价/次、未定价
// 定价主文本:本位币 输入/输出、单价/次、未定价(bil 层定价直显)
function pricingMain(row: any): string {
if (!row.pricing_mode) return '未定价'
if (row.pricing_mode === 'per_request') {
return `$${row.per_request_price?.toFixed(4) ?? '0'}/次`
return `${formatBilling(row.per_request_price ?? 0, 4)}/次`
}
return `$${row.input_price?.toFixed(2) ?? '0'}/$${row.output_price?.toFixed(2) ?? '0'}`
return `${formatBilling(row.input_price ?? 0, 2)}/${formatBilling(row.output_price ?? 0, 2)}`
}
// 定价模式副文本:按量 / 按次 / 阶梯(未定价返回空串)
@@ -218,14 +225,14 @@ const columns: TableColumnData[] = [
}
const modeLabel: Record<string, string> = { token: '按量', per_request: '按次', tiered: '阶梯' }
const tags = [
h(Tag, { color: 'green', size: 'small' }, () => modeLabel[record.pricing_mode] || record.pricing_mode),
h(Tag, { color: pricingModeTagColor[record.pricing_mode] || 'gray', size: 'small' }, () => modeLabel[record.pricing_mode] || record.pricing_mode),
]
if (record.pricing_mode === 'per_request') {
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
`$${record.per_request_price?.toFixed(4) ?? '0'}/次`))
`${formatBilling(record.per_request_price ?? 0, 4)}/次`))
} else {
tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' },
`$${record.input_price?.toFixed(2) ?? '0'}/$${record.output_price?.toFixed(2) ?? '0'}`))
`${formatBilling(record.input_price ?? 0, 2)}/${formatBilling(record.output_price ?? 0, 2)}`))
}
return h('span', { style: 'display: inline-flex; gap: 4px; align-items: center;' }, tags)
},
+5 -4
View File
@@ -10,6 +10,7 @@ import TableStats from '@/components/TableStats.vue'
import request from '@/utils/request'
import ResponsiveTable from '@/components/ResponsiveTable.vue'
import { useDateRange } from '@/composables/useDateRange'
import { formatBilling } from '@/composables/useCurrency'
const { defaultEnd, defaultTodayRange, quickDateRanges } = useDateRange()
@@ -122,10 +123,10 @@ const columns: TableColumnData[] = [
width: 100,
render({ record }) {
if (record.billing_settled && record.actual_cost > 0) {
return `$${record.actual_cost.toFixed(6)}`
return formatBilling(record.actual_cost, 6)
}
if (record.pre_deduct_amount > 0) {
return `$${record.pre_deduct_amount.toFixed(6)} (预扣)`
return `${formatBilling(record.pre_deduct_amount, 6)} (预扣)`
}
return '-'
},
@@ -363,11 +364,11 @@ onMounted(() => {
<ADescriptionsItem label="模型">{{ detailData.model_name }}</ADescriptionsItem>
<ADescriptionsItem label="进度">{{ detailData.progress }}</ADescriptionsItem>
<ADescriptionsItem label="预扣金额">
<span v-if="detailData.pre_deduct_amount > 0">${{ detailData.pre_deduct_amount.toFixed(4) }}</span>
<span v-if="detailData.pre_deduct_amount > 0">{{ formatBilling(detailData.pre_deduct_amount, 4) }}</span>
<span v-else>-</span>
</ADescriptionsItem>
<ADescriptionsItem label="实际费用">
<span v-if="detailData.billing_settled">${{ detailData.actual_cost.toFixed(4) }}</span>
<span v-if="detailData.billing_settled">{{ formatBilling(detailData.actual_cost, 4) }}</span>
<span v-else>未结算</span>
</ADescriptionsItem>
<ADescriptionsItem label="提交时间">{{ detailData.submit_time || '-' }}</ADescriptionsItem>
+3 -2
View File
@@ -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 orders = ref<any[]>([])
@@ -45,7 +46,7 @@ const columns: TableColumnData[] = [
},
{
title: '金额', dataIndex: 'final_amount', width: 100,
render({ record }) { return `¥${Number(record.final_amount).toFixed(2)}` },
render({ record }) { return formatOrder(record.final_amount, 2) },
},
{ title: '支付渠道', dataIndex: 'payment_channel', width: 90 },
{
@@ -161,7 +162,7 @@ const { exporting, exportFile } = useExport({
<AModal v-model:visible="showRefundModal" title="发起退款" :width="450" :mask-closable="false" :on-before-ok="handleRefund" :ok-loading="refundLoading">
<AForm :model="refundForm" :auto-label-width="true" layout="vertical">
<AFormItem label="退款金额">
<span class="text-red-500 font-medium">¥{{ refundAmount.toFixed(2) }}</span>
<span class="text-red-500 font-medium">{{ formatOrder(refundAmount, 2) }}</span>
</AFormItem>
<AFormItem label="退款原因" required>
<AInput v-model="refundForm.reason" type="textarea" :auto-size="{ minRows: 3, maxRows: 5 }" placeholder="请填写退款原因" />
@@ -1,261 +0,0 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
// ========== 全局支付设置 ==========
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const settingsForm = reactive({
amount_options: '10,20,50,100,200,500',
amount_discount: '{}',
min_topup: 1,
currency: 'CNY',
callback_base_url: '',
})
const currencyOptions = [
{ value: 'CNY', label: 'CNY - 人民币' },
{ value: 'USD', label: 'USD - 美元' },
]
async function fetchSettings() {
settingsLoading.value = true
try {
const res = await request.get('/admin/payment-settings')
const data = res.data?.data
if (!data) return
if (data.amount_options) {
settingsForm.amount_options = Array.isArray(data.amount_options)
? data.amount_options.join(',')
: String(data.amount_options)
}
if (data.amount_discount) {
settingsForm.amount_discount = typeof data.amount_discount === 'string'
? data.amount_discount
: JSON.stringify(data.amount_discount)
}
if (data.min_topup !== undefined) settingsForm.min_topup = Number(data.min_topup)
if (data.currency) settingsForm.currency = data.currency
if (data.callback_base_url !== undefined) settingsForm.callback_base_url = data.callback_base_url
} catch {
// interceptor handles error
} finally {
settingsLoading.value = false
}
}
async function handleSettingsSave() {
settingsSaving.value = true
try {
let amountOptions: number[] = []
try {
amountOptions = settingsForm.amount_options
.split(',')
.map((s: string) => Number(s.trim()))
.filter((n: number) => !isNaN(n) && n > 0)
} catch {
Message.error('金额选项格式错误')
return
}
let amountDiscount: Record<string, number> = {}
try {
amountDiscount = JSON.parse(settingsForm.amount_discount || '{}')
} catch {
Message.error('折扣映射格式错误')
return
}
await request.put('/admin/payment-settings', {
amount_options: amountOptions,
amount_discount: amountDiscount,
min_topup: settingsForm.min_topup,
currency: settingsForm.currency,
callback_base_url: settingsForm.callback_base_url,
})
Message.success('保存成功')
} catch {
// interceptor handles error
} finally {
settingsSaving.value = false
}
}
// ========== 支付渠道配置 ==========
const channelsLoading = ref(false)
const epayForm = reactive({
is_enabled: false,
pay_address: '',
merchant_id: '',
merchant_key: '',
pay_methods: '',
})
const epaySaving = ref(false)
async function fetchChannels() {
channelsLoading.value = true
try {
const res = await request.get('/admin/payment-channels')
const list = res.data?.data?.list || []
const map: Record<string, any> = {}
for (const ch of list) {
map[ch.channel] = ch.config || {}
}
const epay = map.epay || {}
epayForm.is_enabled = !!epay.is_enabled
epayForm.pay_address = epay.pay_address || ''
epayForm.merchant_id = epay.merchant_id || ''
epayForm.merchant_key = epay.merchant_key || ''
epayForm.pay_methods = epay.pay_methods
? typeof epay.pay_methods === 'string'
? epay.pay_methods
: JSON.stringify(epay.pay_methods)
: ''
} catch {
// interceptor handles error
} finally {
channelsLoading.value = false
}
}
async function saveChannel(channel: string, config: Record<string, any>, loading: { value: boolean }) {
loading.value = true
try {
await request.put(`/admin/payment-channels/${channel}`, {
config: JSON.stringify(config),
})
Message.success('Epay(易支付)配置已保存')
} catch {
// interceptor handles error
} finally {
loading.value = false
}
}
function handleEpaySave() {
let payMethods = epayForm.pay_methods
try { payMethods = JSON.parse(payMethods) } catch { /* keep as string */ }
saveChannel('epay', {
is_enabled: epayForm.is_enabled,
pay_address: epayForm.pay_address,
merchant_id: epayForm.merchant_id,
merchant_key: epayForm.merchant_key,
pay_methods: payMethods || undefined,
}, epaySaving)
}
onMounted(() => {
fetchSettings()
fetchChannels()
})
</script>
<template>
<div>
<PageHeader title="支付设置" description="管理支付渠道和全局支付配置" />
<div class="space-y-4">
<!-- 全局支付设置 -->
<ACard :bordered="false" :loading="settingsLoading" title="全局设置">
<AForm :model="settingsForm" :auto-label-width="true">
<AGrid :cols="{ xs: 1, sm: 1, md: 2 }" :col-gap="24" :row-gap="0">
<AGridItem>
<AFormItem label="金额选项" extra="预设充值金额,用逗号分隔">
<AInput v-model="settingsForm.amount_options" placeholder="10,20,50,100,200,500" />
</AFormItem>
</AGridItem>
<AGridItem>
<AFormItem label="最低充值">
<AInputNumber v-model="settingsForm.min_topup" :min="0.01" :step="1" :precision="2" />
</AFormItem>
</AGridItem>
<AGridItem>
<AFormItem
label="折扣映射"
extra='JSON 格式,键为金额,值为折扣率。如 {"100":0.9} 表示充100享9折'
>
<AInput
v-model="settingsForm.amount_discount"
type="textarea"
:auto-size="{ minRows: 2, maxRows: 4 }"
placeholder='{}'
/>
</AFormItem>
</AGridItem>
<AGridItem>
<AFormItem
label="回调基础 URL"
extra="支付网关回调地址的前缀,留空则使用请求时的 Host"
>
<AInput v-model="settingsForm.callback_base_url" placeholder="https://api.example.com" />
</AFormItem>
</AGridItem>
<AGridItem>
<AFormItem label="默认货币">
<ASelect v-model="settingsForm.currency" :options="currencyOptions" />
</AFormItem>
</AGridItem>
</AGrid>
</AForm>
<template #actions>
<AButton type="primary" :loading="settingsSaving" @click="handleSettingsSave">保存设置</AButton>
</template>
</ACard>
<!-- 支付渠道 -->
<ACard :bordered="false" :loading="channelsLoading">
<template #title>
<div class="flex items-center gap-2">
<span>支付渠道 · Epay(易支付)</span>
<ATag :color="epayForm.is_enabled ? 'green' : undefined" size="small">
{{ epayForm.is_enabled ? '已启用' : '未启用' }}
</ATag>
</div>
</template>
<AForm :model="epayForm" :auto-label-width="true">
<AGrid :cols="{ xs: 1, sm: 1, md: 2 }" :col-gap="24" :row-gap="0">
<AGridItem>
<AFormItem label="启用">
<ASwitch v-model="epayForm.is_enabled" />
</AFormItem>
</AGridItem>
<AGridItem>
<AFormItem label="支付网关地址">
<AInput v-model="epayForm.pay_address" placeholder="https://pay.example.com" />
</AFormItem>
</AGridItem>
<AGridItem>
<AFormItem label="商户ID">
<AInput v-model="epayForm.merchant_id" placeholder="1001" />
</AFormItem>
</AGridItem>
<AGridItem>
<AFormItem label="商户密钥">
<AInput v-model="epayForm.merchant_key" placeholder="商户密钥" />
</AFormItem>
</AGridItem>
</AGrid>
<AFormItem
label="支付方式"
extra='JSON 数组。如 [{"name":"支付宝","type":"alipay","color":"#1677ff"},{"name":"微信支付","type":"wxpay","color":"#07c160"}]'
>
<AInput
v-model="epayForm.pay_methods"
type="textarea"
:auto-size="{ minRows: 2, maxRows: 4 }"
placeholder='[{"name":"支付宝","type":"alipay","color":"#1677ff"}]'
/>
</AFormItem>
</AForm>
<template #actions>
<AButton type="primary" :loading="epaySaving" @click="handleEpaySave">保存渠道配置</AButton>
</template>
</ACard>
</div>
</div>
</template>
+3 -2
View File
@@ -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<any[]>([])
@@ -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' ? '上架' : '下架') },
@@ -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<any[]>([])
@@ -164,7 +165,7 @@ const { exporting, exportFile } = useExport({
<ATable :columns="[
{ title: '租户ID', dataIndex: 'tenant_id', width: 80 },
{ title: '订单ID', dataIndex: 'order_id', width: 80 },
{ title: '折扣金额', dataIndex: 'discount_amount', width: 100, render({ record }) { return '$' + Number(record.discount_amount).toFixed(2) } },
{ title: '折扣金额', dataIndex: 'discount_amount', width: 100, render({ record }) { return formatOrder(record.discount_amount, 2) } },
{ title: '时间', dataIndex: 'created_at', width: 160 },
]" :data="usages" :loading="usagesLoading" :pagination="false" row-key="id" />
<div class="mt-4 flex justify-end">
@@ -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<any[]>([])
@@ -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) || '-' } },
@@ -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<any[]>([])
@@ -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 },
@@ -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<any[]>([])
@@ -224,7 +225,8 @@ const gradeColor: Record<string, string> = {
critical: 'red',
}
const columns: TableColumnData[] = [
// 列定义为 computed:总成本列头币种跟随本位币(配置异步加载后自动更新)
const columns = computed<TableColumnData[]>(() => [
{ 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) {
<div class="metric-item">
<div class="metric-name">总成本</div>
<div class="metric-body">
<p>区间内该模型实际计费成本合计(USD),已含输入、输出与缓存各项费用。</p>
<p>区间内该模型实际计费成本合计({{ displayCurrency }}),已含输入、输出与缓存各项费用。</p>
<p><code>总成本 = Σ total_cost</code></p>
</div>
</div>
@@ -596,8 +598,8 @@ function renderChannelMatrix(record: any) {
<div class="metric-card__value">{{ stats.overallRate.toFixed(2) }}%</div>
</div>
<div class="metric-card metric-card--orange">
<div class="metric-card__label">总成本 (USD)</div>
<div class="metric-card__value">${{ stats.totalCost.toFixed(6) }}</div>
<div class="metric-card__label">总成本 ({{ displayCurrency }})</div>
<div class="metric-card__value">{{ formatBilling(stats.totalCost, 6) }}</div>
</div>
<div class="metric-card metric-card--green">
<div class="metric-card__label">请求缓存命中率</div>
@@ -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()
}
@@ -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 }) {
@@ -26,11 +26,8 @@ function updateCnyToUsd(usdToCny: number | undefined) {
<div class="section">
<div class="section-title">基础设置</div>
<div class="section-grid">
<AFormItem label="货币单位">
<ASelect v-model="values['payment_currency']">
<AOption value="CNY" label="CNY (人民币)" />
<AOption value="USD" label="USD (美元)" />
</ASelect>
<AFormItem label="本位币" help="系统记账与全站显示货币,初始化时选定,不可更改">
<AInput :model-value="values['billing_currency'] || 'USD'" disabled style="width: 100%" />
</AFormItem>
<AFormItem label="最低充值金额">
<AInputNumber
@@ -1,6 +1,7 @@
import { ref, reactive, provide, inject, type InjectionKey } from 'vue'
import { Message } from '@arco-design/web-vue'
import request from '@/utils/request'
import { refreshCurrencySettings } from '@/composables/useCurrency'
const settingsFormKey: InjectionKey<Record<string, any>> = 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
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue'
import { ref, reactive, computed, onMounted, h } from 'vue'
import {
Tag, Button, Space, Message, Modal,
} from '@arco-design/web-vue'
@@ -8,16 +8,18 @@ import PageHeader from '@/components/PageHeader.vue'
import TableStats from '@/components/TableStats.vue'
import request from '@/utils/request'
import ResponsiveTable from '@/components/ResponsiveTable.vue'
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
const loading = ref(false)
const levels = ref<any[]>([])
const columns: TableColumnData[] = [
// 列定义为 computed:阈值列头币种跟随本位币(配置异步加载后自动更新)
const columns = computed<TableColumnData[]>(() => [
{ title: '等级号', dataIndex: 'level', width: 80 },
{ title: '名称', dataIndex: 'name', width: 120 },
{
title: '累计充值阈值(USD)', dataIndex: 'cumulative_recharge_threshold', width: 180,
render({ record }) { return `$${Number(record.cumulative_recharge_threshold).toFixed(2)}` },
title: `累计充值阈值(${displayCurrency.value})`, dataIndex: 'cumulative_recharge_threshold', width: 180,
render({ record }) { return formatBilling(record.cumulative_recharge_threshold, 2) },
},
{
title: '最大成员数', dataIndex: 'max_members', width: 100,
@@ -45,7 +47,7 @@ const columns: TableColumnData[] = [
])
},
},
]
])
async function fetchLevels() {
loading.value = true
@@ -166,7 +168,7 @@ onMounted(fetchLevels)
<AFormItem label="等级名称" required>
<AInput v-model="formData.name" placeholder="如:LV2 白银用户" />
</AFormItem>
<AFormItem label="累计充值阈值(USD)" required>
<AFormItem :label="`累计充值阈值(${displayCurrency})`" required>
<AInputNumber v-model="formData.cumulative_recharge_threshold" :min="0" :precision="2" class="w-full" placeholder="达到此金额自动升级" />
</AFormItem>
<AFormItem label="最大成员数">
+7 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue'
import { ref, reactive, computed, onMounted, h } from 'vue'
import { useRouter } from 'vue-router'
import {
Tag, Button, Space, Message, InputNumber,
@@ -10,6 +10,7 @@ import TableStats from '@/components/TableStats.vue'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
import ResponsiveTable from '@/components/ResponsiveTable.vue'
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
const router = useRouter()
@@ -118,7 +119,8 @@ async function handleCreateSubmit(done: () => void) {
}
}
const columns: TableColumnData[] = [
// 列定义为 computed:钱包余额列头币种跟随本位币(配置异步加载后自动更新)
const columns = computed<TableColumnData[]>(() => [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '租户名', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '代码', dataIndex: 'code', width: 120 },
@@ -171,12 +173,12 @@ const columns: TableColumnData[] = [
},
},
{
title: '钱包余额(美元)',
title: `钱包余额(${displayCurrency.value})`,
dataIndex: 'wallet_balance',
width: 120,
render({ record }) {
const val = parseFloat(record.wallet_balance || '0')
return `\$${val.toFixed(2)}`
return formatBilling(val, 2)
},
},
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
@@ -192,7 +194,7 @@ const columns: TableColumnData[] = [
])
},
},
]
])
async function fetchData() {
loading.value = true
+329 -378
View File
@@ -1,19 +1,19 @@
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue'
import { ref, reactive, computed, onMounted, h } from 'vue'
import { useRouter } from 'vue-router'
import {
Message, Tag, Tooltip, Space, Button,
} from '@arco-design/web-vue'
import { IconArchive, IconSave } from '@arco-design/web-vue/es/icon'
import { IconSave, IconFile, IconBarChart, IconCamera, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
import type { TableColumnData } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import TableStats from '@/components/TableStats.vue'
import ForwardingTracePanel from '@/components/ForwardingTracePanel.vue'
import request from '@/utils/request'
import ResponsiveTable from '@/components/ResponsiveTable.vue'
import { useExport } from '@/composables/useExport'
import { useDateRange } from '@/composables/useDateRange'
import { useIsMobile } from '@/composables/useIsMobile'
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
const { defaultEnd, defaultTodayRange, quickDateRanges } = useDateRange()
const isMobileView = useIsMobile()
@@ -160,12 +160,15 @@ const billingSourceLabel: Record<string, string> = {
}
const detailVisible = ref(false)
// 计费快照默认折叠,需要时展开查看定价/倍率等审计细节
const snapshotCollapsed = ref(true)
const detailLog = ref<any>(null)
const router = useRouter()
// 费用格式化:bil 层本位币直显,符号跟随 displayCurrency
function formatCost(n: number): string {
if (n == null || isNaN(n)) return '$0.000000'
return '$' + n.toFixed(6)
if (n == null || isNaN(n)) return formatBilling(0, 6)
return formatBilling(n, 6)
}
function formatMs(n: number): string {
@@ -173,15 +176,34 @@ function formatMs(n: number): string {
return n < 1000 ? `${n}ms` : `${(n / 1000).toFixed(2)}s`
}
// 耗时分级着色:按三档阈值返回 绿/蓝/橙,超出最大档返回红色(输入毫秒)
function durationColor(ms: number, thresholds: [number, number, number]): string {
if (ms == null || ms <= 0) return 'inherit'
if (ms < thresholds[0]) return '#00b42a' // 绿色:优
if (ms < thresholds[1]) return '#165dff' // 蓝色:良
if (ms < thresholds[2]) return '#ff7d00' // 橙色:中
return '#f53f3f' // 红色:差
}
// 延迟分级:<30s 绿 / <60s 蓝 / <180s 橙 / ≥180s 红
function latencyColor(ms: number): string {
return durationColor(ms, [30000, 60000, 180000])
}
// TTFT 分级:<3s 绿 / <10s 蓝 / <30s 橙 / ≥30s 红
function ttftColor(ms: number): string {
return durationColor(ms, [3000, 10000, 30000])
}
function formatTime(s: string): string {
if (!s) return '-'
return s.replace('T', ' ').substring(0, 19)
}
// 格式化金额(保留2位小数)
// 格式化金额(bil 层本位币,保留2位小数)
function formatMoney(amount: number): string {
if (amount == null || isNaN(amount)) return '$0.00'
return '$' + amount.toFixed(2)
if (amount == null || isNaN(amount)) return formatBilling(0, 2)
return formatBilling(amount, 2)
}
// 格式化数字(添加千分位)
@@ -203,7 +225,7 @@ function totalTokens(log: any): number {
}
function parseSnapshot(log: any): any {
if (!log.billing_snapshot) return null
if (!log?.billing_snapshot) return null
try {
return typeof log.billing_snapshot === 'string' ? JSON.parse(log.billing_snapshot) : log.billing_snapshot
} catch {
@@ -211,6 +233,77 @@ function parseSnapshot(log: any): any {
}
}
// 计费快照解析结果:computed 只解析一次,避免模板里几十处调用反复 JSON.parse
const snapshot = computed(() => parseSnapshot(detailLog.value))
// 快照 token_costs 的 key → 中文标签
const tokenCostLabels: Record<string, string> = {
input: '输入',
output: '输出',
cache_read: '缓存读取',
cache_creation: '缓存创建',
cache_creation_5m: '缓存创建(5分钟)',
cache_creation_1h: '缓存创建(1小时)',
}
// 消费明细小票行:divider=true 渲染分隔线,strong=true 渲染小计行,total=true 渲染合计行
interface ReceiptRow {
label?: string
value?: string
sub?: string
valueClass?: string
strong?: boolean
total?: boolean
divider?: boolean
}
function tokenRow(label: string, tokens: number | null | undefined): ReceiptRow {
return { label, value: (tokens || 0).toLocaleString() }
}
// 小票行数据驱动生成,替代手写重复模板
const receiptRows = computed<ReceiptRow[]>(() => {
const d = detailLog.value
if (!d) return []
const rows: ReceiptRow[] = [
tokenRow('输入 Token', d.input_tokens),
tokenRow('输出 Token', d.output_tokens),
]
if ((d.cache_read_tokens || 0) > 0) rows.push(tokenRow('缓存读取', d.cache_read_tokens))
if ((d.cache_creation_tokens || 0) > 0) rows.push(tokenRow('缓存创建', d.cache_creation_tokens))
if ((d.cache_creation_5m_tokens || 0) > 0) rows.push(tokenRow('缓存创建(5分钟)', d.cache_creation_5m_tokens))
if ((d.cache_creation_1h_tokens || 0) > 0) rows.push(tokenRow('缓存创建(1小时)', d.cache_creation_1h_tokens))
if ((d.reasoning_tokens || 0) > 0) rows.push(tokenRow('推理 Token', d.reasoning_tokens))
if ((d.audio_input_tokens || 0) > 0) rows.push(tokenRow('音频输入', d.audio_input_tokens))
if ((d.audio_output_tokens || 0) > 0) rows.push(tokenRow('音频输出', d.audio_output_tokens))
if ((d.image_output_tokens || 0) > 0) rows.push(tokenRow('图像输出', d.image_output_tokens))
if ((d.image_count || 0) > 0) rows.push({ label: '生成图片', value: `${d.image_count} 张`, sub: d.image_size ? `(${d.image_size})` : '' })
// Token 合计行
rows.push({ label: 'Token 合计', value: totalTokens(d).toLocaleString(), strong: true })
rows.push({ divider: true })
rows.push({ label: '输入费用', value: formatCost(d.input_cost || 0) })
rows.push({ label: '输出费用', value: formatCost(d.output_cost || 0) })
if ((d.cache_creation_cost || 0) > 0) rows.push({ label: '缓存创建费用', value: formatCost(d.cache_creation_cost) })
if ((d.cache_read_cost || 0) > 0) rows.push({ label: '缓存读取费用', value: formatCost(d.cache_read_cost) })
const rm = d.rate_multiplier
rows.push({
label: '费率倍率',
value: rm && rm !== 1 ? `${rm.toFixed(4)}x` : '-',
valueClass: rm && rm !== 1 ? (rm < 1 ? 'text-success' : 'text-warning') : undefined,
})
rows.push({ label: '基础费用', value: formatCost(d.total_cost || 0) })
rows.push({ label: '定价来源', value: billingSourceLabel[d.billing_source] || d.billing_source || '-' })
if ((d.pre_deduct_amount || 0) > 0 || (d.refund_amount || 0) > 0 || (d.supplement_amount || 0) > 0) {
rows.push({ divider: true })
rows.push({ label: '预扣金额', value: formatCost(d.pre_deduct_amount) })
if ((d.refund_amount || 0) > 0) rows.push({ label: '退回金额', value: formatCost(d.refund_amount), valueClass: 'text-success' })
if ((d.supplement_amount || 0) > 0) rows.push({ label: '补扣金额', value: formatCost(d.supplement_amount), valueClass: 'text-warning' })
}
rows.push({ divider: true })
rows.push({ label: '实际费用', value: formatCost(d.actual_cost || 0), total: true })
return rows
})
function copyText(text: string) {
navigator.clipboard.writeText(text).then(() => {
Message.success('已复制')
@@ -224,32 +317,11 @@ function viewAuditLog(requestId: string, taskId?: string) {
router.push({ name: 'AdminRequestAuditLogs', query })
}
// 转发路径追踪(从审计日志按 request_id 懒加载,用量日志表不存 trace)
const forwardingTrace = ref<any>(null)
const forwardingTraceLoading = ref(false)
const forwardingTraceFound = ref(false)
async function fetchForwardingTrace(requestId: string) {
forwardingTrace.value = null
forwardingTraceFound.value = false
if (!requestId) return
forwardingTraceLoading.value = true
try {
const res: any = await request.get(`/admin/audit/forwarding-trace/${encodeURIComponent(requestId)}`)
const d = res.data?.data
forwardingTraceFound.value = !!d?.found
forwardingTrace.value = d?.forwarding_trace || null
} catch {
/* 静默:trace 为辅助信息,失败不打扰用户 */
} finally {
forwardingTraceLoading.value = false
}
}
function openDetail(record: any) {
detailLog.value = record
// 每次打开重置为折叠,避免上一条记录的快照展开状态带入
snapshotCollapsed.value = true
detailVisible.value = true
fetchForwardingTrace(record.request_id)
}
function tooltipRow(label: string, value: string, valueClass = 'dark-tooltip-value') {
@@ -260,7 +332,12 @@ function tooltipRow(label: string, value: string, valueClass = 'dark-tooltip-val
}
const columns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 100 },
{
title: '时间', dataIndex: 'created_at', width: 160,
render({ record }) {
return h('span', { style: 'white-space: nowrap' }, formatTime(record.created_at))
},
},
{
title: '租户', dataIndex: 'tenant_name', width: 120, ellipsis: true,
render({ record }) { return record.tenant_name || record.tenant_id },
@@ -338,14 +415,15 @@ const columns: TableColumnData[] = [
h('span', { class: 'token-symbol', 'aria-hidden': 'true' }, '↓'),
h('span', { class: 'token-value' }, (record.output_tokens || 0).toLocaleString()),
]),
h('span', { class: 'token-item', style: 'color: #0fc6c2', 'aria-label': `缓存读取 ${(record.cache_read_tokens || 0).toLocaleString()}` }, [
h('span', { class: 'token-symbol', 'aria-hidden': 'true' }, '⚡'),
h('span', { class: 'token-value' }, (record.cache_read_tokens || 0).toLocaleString()),
]),
h('span', { class: 'token-item', style: 'color: #ff7d00', 'aria-label': `缓存写入 ${(record.cache_creation_tokens || 0).toLocaleString()}` }, [
h(IconSave, { size: 13, 'aria-hidden': 'true' }),
h('span', { class: 'token-value' }, (record.cache_creation_tokens || 0).toLocaleString()),
]),
h('span', { class: 'token-item', style: 'color: #0fc6c2', 'aria-label': `缓存读取 ${(record.cache_read_tokens || 0).toLocaleString()}` }, [
h('span', { class: 'token-symbol', 'aria-hidden': 'true' }, '⚡'),
h('span', { class: 'token-value' }, (record.cache_read_tokens || 0).toLocaleString()),
]),
]),
]),
content: () => h('div', { class: 'dark-tooltip' }, tooltipContent),
@@ -391,16 +469,16 @@ const columns: TableColumnData[] = [
title: '延迟', dataIndex: 'latency_ms', width: 100,
render({ record }) {
const items: any[] = [
h('div', { class: 'time-text' }, formatMs(record.latency_ms)),
h('div', { class: 'time-text', style: { color: latencyColor(record.latency_ms) } }, formatMs(record.latency_ms)),
]
if (record.first_token_ms > 0) {
items.push(h('div', { class: 'sub-text', style: 'font-size: 11px' }, `TTFT ${formatMs(record.first_token_ms)}`))
items.push(h('div', { class: 'sub-text', style: { fontSize: '11px', color: ttftColor(record.first_token_ms) } }, `TTFT ${formatMs(record.first_token_ms)}`))
}
return h('div', { style: 'line-height: 1.4' }, items)
},
},
{
title: '状态', dataIndex: 'status', width: 80,
title: '状态', dataIndex: 'status', width: 130,
render({ record }) {
const items: any[] = [
h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusLabel[record.status] || record.status),
@@ -411,12 +489,6 @@ const columns: TableColumnData[] = [
return h(Space, { size: 4 }, () => items)
},
},
{
title: '时间', dataIndex: 'created_at', width: 160,
render({ record }) {
return h('span', { class: 'time-text' }, formatTime(record.created_at))
},
},
{
title: '操作', dataIndex: 'actions', width: 80, fixed: 'right',
render({ record }) {
@@ -715,18 +787,47 @@ const { exporting, exportFile } = useExport({
<a-modal
v-model:visible="detailVisible"
title="用量详情"
:width="720"
:width="isMobileView ? '92%' : 720"
:footer="false"
:body-style="{ maxHeight: '65vh', overflowY: 'auto' }"
unmount-on-close
>
<template v-if="detailLog">
<!-- 关键结果摘要:模型 / 状态 / 费用 / 耗时一眼可读 -->
<div class="detail-summary">
<div class="detail-summary-main">
<span class="summary-model">{{ detailLog.model_name }}<span v-if="hasUpstreamModel(detailLog)" class="summary-upstream"> ↳ {{ detailLog.upstream_model }}</span></span>
<a-tag :color="statusTagColor[detailLog.status]" size="small">{{ statusLabel[detailLog.status] || detailLog.status }}</a-tag>
<span v-if="detailLog.retry_index > 0" class="retry-badge">重试 {{ detailLog.retry_index }} 次</span>
<span class="summary-cost">{{ formatCost(detailLog.actual_cost || 0) }}</span>
</div>
<div class="detail-summary-sub">
<span :style="{ color: latencyColor(detailLog.latency_ms) }">总延迟 {{ formatMs(detailLog.latency_ms) }}</span>
<template v-if="detailLog.first_token_ms > 0">
<span class="summary-dot">·</span>
<span :style="{ color: ttftColor(detailLog.first_token_ms) }">首 Token {{ formatMs(detailLog.first_token_ms) }}</span>
</template>
<span class="summary-dot">·</span>
<span>{{ formatTime(detailLog.created_at) }}</span>
<a-tag v-if="detailLog.stream_end_reason" :color="detailLog.stream_end_reason === 'done' ? 'green' : 'gray'" size="small">{{ detailLog.stream_end_reason }}</a-tag>
</div>
</div>
<div v-if="detailLog.error_message" class="detail-section">
<div class="detail-section-title error-title">
<span class="detail-icon detail-icon-error"><IconExclamationCircle /></span>
错误信息
</div>
<div class="error-message">{{ detailLog.error_message }}</div>
</div>
<div class="detail-section">
<div class="detail-section-title">
<span class="detail-icon detail-icon-doc"></span>
<span class="detail-icon"><IconFile /></span>
基本信息
</div>
<div class="detail-grid">
<div class="detail-item">
<div class="detail-item detail-item-full">
<span class="detail-label">请求 ID</span>
<span class="detail-value mono-text">
{{ detailLog.request_id }}
@@ -734,7 +835,7 @@ const { exporting, exportFile } = useExport({
<a-link class="copy-btn" @click="viewAuditLog(detailLog.request_id, detailLog.task_id || undefined)">查看审计日志</a-link>
</span>
</div>
<div v-if="detailLog.task_id" class="detail-item">
<div v-if="detailLog.task_id" class="detail-item detail-item-full">
<span class="detail-label">关联任务</span>
<span class="detail-value mono-text">
{{ detailLog.task_id }}
@@ -748,9 +849,9 @@ const { exporting, exportFile } = useExport({
<span v-if="detailLog.channel_type" class="sub-text">({{ detailLog.channel_type }})</span>
</span>
</div>
<div class="detail-item">
<div v-if="detailLog.relay_mode" class="detail-item">
<span class="detail-label">代理模式</span>
<span class="detail-value mono-text">{{ detailLog.relay_mode || '-' }}</span>
<span class="detail-value mono-text">{{ detailLog.relay_mode }}</span>
</div>
<div class="detail-item">
<span class="detail-label">请求类型</span>
@@ -769,18 +870,11 @@ const { exporting, exportFile } = useExport({
</span>
</div>
<div class="detail-item">
<span class="detail-label">状态</span>
<span class="detail-value">
<a-tag :color="statusTagColor[detailLog.status]" size="small">
{{ statusLabel[detailLog.status] || detailLog.status }}
</a-tag>
<span v-if="detailLog.retry_index > 0" class="retry-badge">重试 {{ detailLog.retry_index }} 次</span>
<span class="detail-label">API Key</span>
<span class="detail-value mono-text">
{{ detailLog.api_key_name || detailLog.api_key_id || '-' }}<span v-if="detailLog.api_key_name && detailLog.api_key_id" class="sub-text">({{ detailLog.api_key_id }})</span>
</span>
</div>
<div class="detail-item">
<span class="detail-label">API Key ID</span>
<span class="detail-value mono-text">{{ detailLog.api_key_id || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">客户端 IP</span>
<span class="detail-value mono-text">{{ detailLog.client_ip || '-' }}</span>
@@ -799,228 +893,69 @@ const { exporting, exportFile } = useExport({
</div>
<div v-if="detailLog.user_agent" class="detail-item detail-item-full">
<span class="detail-label">User-Agent</span>
<span class="detail-value sub-text" :title="detailLog.user_agent">{{ detailLog.user_agent }}</span>
</div>
<div class="detail-item">
<span class="detail-label">时间</span>
<span class="detail-value">{{ formatTime(detailLog.created_at) }}</span>
</div>
</div>
</div>
<div v-if="hasUpstreamModel(detailLog)" class="detail-section">
<div class="detail-section-title">
<span class="detail-icon detail-icon-arrow"></span>
模型映射
</div>
<div class="model-mapping">
<a-tag>{{ detailLog.model_name }}</a-tag>
<span class="mapping-arrow">&rarr;</span>
<a-tag color="arcoblue">{{ detailLog.upstream_model }}</a-tag>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">
<span class="detail-icon detail-icon-chart"></span>
Token 使用量
</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label"><span class="token-dot token-dot-in"></span> 输入 Token</span>
<span class="detail-value">{{ (detailLog.input_tokens || 0).toLocaleString() }}</span>
</div>
<div class="detail-item">
<span class="detail-label"><span class="token-dot token-dot-out"></span> 输出 Token</span>
<span class="detail-value">{{ (detailLog.output_tokens || 0).toLocaleString() }}</span>
</div>
<div class="detail-item">
<span class="detail-label"><span class="token-dot token-dot-cache-read"></span> 缓存读取</span>
<span class="detail-value token-cache-read">{{ (detailLog.cache_read_tokens || 0).toLocaleString() }}</span>
</div>
<div class="detail-item">
<span class="detail-label"><span class="token-dot token-dot-cache-create"></span> 缓存创建</span>
<span class="detail-value token-cache-create">{{ (detailLog.cache_creation_tokens || 0).toLocaleString() }}</span>
</div>
<div class="detail-item">
<span class="detail-label"><span class="token-dot token-dot-cache-5m"></span> 缓存创建(5分钟)</span>
<span class="detail-value token-cache-5m">{{ (detailLog.cache_creation_5m_tokens || 0).toLocaleString() }}</span>
</div>
<div class="detail-item">
<span class="detail-label"><span class="token-dot token-dot-cache-1h"></span> 缓存创建(1小时)</span>
<span class="detail-value token-cache-1h">{{ (detailLog.cache_creation_1h_tokens || 0).toLocaleString() }}</span>
</div>
<div v-if="detailLog.reasoning_tokens > 0" class="detail-item">
<span class="detail-label"><span class="token-dot token-dot-reasoning"></span> 推理 Token</span>
<span class="detail-value token-reasoning">{{ detailLog.reasoning_tokens.toLocaleString() }}</span>
</div>
<div v-if="detailLog.audio_input_tokens > 0" class="detail-item">
<span class="detail-label"><span class="token-dot token-dot-audio"></span> 音频输入</span>
<span class="detail-value token-audio">{{ detailLog.audio_input_tokens.toLocaleString() }}</span>
</div>
<div v-if="detailLog.audio_output_tokens > 0" class="detail-item">
<span class="detail-label"><span class="token-dot token-dot-audio"></span> 音频输出</span>
<span class="detail-value token-audio">{{ detailLog.audio_output_tokens.toLocaleString() }}</span>
</div>
<div v-if="detailLog.image_output_tokens > 0" class="detail-item">
<span class="detail-label"><span class="token-dot token-dot-image"></span> 图像输出</span>
<span class="detail-value token-image">{{ detailLog.image_output_tokens.toLocaleString() }}</span>
</div>
<div v-if="detailLog.image_count > 0" class="detail-item">
<span class="detail-label">生成图片</span>
<span class="detail-value">
{{ detailLog.image_count }} 张
<span v-if="detailLog.image_size" class="sub-text">({{ detailLog.image_size }})</span>
</span>
<span class="detail-value sub-text detail-ellipsis" :title="detailLog.user_agent">{{ detailLog.user_agent }}</span>
</div>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">
<span class="detail-icon detail-icon-credit"></span>
费用明细
<span class="detail-icon"><IconBarChart /></span>
消费明细
</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">输入费用</span>
<span class="detail-value">{{ formatCost(detailLog.input_cost || 0) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">输出费用</span>
<span class="detail-value">{{ formatCost(detailLog.output_cost || 0) }}</span>
</div>
<div v-if="detailLog.cache_creation_cost > 0" class="detail-item">
<span class="detail-label">缓存创建费用</span>
<span class="detail-value">{{ formatCost(detailLog.cache_creation_cost) }}</span>
</div>
<div v-if="detailLog.cache_read_cost > 0" class="detail-item">
<span class="detail-label">缓存读取费用</span>
<span class="detail-value">{{ formatCost(detailLog.cache_read_cost) }}</span>
</div>
</div>
<div class="detail-summary">
<div class="detail-item">
<span class="detail-label">费率倍率</span>
<span
v-if="detailLog.rate_multiplier && detailLog.rate_multiplier !== 1"
class="detail-value"
:class="detailLog.rate_multiplier < 1 ? 'text-success' : 'text-warning'"
>
{{ detailLog.rate_multiplier.toFixed(4) }}x
</span>
<span v-else class="detail-value">-</span>
</div>
<div class="detail-item">
<span class="detail-label">基础费用</span>
<span class="detail-value">{{ formatCost(detailLog.total_cost || 0) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">实际费用</span>
<span class="detail-value text-success">{{ formatCost(detailLog.actual_cost || 0) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">定价来源</span>
<span class="detail-value">{{ billingSourceLabel[detailLog.billing_source] || detailLog.billing_source || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">货币</span>
<span class="detail-value">{{ detailLog.currency || 'USD' }}</span>
<div class="receipt">
<div class="receipt-head">
<span class="receipt-brand">用量 · 费用 · 结算</span>
<span class="receipt-meta">{{ displayCurrency }}</span>
</div>
<!-- 小票行由 receiptRows 数据驱动生成 -->
<template v-for="(row, index) in receiptRows" :key="index">
<div v-if="row.divider" class="receipt-divider"></div>
<div v-else class="receipt-row" :class="[row.strong ? 'receipt-strong' : '', row.total ? 'receipt-total' : '']">
<span class="receipt-label">{{ row.label }}</span>
<span class="receipt-value" :class="row.valueClass">
{{ row.value }}<span v-if="row.sub" class="receipt-sub">{{ row.sub }}</span>
</span>
</div>
</template>
</div>
</div>
<div v-if="detailLog.pre_deduct_amount > 0 || detailLog.refund_amount > 0 || detailLog.supplement_amount > 0" class="detail-section">
<div v-if="detailLog.billing_summary || snapshot" class="detail-section">
<div class="detail-section-title">
<span class="detail-icon detail-icon-refresh"></span>
结算明细
</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">预扣金额</span>
<span class="detail-value">{{ formatCost(detailLog.pre_deduct_amount) }}</span>
</div>
<div v-if="detailLog.refund_amount > 0" class="detail-item">
<span class="detail-label">退回金额</span>
<span class="detail-value text-success">{{ formatCost(detailLog.refund_amount) }}</span>
</div>
<div v-if="detailLog.supplement_amount > 0" class="detail-item">
<span class="detail-label">补扣金额</span>
<span class="detail-value text-warning">{{ formatCost(detailLog.supplement_amount) }}</span>
</div>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">
<span class="detail-icon detail-icon-clock"></span>
性能指标
</div>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">总延迟</span>
<span class="detail-value">{{ formatMs(detailLog.latency_ms) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">首 Token 延迟</span>
<span class="detail-value">{{ formatMs(detailLog.first_token_ms) }}</span>
</div>
<div v-if="detailLog.stream_end_reason" class="detail-item">
<span class="detail-label">流结束原因</span>
<span class="detail-value">
<a-tag
:color="detailLog.stream_end_reason === 'done' ? 'green' : 'gray'"
size="medium"
>
{{ detailLog.stream_end_reason }}
</a-tag>
</span>
</div>
</div>
</div>
<div v-if="detailLog.billing_summary || parseSnapshot(detailLog)" class="detail-section">
<div class="detail-section-title">
<span class="detail-icon detail-icon-clipboard"></span>
<span class="detail-icon"><IconCamera /></span>
计费快照
<a-link class="snapshot-toggle" @click="snapshotCollapsed = !snapshotCollapsed">{{ snapshotCollapsed ? '展开' : '收起' }}</a-link>
</div>
<template v-if="parseSnapshot(detailLog)">
<div v-if="snapshot && !snapshotCollapsed">
<div class="snapshot-grid">
<div class="snapshot-block">
<div class="snapshot-block-title">定价信息</div>
<div class="snapshot-block-body">
<div v-if="parseSnapshot(detailLog).pricing.billing_source" class="snapshot-row">
<span class="snapshot-label">价格来源</span>
<span class="snapshot-value">{{ billingSourceLabel[parseSnapshot(detailLog).pricing.billing_source] || parseSnapshot(detailLog).pricing.billing_source }}</span>
</div>
<div v-if="parseSnapshot(detailLog).pricing.billing_mode" class="snapshot-row">
<span class="snapshot-label">计费模式</span>
<span class="snapshot-value">{{ billingModeLabel[parseSnapshot(detailLog).pricing.billing_mode] || parseSnapshot(detailLog).pricing.billing_mode }}</span>
</div>
<div class="snapshot-row">
<span class="snapshot-label">基础输入价</span>
<span class="snapshot-value">${{ (parseSnapshot(detailLog).pricing.base_input_price || 0).toFixed(6) }}/1M</span>
<span class="snapshot-value">{{ formatBilling(snapshot.pricing.base_input_price || 0, 6) }}/1M</span>
</div>
<div class="snapshot-row">
<span class="snapshot-label">基础输出价</span>
<span class="snapshot-value">${{ (parseSnapshot(detailLog).pricing.base_output_price || 0).toFixed(6) }}/1M</span>
<span class="snapshot-value">{{ formatBilling(snapshot.pricing.base_output_price || 0, 6) }}/1M</span>
</div>
<div v-if="parseSnapshot(detailLog).pricing.effective_input_price !== parseSnapshot(detailLog).pricing.base_input_price" class="snapshot-row">
<div v-if="snapshot.pricing.effective_input_price !== snapshot.pricing.base_input_price" class="snapshot-row">
<span class="snapshot-label">实际输入价</span>
<span class="snapshot-value text-success">${{ (parseSnapshot(detailLog).pricing.effective_input_price || 0).toFixed(6) }}/1M</span>
<span class="snapshot-value text-success">{{ formatBilling(snapshot.pricing.effective_input_price || 0, 6) }}/1M</span>
</div>
<div v-if="parseSnapshot(detailLog).pricing.effective_output_price !== parseSnapshot(detailLog).pricing.base_output_price" class="snapshot-row">
<div v-if="snapshot.pricing.effective_output_price !== snapshot.pricing.base_output_price" class="snapshot-row">
<span class="snapshot-label">实际输出价</span>
<span class="snapshot-value text-success">${{ (parseSnapshot(detailLog).pricing.effective_output_price || 0).toFixed(6) }}/1M</span>
<span class="snapshot-value text-success">{{ formatBilling(snapshot.pricing.effective_output_price || 0, 6) }}/1M</span>
</div>
<div v-if="parseSnapshot(detailLog).cache_prices && parseSnapshot(detailLog).cache_prices.cache_creation_price > 0" class="snapshot-row">
<div v-if="snapshot.cache_prices && snapshot.cache_prices.cache_creation_price > 0" class="snapshot-row">
<span class="snapshot-label">缓存创建单价</span>
<span class="snapshot-value">${{ (parseSnapshot(detailLog).cache_prices.cache_creation_price || 0).toFixed(6) }}/1M</span>
<span class="snapshot-value">{{ formatBilling(snapshot.cache_prices.cache_creation_price || 0, 6) }}/1M</span>
</div>
<div v-if="parseSnapshot(detailLog).cache_prices && parseSnapshot(detailLog).cache_prices.cache_read_price > 0" class="snapshot-row">
<div v-if="snapshot.cache_prices && snapshot.cache_prices.cache_read_price > 0" class="snapshot-row">
<span class="snapshot-label">缓存读取单价</span>
<span class="snapshot-value">${{ (parseSnapshot(detailLog).cache_prices.cache_read_price || 0).toFixed(6) }}/1M</span>
<span class="snapshot-value">{{ formatBilling(snapshot.cache_prices.cache_read_price || 0, 6) }}/1M</span>
</div>
</div>
</div>
@@ -1028,107 +963,46 @@ const { exporting, exportFile } = useExport({
<div class="snapshot-block">
<div class="snapshot-block-title">倍率信息</div>
<div class="snapshot-block-body">
<div v-if="parseSnapshot(detailLog).multipliers.model_multiplier && parseSnapshot(detailLog).multipliers.model_multiplier !== 1" class="snapshot-row">
<div v-if="snapshot.multipliers.model_multiplier && snapshot.multipliers.model_multiplier !== 1" class="snapshot-row">
<span class="snapshot-label">模型倍率</span>
<span class="snapshot-value">{{ (parseSnapshot(detailLog).multipliers.model_multiplier).toFixed(4) }}x</span>
<span class="snapshot-value">{{ (snapshot.multipliers.model_multiplier).toFixed(4) }}x</span>
</div>
<div class="snapshot-row">
<span class="snapshot-label">租户倍率</span>
<span class="snapshot-value" :class="(parseSnapshot(detailLog).multipliers.tenant_multiplier || 1) < 1 ? 'text-success' : ''">
{{ (parseSnapshot(detailLog).multipliers.tenant_multiplier || 1).toFixed(4) }}x
<span class="snapshot-value" :class="(snapshot.multipliers.tenant_multiplier || 1) < 1 ? 'text-success' : ''">
{{ (snapshot.multipliers.tenant_multiplier || 1).toFixed(4) }}x
</span>
</div>
<div v-if="parseSnapshot(detailLog).multipliers.discount_ratio && parseSnapshot(detailLog).multipliers.discount_ratio !== 1" class="snapshot-row">
<div v-if="snapshot.multipliers.discount_ratio && snapshot.multipliers.discount_ratio !== 1" class="snapshot-row">
<span class="snapshot-label">折扣比例</span>
<span class="snapshot-value text-success">{{ (parseSnapshot(detailLog).multipliers.discount_ratio).toFixed(4) }}x</span>
<span class="snapshot-value text-success">{{ (snapshot.multipliers.discount_ratio).toFixed(4) }}x</span>
</div>
</div>
</div>
<div v-if="parseSnapshot(detailLog).token_costs" class="snapshot-block snapshot-block-full">
<div v-if="snapshot.token_costs" class="snapshot-block snapshot-block-full">
<div class="snapshot-block-title">Token 费用计算</div>
<div class="snapshot-block-body">
<template v-for="(tc, key) in parseSnapshot(detailLog).token_costs" :key="key">
<template v-for="(tc, key) in snapshot.token_costs" :key="key">
<div v-if="(tc.tokens || 0) > 0" class="snapshot-row">
<span class="snapshot-label">{{ { input: '输入', output: '输出', cache_read: '缓存读取', cache_creation: '缓存创建', cache_creation_5m: '缓存创建(5分钟)', cache_creation_1h: '缓存创建(1小时)' }[key] || key }}</span>
<span class="snapshot-label">{{ tokenCostLabels[key] || key }}</span>
<span class="snapshot-value">
{{ (tc.tokens || 0).toLocaleString() }} tokens &times; ${{ (tc.unit_price || 0).toFixed(6) }}/1M = <strong>${{ (tc.cost || 0).toFixed(6) }}</strong>
{{ (tc.tokens || 0).toLocaleString() }} tokens &times; {{ formatBilling(tc.unit_price || 0, 6) }}/1M = <strong>{{ formatBilling(tc.cost || 0, 6) }}</strong>
</span>
</div>
</template>
</div>
</div>
<div v-if="parseSnapshot(detailLog).settlement" class="snapshot-block">
<div class="snapshot-block-title">结算信息</div>
<div class="snapshot-block-body">
<div class="snapshot-row">
<span class="snapshot-label">预扣金额</span>
<span class="snapshot-value">{{ formatCost(parseSnapshot(detailLog).settlement.pre_deduct_amount || 0) }}</span>
</div>
<div class="snapshot-row">
<span class="snapshot-label">实际费用</span>
<span class="snapshot-value text-success">{{ formatCost(parseSnapshot(detailLog).settlement.actual_cost || 0) }}</span>
</div>
<div v-if="parseSnapshot(detailLog).settlement.refund_amount > 0" class="snapshot-row">
<span class="snapshot-label">退还金额</span>
<span class="snapshot-value text-success">{{ formatCost(parseSnapshot(detailLog).settlement.refund_amount) }}</span>
</div>
<div v-if="parseSnapshot(detailLog).settlement.supplement_amount > 0" class="snapshot-row">
<span class="snapshot-label">补扣金额</span>
<span class="snapshot-value text-warning">{{ formatCost(parseSnapshot(detailLog).settlement.supplement_amount) }}</span>
</div>
</div>
</div>
<div v-if="parseSnapshot(detailLog).request_meta" class="snapshot-block">
<div class="snapshot-block-title">请求元信息</div>
<div class="snapshot-block-body">
<div v-if="parseSnapshot(detailLog).request_meta.requested_model" class="snapshot-row">
<span class="snapshot-label">请求模型</span>
<span class="snapshot-value mono-text">{{ parseSnapshot(detailLog).request_meta.requested_model }}</span>
</div>
<div v-if="parseSnapshot(detailLog).request_meta.upstream_model" class="snapshot-row">
<span class="snapshot-label">上游模型</span>
<span class="snapshot-value mono-text">{{ parseSnapshot(detailLog).request_meta.upstream_model }}</span>
</div>
<div class="snapshot-row">
<span class="snapshot-label">流式请求</span>
<span class="snapshot-value">{{ parseSnapshot(detailLog).request_meta.is_stream ? '是' : '否' }}</span>
</div>
<div v-if="parseSnapshot(detailLog).request_meta.first_token_ms" class="snapshot-row">
<span class="snapshot-label">首 Token</span>
<span class="snapshot-value">{{ parseSnapshot(detailLog).request_meta.first_token_ms }}ms</span>
</div>
</div>
</div>
</div>
</template>
</div>
<div v-if="detailLog.billing_summary" style="margin-top: 12px;">
<div v-if="detailLog.billing_summary && !snapshotCollapsed" style="margin-top: 12px;">
<div class="snapshot-text-title">计算过程</div>
<pre class="billing-snapshot">{{ detailLog.billing_summary }}</pre>
</div>
</div>
<div v-if="detailLog.error_message" class="detail-section">
<div class="detail-section-title error-title">
<span class="detail-icon detail-icon-error"></span>
错误信息
</div>
<div class="error-message">{{ detailLog.error_message }}</div>
</div>
<div class="detail-section">
<div class="detail-section-title">
转发路径追踪
</div>
<a-spin :loading="forwardingTraceLoading" class="w-full">
<ForwardingTracePanel v-if="forwardingTrace" :trace="forwardingTrace" />
<a-empty v-else-if="!forwardingTraceLoading && !forwardingTraceFound" description="该请求未开启审计,无转发追踪数据" />
<a-empty v-else-if="!forwardingTraceLoading && forwardingTraceFound" description="审计已记录,但未捕获转发追踪" />
</a-spin>
</div>
</template>
</a-modal>
</div>
@@ -1167,6 +1041,8 @@ const { exporting, exportFile } = useExport({
.token-item > svg,
.token-symbol {
flex-shrink: 0;
width: 16px;
text-align: center;
}
.token-value {
min-width: 0;
@@ -1215,7 +1091,6 @@ const { exporting, exportFile } = useExport({
}
.cost-value {
font-weight: 600;
color: #00b42a;
font-size: 13px;
}
.dark-tooltip {
@@ -1300,9 +1175,6 @@ const { exporting, exportFile } = useExport({
.detail-section {
margin-top: 16px;
}
.detail-section:first-child {
margin-top: 0;
}
.detail-section-title {
font-size: 13px;
font-weight: 600;
@@ -1314,48 +1186,19 @@ const { exporting, exportFile } = useExport({
align-items: center;
gap: 6px;
}
.snapshot-toggle {
margin-left: auto;
font-size: 12px;
}
.detail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.detail-icon-doc {
content: '';
width: 14px;
height: 14px;
border: 1.5px solid #165dff;
border-radius: 2px;
}
.detail-icon-arrow {
color: #165dff;
font-size: 12px;
font-weight: 700;
}
.detail-icon-chart {
color: #165dff;
font-size: 14px;
}
.detail-icon-credit {
color: #165dff;
font-size: 14px;
}
.detail-icon-refresh {
color: #165dff;
font-size: 13px;
}
.detail-icon-clock {
color: #165dff;
font-size: 14px;
}
.detail-icon-clipboard {
color: #165dff;
font-size: 13px;
}
.detail-icon-error {
color: #f53f3f;
font-size: 14px;
}
.detail-grid {
display: grid;
@@ -1385,25 +1228,135 @@ const { exporting, exportFile } = useExport({
text-align: right;
word-break: break-all;
}
.detail-summary {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid #e5e6eb;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 24px;
/* 消费明细小票风格 */
.receipt {
margin-top: 8px;
padding: 12px 14px;
background: #fafafa;
border: 1px dashed #d9dce0;
border-radius: 8px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12.5px;
}
.model-mapping {
.receipt-head {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px dashed #d9dce0;
margin-bottom: 4px;
}
.receipt-brand {
font-weight: 600;
color: #1d2129;
letter-spacing: 0.5px;
}
.receipt-meta {
color: #86909c;
font-size: 11px;
}
.receipt-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 3.5px 0;
line-height: 1.5;
}
.receipt-label {
color: #86909c;
flex-shrink: 0;
}
.receipt-value {
font-weight: 600;
color: #1d2129;
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.receipt-divider {
margin: 6px 0;
border-top: 1px dashed #d9dce0;
}
.receipt-total {
margin-top: 2px;
font-size: 14px;
}
.receipt-total .receipt-label {
color: #1d2129;
font-weight: 600;
}
.receipt-total .receipt-value {
font-size: 15px;
}
/* 小票小计行(Token 合计) */
.receipt-strong .receipt-label {
color: #1d2129;
font-weight: 600;
}
/* 小票值的灰色补充说明(如图片尺寸) */
.receipt-sub {
margin-left: 4px;
color: #86909c;
font-weight: 400;
font-size: 11px;
}
/* 关键结果摘要条 */
.detail-summary {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
background: #f7f8fa;
border-radius: 8px;
}
.detail-summary-main {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f7f8fa;
border-radius: 6px;
min-width: 0;
}
.mapping-arrow {
color: #86909c;
.summary-model {
font-size: 14px;
font-weight: 600;
color: #1d2129;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-upstream {
font-size: 12px;
font-weight: 400;
color: #86909c;
}
.summary-cost {
margin-left: auto;
flex-shrink: 0;
font-size: 16px;
font-weight: 700;
color: #1d2129;
font-variant-numeric: tabular-nums;
}
.detail-summary-sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 8px;
font-size: 12px;
color: #4e5969;
}
.summary-dot {
color: #c9cdd4;
}
.detail-summary-main .retry-badge {
flex-shrink: 0;
}
/* 超长值单行省略(配合 title 提示) */
.detail-ellipsis {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono-text {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
@@ -1475,10 +1428,8 @@ const { exporting, exportFile } = useExport({
margin: 0;
line-height: 1.6;
}
.error-title {
.detail-section-title.error-title {
border-left-color: #f53f3f;
}
.error-title .detail-section-title {
color: #f53f3f;
}
.error-message {
@@ -3,6 +3,7 @@ import { ref, reactive, watch, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import request from '@/utils/request'
import { useIsMobile } from '@/composables/useIsMobile'
import { formatBilling } from '@/composables/useCurrency'
const props = defineProps<{
tenantId: string
@@ -105,7 +106,7 @@ onMounted(fetchLevelOptions)
<ATag v-if="detail.max_members != null" size="small" color="arcoblue" style="margin-left: 4px">自定义</ATag>
</ADescriptionsItem>
<ADescriptionsItem label="钱包余额">
<span class="money">${{ parseFloat(detail.wallet_balance || '0').toFixed(2) }}</span>
<span class="money">{{ formatBilling(detail.wallet_balance, 2) }}</span>
</ADescriptionsItem>
<ADescriptionsItem label="并发上限">
{{ getEffectiveMaxConcurrency() ?? '不限' }}
@@ -5,6 +5,10 @@ import type { TableColumnData } from '@arco-design/web-vue'
import ResponsiveTable from '@/components/ResponsiveTable.vue'
import TableStats from '@/components/TableStats.vue'
import request from '@/utils/request'
import { displayCurrency } from '@/composables/useCurrency'
// 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
const props = defineProps<{
tenantId: string
@@ -488,22 +492,22 @@ defineExpose({ openPreviewModal })
<div class="grid grid-cols-2 gap-x-3">
<AFormItem label="输入价格">
<AInputNumber v-model="editModelForm.custom_input_price" :min="0" :precision="4" placeholder="默认" class="w-full">
<template #suffix>$ / 1M</template>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格">
<AInputNumber v-model="editModelForm.custom_output_price" :min="0" :precision="4" placeholder="默认" class="w-full">
<template #suffix>$ / 1M</template>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存读取价格">
<AInputNumber v-model="editModelForm.custom_cache_read_price" :min="0" :precision="4" placeholder="默认" class="w-full">
<template #suffix>$ / 1M</template>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建价格">
<AInputNumber v-model="editModelForm.custom_cache_creation_price" :min="0" :precision="4" placeholder="默认" class="w-full">
<template #suffix>$ / 1M</template>
<template #suffix>{{ currencySymbol }} / 1M</template>
</AInputNumber>
</AFormItem>
</div>
@@ -514,7 +518,7 @@ defineExpose({ openPreviewModal })
<ADivider margin="8px">按次定价</ADivider>
<AFormItem label="按次单价">
<AInputNumber v-model="editModelForm.per_request_price" :min="0" :precision="4" placeholder="每次调用价格" class="w-full">
<template #suffix>$ / 次</template>
<template #suffix>{{ currencySymbol }} / 次</template>
</AInputNumber>
</AFormItem>
</template>
@@ -540,24 +544,24 @@ defineExpose({ openPreviewModal })
<div class="grid grid-cols-2 gap-x-3 mt-2">
<AFormItem label="输入价格">
<AInputNumber v-model="tier.input_price" :min="0" :precision="4" class="w-full">
<template #suffix>$/1M</template>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格">
<AInputNumber v-model="tier.output_price" :min="0" :precision="4" class="w-full">
<template #suffix>$/1M</template>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
</div>
<div class="grid grid-cols-2 gap-x-3 mt-2">
<AFormItem label="缓存读取价格">
<AInputNumber v-model="tier.cache_read_price" :min="0" :precision="4" class="w-full">
<template #suffix>$/1M</template>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建价格">
<AInputNumber v-model="tier.cache_creation_price" :min="0" :precision="4" class="w-full">
<template #suffix>$/1M</template>
<template #suffix>{{ currencySymbol }}/1M</template>
</AInputNumber>
</AFormItem>
</div>
@@ -6,6 +6,7 @@ import ResponsiveTable from '@/components/ResponsiveTable.vue'
import TableStats from '@/components/TableStats.vue'
import request from '@/utils/request'
import { useIsMobile } from '@/composables/useIsMobile'
import { formatBilling } from '@/composables/useCurrency'
const props = defineProps<{
tenantId: string
@@ -63,13 +64,13 @@ const txColumns: TableColumnData[] = [
render({ record }) {
const val = parseFloat(record.amount) || 0
const color = val >= 0 ? 'rgb(var(--green-6))' : 'rgb(var(--red-6))'
const prefix = val >= 0 ? '+' : ''
return h('span', { style: { fontWeight: 600, color } }, `${prefix}${val.toFixed(6)}`)
// bil 层本位币金额:showSign 自动带 +/- 符号
return h('span', { style: { fontWeight: 600, color } }, formatBilling(val, 6, true))
},
},
{
title: '变动后余额', dataIndex: 'balance_after', width: 140,
render({ record }) { return `$${(parseFloat(record.balance_after) || 0).toFixed(6)}` },
render({ record }) { return formatBilling(parseFloat(record.balance_after) || 0, 6) },
},
{
title: '用户', dataIndex: 'username', width: 120,
+35 -18
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, reactive, watch, onMounted, h } from 'vue'
import { ref, reactive, computed, watch, onMounted, h } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import ResponsiveTable from '@/components/ResponsiveTable.vue'
import request from '@/utils/request'
import { hasPermission } from '@/utils/permission'
import { useIsMobile } from '@/composables/useIsMobile'
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
const props = defineProps<{
tenantId: string
@@ -18,6 +19,21 @@ const emit = defineEmits<{
const isMobile = useIsMobile()
// 本位币符号:bil 层表单(调整余额/预警阈值)输入前缀跟随本位币,输入值仍为存储原值不折算
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
// 线下充值入账弹窗文案:收款恒为人民币(银行转账),到账按本位币分支说明
const offlineRechargeIntro = computed(() =>
displayCurrency.value === 'CNY'
? '用于线下银行转账入账:输入人民币金额直接入账钱包(本位币为人民币,无需换算),并累计到累计充值(影响租户等级)。'
: '用于线下银行转账入账:输入人民币金额,到账按平台汇率换算为美元,并累计到累计充值(影响租户等级)。',
)
const offlineRechargeNote = computed(() =>
displayCurrency.value === 'CNY'
? '到账金额 = 输入的人民币金额(本位币直接入账)'
: '到账金额 = 人民币金额 × 平台汇率,精确到 6 位小数',
)
// === 钱包信息 ===
const walletInfo = ref<any>(null)
const walletLoading = ref(false)
@@ -63,7 +79,7 @@ const frozenColumns: TableColumnData[] = [
{
title: '冻结金额', dataIndex: 'amount', width: 130,
render({ record }) {
return h('span', { style: { color: 'rgb(var(--orange-6))', fontWeight: 600 } }, `$${(parseFloat(record.amount) || 0).toFixed(6)}`)
return h('span', { style: { color: 'rgb(var(--orange-6))', fontWeight: 600 } }, formatBilling(parseFloat(record.amount) || 0, 6))
},
},
{ title: '冻结时间', dataIndex: 'created_at', width: 280 },
@@ -102,7 +118,7 @@ async function handleRelease(done: (closed: boolean) => void) {
reason: releaseForm.reason.trim(),
})
const amt = parseFloat(res.data?.data?.released_amount || 0)
Message.success(`已释放冻结 $${amt.toFixed(6)}`)
Message.success(`已释放冻结 ${formatBilling(amt, 6)}`)
done(true)
await refreshAll()
} catch {
@@ -134,7 +150,7 @@ async function handleReleaseAll(done: (closed: boolean) => void) {
const data = res.data?.data || {}
const amt = parseFloat(data.released_amount || 0)
const skipped = parseInt(data.skipped_count || 0, 10)
Message.success(`已释放 ${data.released_count || 0} 笔冻结,合计 $${amt.toFixed(6)}${skipped ? `,跳过 ${skipped} 笔` : ''}`)
Message.success(`已释放 ${data.released_count || 0} 笔冻结,合计 ${formatBilling(amt, 6)}${skipped ? `,跳过 ${skipped} 笔` : ''}`)
if (skipped) {
Message.warning(`跳过原因:${(data.skipped_reasons || []).join(';')}`)
}
@@ -193,7 +209,8 @@ async function handleOfflineRecharge(done: (closed: boolean) => void) {
description: offlineRechargeForm.description,
})
const data = res.data?.data || {}
Message.success(`入账成功,到账 $${parseFloat(data.credited_usd || 0).toFixed(2)}(汇率 ${data.rate})`)
// credited_usd 为 bil 层本位币金额,按本位币符号展示(汇率信息保留)
Message.success(`入账成功,到账 ${formatBilling(data.credited_usd, 2)}(汇率 ${data.rate})`)
done(true)
await refreshAll()
emit('refresh-detail')
@@ -253,16 +270,16 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
</template>
<ADescriptions :column="isMobile ? 1 : 3" bordered size="medium">
<ADescriptionsItem label="可用余额">
<span class="money">${{ (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) }}</span>
<span class="money">{{ formatBilling(walletInfo.balance - walletInfo.frozen_balance, 6) }}</span>
</ADescriptionsItem>
<ADescriptionsItem label="总余额">
${{ parseFloat(walletInfo.balance).toFixed(6) }}
{{ formatBilling(walletInfo.balance, 6) }}
</ADescriptionsItem>
<ADescriptionsItem label="冻结余额">
<span style="color: rgb(var(--orange-6))">${{ parseFloat(walletInfo.frozen_balance).toFixed(6) }}</span>
<span style="color: rgb(var(--orange-6))">{{ formatBilling(walletInfo.frozen_balance, 6) }}</span>
</ADescriptionsItem>
<ADescriptionsItem label="预警阈值">
{{ walletInfo.warning_threshold > 0 ? `$${parseFloat(walletInfo.warning_threshold).toFixed(6)}` : '关闭' }}
{{ walletInfo.warning_threshold > 0 ? formatBilling(walletInfo.warning_threshold, 6) : '关闭' }}
</ADescriptionsItem>
</ADescriptions>
</ACard>
@@ -338,16 +355,16 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
<!-- 调整余额弹窗 -->
<AModal v-model:visible="showRechargeModal" title="调整余额" :width="440" :on-before-ok="handleRecharge" :ok-loading="rechargeLoading">
<div class="mb-3 text-sm leading-6" style="color: var(--ta-text-secondary)">
手动调整该租户的美元钱包余额(正数增加、负数扣减),用于运营补偿、余额纠错等场景。
手动调整该租户的{{ displayCurrency === 'USD' ? '美元' : '人民币' }}钱包余额(正数增加、负数扣减),用于运营补偿、余额纠错等场景。
<br />
总余额 <span class="money">${{ walletInfo ? parseFloat(walletInfo.balance).toFixed(6) : '0.000000' }}</span>
| 可用余额 <span class="money">${{ walletInfo ? (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) : '0.000000' }}</span>
总余额 <span class="money">{{ walletInfo ? formatBilling(walletInfo.balance, 6) : formatBilling(0, 6) }}</span>
| 可用余额 <span class="money">{{ walletInfo ? formatBilling(walletInfo.balance - walletInfo.frozen_balance, 6) : formatBilling(0, 6) }}</span>
<span style="color: var(--ta-text-tertiary)">(扣减上限)</span>
</div>
<AForm :model="rechargeForm" layout="vertical">
<AFormItem label="调整金额(USD)" required>
<AFormItem :label="`调整金额(${displayCurrency})`" required>
<AInputNumber v-model="rechargeForm.amount" :precision="2" :step="1" placeholder="正数=增加余额,负数=扣减余额" class="w-full">
<template #prefix>$</template>
<template #prefix>{{ currencySymbol }}</template>
</AInputNumber>
</AFormItem>
<AFormItem label="调整说明">
@@ -362,7 +379,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
<!-- 线下充值入账弹窗 -->
<AModal v-model:visible="showOfflineRechargeModal" title="线下充值入账" :width="440" :on-before-ok="handleOfflineRecharge" :ok-loading="offlineRechargeLoading">
<div class="mb-3 text-sm" style="color: var(--ta-text-tertiary)">
用于线下银行转账入账:输入人民币金额,到账按平台汇率换算为美元,并累计到累计充值(影响租户等级)。
{{ offlineRechargeIntro }}
</div>
<AForm :model="offlineRechargeForm" layout="vertical">
<AFormItem label="入账金额(人民币)" required>
@@ -378,7 +395,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
</AFormItem>
</AForm>
<div class="mt-2 text-xs" style="color: var(--ta-text-tertiary)">
到账金额 = 人民币金额 × 平台汇率,精确到 6 位小数
{{ offlineRechargeNote }}
</div>
</AModal>
@@ -390,7 +407,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
<AForm :model="thresholdForm" layout="vertical">
<AFormItem label="预警阈值">
<AInputNumber v-model="thresholdForm.threshold" :precision="2" :min="0" placeholder="设为 0 表示不预警" class="w-full">
<template #prefix>$</template>
<template #prefix>{{ currencySymbol }}</template>
</AInputNumber>
</AFormItem>
</AForm>
@@ -424,7 +441,7 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
<div class="mb-3 text-sm leading-6" style="color: var(--ta-text-secondary)">
请求ID:<span style="font-family: monospace">{{ releaseTarget.request_id }}</span>
<br />
冻结金额:<span style="color: rgb(var(--orange-6)); font-weight: 600">${{ (parseFloat(releaseTarget.amount) || 0).toFixed(6) }}</span>
冻结金额:<span style="color: rgb(var(--orange-6)); font-weight: 600">{{ formatBilling(releaseTarget.amount, 6) }}</span>
| 已冻结 {{ formatFrozenAge(releaseTarget.age_seconds) }}
</div>
<AForm :model="releaseForm" layout="vertical">
@@ -9,6 +9,7 @@ import {
type ChartData,
type ChartOptions,
} from 'chart.js'
import { formatBilling } from '@/composables/useCurrency'
import Icon from '@/components/common/Icon.vue'
ChartJS.register(ArcElement, Tooltip, Legend)
@@ -28,10 +29,10 @@ const props = withDefaults(defineProps<{
const colors = ['#14b8a6', '#06b6d4', '#6366f1', '#f59e0b', '#ec4899', '#3b82f6', '#10b981', '#ef4444']
// 金额格式化统一走本位币;chartOptions 为 computed,tooltip 回调随 displayCurrency 变化重渲染
function formatCost(value: number): string {
if (value === 0) return '$0.00'
if (value >= 1) return `$${value.toFixed(2)}`
return `$${value.toFixed(4)}`
if (value === 0 || value >= 1) return formatBilling(value, 2)
return formatBilling(value, 4)
}
const totalCost = computed(() => props.data.reduce((sum, item) => sum + Number(item.total_cost || 0), 0))
@@ -6,6 +6,7 @@ import BaseModal from '@/components/common/BaseModal.vue'
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
import { toast } from '@/utils/toast'
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
export interface ApiKeyData {
id: number
@@ -374,7 +375,7 @@ const isFormMode = computed(() => {
style="width: 100%"
/>
</n-form-item>
<n-form-item label="总额度 (USD)" class="!mb-0">
<n-form-item :label="`总额度 (${displayCurrency})`" class="!mb-0">
<n-input-number
v-model:value="form.total_quota"
:min="0"
@@ -385,7 +386,7 @@ const isFormMode = computed(() => {
/>
</n-form-item>
</div>
<p v-if="apiKey && apiKey.used_quota" class="input-hint mb-2">已使用 ${{ apiKey.used_quota?.toFixed(4) }}</p>
<p v-if="apiKey && apiKey.used_quota" class="input-hint mb-2">已使用 {{ formatBilling(apiKey.used_quota, 4) }}</p>
<n-form-item label="IP 白名单">
<div class="w-full">
+84
View File
@@ -0,0 +1,84 @@
import { computed } from 'vue'
import { usePublicSettings } from '@/composables/usePublicSettings'
/**
* 全站货币显示(本位币制):
* - bil_ 层(钱包/计费/定价/用量/阈值)币种 = 本位币 billing_currency,直显不折算
* - ord_/pln_ 层(订单/套餐/优惠码折扣)恒 CNY;本位币=USD 时按汇率折算显示,本位币=CNY 直显
* - 本位币由系统初始化向导选定,前端只读(/settings/public 暴露)
* - 编辑表单保持存储币种原值输入,折算仅用于只读展示
*/
export type CurrencyCode = 'USD' | 'CNY'
/** 默认 CNY→USD 汇率(与后端 billing.defaultCNYToUSD 一致,配置未加载/无效时兜底) */
const FALLBACK_CNY_TO_USD = 0.14
const { settings } = usePublicSettings()
/** 全站显示货币(= 系统本位币),未加载前保守默认 USD */
export const displayCurrency = computed<CurrencyCode>(() =>
settings.value.billing_currency === 'CNY' ? 'CNY' : 'USD',
)
/** CNY→USD 汇率(管理后台支付设置配置) */
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 })
}
/**
* 充值页专用:本位币 USD 部署下,用户输入的显示货币(USD)金额换算为 CNY 下单金额。
* 本位币 CNY 时原样返回(充值锚定 CNY,零折算)。
*/
export function displayToCny(value: number): number {
if (displayCurrency.value === 'CNY') return value
return Number((value / cnyToUsd.value).toFixed(2))
}
/**
* 充值页专用:CNY 下单金额换算为显示货币金额(档位折算展示,¥100 × 0.14 = $14)。
* 本位币 CNY 时原样返回。
*/
export function cnyToDisplay(value: number): number {
if (displayCurrency.value === 'CNY') return value
return Number((value * cnyToUsd.value).toFixed(2))
}
+8 -9
View File
@@ -1,5 +1,6 @@
import { h } from 'vue'
import { NTag } from 'naive-ui'
import { formatMoney as formatMoneyWithCurrency } from '@/composables/useCurrency'
/**
* NDataTable 迁移公共渲染工具。
@@ -34,24 +35,22 @@ export function renderBadge(
}
export interface FormatMoneyOptions {
/** 数据存储层币种(按字段所属层传入:bil 层 USD/CNY 由本位币定,订单层 CNY),非显示币种 */
currency?: 'USD' | 'CNY'
precision?: number
showSign?: boolean
}
/**
* 统一金额格式化。默认 USD、6 位精度(与 NUMERIC(20,10) 对齐)。
* currency='CNY' 时用 ¥ 前缀 + 2 位精度(订单/充值层)。
* showSign 用 +/− 前缀(交易流水)。
* 统一金额格式化(委托 useCurrency,跟随系统本位币显示)。
* currency 参数语义 = 数据存储层币种:bil 层数据传本位币对应值(USD 部署默认 USD)、
* 订单/充值层传 'CNY'(本位币 USD 时自动按汇率折算显示)。
* 精度默认按显示币种取 USD 6 位 / CNY 2 位;showSign 用 +/− 前缀(交易流水)。
* 自动去掉末尾的 0(如 $1.500000 → $1.5)。
*/
export function formatMoney(value: unknown, opts: FormatMoneyOptions = {}): string {
const { currency = 'USD', precision = 6, showSign = false } = opts
const num = Number(value ?? 0)
const symbol = currency === 'CNY' ? '¥' : '$'
const sign = showSign ? (num > 0 ? '+' : num < 0 ? '-' : '') : ''
// 先 toFixed 确保精度,再用 Number 去掉末尾的 0
return `${sign}${symbol}${Number(Math.abs(num).toFixed(precision))}`
const { currency = 'USD', precision, showSign = false } = opts
return formatMoneyWithCurrency(value, { source: currency, precision, showSign })
}
/** 统一日期格式化:ISO T 转空格、截断到分钟;空值显示 -- */
+3 -2
View File
@@ -6,6 +6,7 @@ import { useNotificationCount } from '@/composables/useNotificationCount'
import { useAnnouncementRead } from '@/composables/useAnnouncementRead'
import { usePublicSettings } from '@/composables/usePublicSettings'
import { useWatermark } from '@/composables/useWatermark'
import { formatBilling } from '@/composables/useCurrency'
import { toast } from '@/utils/toast'
import Icon from '@/components/common/Icon.vue'
import MaintenanceBanner from '@/components/common/MaintenanceBanner.vue'
@@ -173,7 +174,7 @@ async function fetchWalletBalance() {
const w = res.data?.data
if (w) {
const bal = w.available_balance ?? w.balance ?? 0
walletBalance.value = bal >= 100 ? '$' + bal.toFixed(0) : '$' + bal.toFixed(2)
walletBalance.value = bal >= 100 ? formatBilling(bal, 0) : formatBilling(bal, 2)
}
} catch {
// silently ignore
@@ -447,7 +448,7 @@ onBeforeUnmount(() => {
title="钱包"
>
<Icon name="currencyDollar" size="sm" />
<span class="text-xs font-semibold tracking-tight">{{ walletBalance || '$0' }}</span>
<span class="text-xs font-semibold tracking-tight">{{ walletBalance || formatBilling(0, 2) }}</span>
</router-link>
<!-- Member Quota Capsule -->
<router-link
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { formatBilling } from '@/composables/useCurrency'
import Icon from '@/components/common/Icon.vue'
import ModelDistChart from '@/components/charts/ModelDistChart.vue'
import TokenTrendChart from '@/components/charts/TokenTrendChart.vue'
@@ -110,12 +111,12 @@ function formatNumber(value: unknown): string {
return number.toLocaleString('zh-CN')
}
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
function formatCost(value: unknown): string {
const number = Number(value) || 0
if (number === 0) return '$0.00'
if (number >= 1) return `$${number.toFixed(2)}`
if (number >= 0.01) return `$${number.toFixed(4)}`
return `$${number.toFixed(6)}`
if (number === 0 || number >= 1) return formatBilling(number, 2)
if (number >= 0.01) return formatBilling(number, 4)
return formatBilling(number, 6)
}
const greeting = computed(() => {
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { formatBilling } from '@/composables/useCurrency'
import Icon from '@/components/common/Icon.vue'
import ModelDistChart from '@/components/charts/ModelDistChart.vue'
import TokenTrendChart from '@/components/charts/TokenTrendChart.vue'
@@ -106,12 +107,12 @@ function formatNumber(value: unknown): string {
return number.toLocaleString('zh-CN')
}
// 金额格式化统一走本位币(formatBilling 内部读取响应式 displayCurrency,配置变化自动重渲染)
function formatCost(value: unknown): string {
const number = Number(value) || 0
if (number === 0) return '$0.00'
if (number >= 1) return `$${number.toFixed(2)}`
if (number >= 0.01) return `$${number.toFixed(4)}`
return `$${number.toFixed(6)}`
if (number === 0 || number >= 1) return formatBilling(number, 2)
if (number >= 0.01) return formatBilling(number, 4)
return formatBilling(number, 6)
}
function formatMs(value: unknown): string {
+3 -1
View File
@@ -6,6 +6,7 @@ import Icon from '@/components/common/Icon.vue'
import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
import request from '@/utils/request'
import { formatBilling } from '@/composables/useCurrency'
const code = ref('')
const loading = ref(false)
@@ -69,9 +70,10 @@ const columns = computed<DataTableColumns<any>>(() => [
title: '面值',
key: 'value',
width: 130,
// 兑换码面值属 bil 层(本位币),兑换入账恒为正数
render: (row) =>
row.type === 'quota'
? h('span', { class: 'font-mono' }, `+${Number(row.value).toFixed(6)}`)
? h('span', { class: 'font-mono' }, formatBilling(row.value, 6, true))
: h('span', { class: 'font-mono' }, '-'),
},
{
@@ -8,6 +8,7 @@ import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
import { formatBilling } from '@/composables/useCurrency'
const loading = ref(false)
const transactions = ref<any[]>([])
@@ -87,9 +88,9 @@ const txTypeBadgeClass: Record<string, string> = {
unfreeze: 'badge-gray',
}
// 流水金额:bil 层数据直显本位币符号,showSign 为正数补 +、负数自带 -
function formatAmount(amount: number): string {
if (amount >= 0) return '+$' + amount.toFixed(6)
return '-$' + Math.abs(amount).toFixed(6)
return formatBilling(amount, 6, true)
}
async function fetchTransactions() {
@@ -150,7 +151,7 @@ const columns = computed<DataTableColumns<any>>(() => [
title: '余额',
key: 'balance_after',
width: 120,
render: (row) => h('span', { class: 'text-gray-700' }, row.balance_after != null ? `$${row.balance_after.toFixed(6)}` : '--'),
render: (row) => h('span', { class: 'text-gray-700' }, row.balance_after != null ? formatBilling(row.balance_after, 6) : '--'),
},
{ title: '用户', key: 'username', width: 140, render: (row) => h('span', { class: 'text-gray-700 text-sm' }, row.username || '--') },
{
+48 -27
View File
@@ -8,14 +8,18 @@ import ResponsiveDataTable from '@/components/common/ResponsiveDataTable.vue'
import { renderBadge, tableScrollX } from '@/utils/renderUtils'
import request from '@/utils/request'
import { dispatchPayment } from '@/utils/payment'
import { formatBilling, formatOrder, displayCurrency, displayToCny, cnyToDisplay } from '@/composables/useCurrency'
const route = useRoute()
const wallet = ref<any>(null)
const walletLoading = ref(true)
// Recharge
// rechargeAmount 统一存"用户输入/选择的显示货币金额"(本位币);提交与折扣匹配前折回 CNY
const rechargeAmount = ref<number | null>(null)
const customAmount = ref<number | null>(null)
// 当前选中的预设档位(CNY 原面额),自定义输入时清空,仅用于档位选中态展示
const selectedPresetCny = ref<number | null>(null)
const rechargeLoading = ref(false)
const selectedChannel = ref('')
const selectedPaymentMethod = ref('')
@@ -75,6 +79,12 @@ const payMethods = computed(() => {
const minimumAmount = computed(() => Number(paymentInfo.value?.min_topup) || 1)
// 充值页显示口径换算:minimumAmount 是 CNY 门槛,输入框的最小值需换算为显示货币
const displayMinAmount = computed(() => cnyToDisplay(minimumAmount.value))
// 提交口径:显示货币金额折回 CNY 订单金额(与 handleRecharge 提交值一致,折扣/门槛校验同口径)
const rechargeCnyAmount = computed(() => (rechargeAmount.value != null ? displayToCny(rechargeAmount.value) : 0))
const thresholdActive = computed(() => Number(wallet.value?.warning_threshold) > 0)
const thresholdValidationError = computed(() => {
@@ -87,21 +97,24 @@ const thresholdValidationError = computed(() => {
const selectedDiscount = computed(() => {
// 折扣档位仅整数金额精确命中(与后端 RechargeCreate 口径一致):
// 100.99 不享受 100 档折扣,避免前端展示与实际计价不符
if (!rechargeAmount.value || !Number.isInteger(rechargeAmount.value)) return 1
return Number(paymentInfo.value?.amount_discount?.[rechargeAmount.value]) || 1
// 折扣锚定 CNY 整数面额:预设档位(CNY 锚定)可命中,自定义输入折算后非整数不命中(预期行为)
const cny = rechargeCnyAmount.value
if (!cny || !Number.isInteger(cny)) return 1
return Number(paymentInfo.value?.amount_discount?.[cny]) || 1
})
// 实付金额 = 显示货币金额 × 折扣(显示口径,展示时直接用 formatBilling)
const finalPayAmount = computed(() => (rechargeAmount.value || 0) * selectedDiscount.value)
const rechargeValidationMessage = computed(() => {
if (!rechargeAmount.value) return ''
if (rechargeAmount.value < minimumAmount.value) return `最低充值金额为 ¥${minimumAmount.value.toFixed(2)}`
if (rechargeCnyAmount.value < minimumAmount.value) return `最低充值金额为 ${formatOrder(minimumAmount.value, 2)}`
return ''
})
const rechargeReady = computed(() =>
!!rechargeAmount.value
&& rechargeAmount.value >= minimumAmount.value
&& rechargeCnyAmount.value >= minimumAmount.value
&& !!selectedChannel.value
&& !!selectedPaymentMethod.value,
)
@@ -135,11 +148,15 @@ async function fetchPaymentInfo() {
}
function selectPresetAmount(amount: number) {
rechargeAmount.value = amount
// amount 为 CNY 原面额:选中态与折扣匹配锚定 CNY,rechargeAmount 存换算后的显示货币金额
selectedPresetCny.value = amount
rechargeAmount.value = cnyToDisplay(amount)
customAmount.value = null
}
function onCustomInput() {
// 自定义输入按显示货币理解(本位币 USD 时即 USD 金额),提交时统一折回 CNY
selectedPresetCny.value = null
const val = customAmount.value ?? 0
rechargeAmount.value = val <= 0 ? null : val
}
@@ -160,7 +177,8 @@ async function handleRecharge() {
rechargeLoading.value = true
try {
const res: any = await request.post('/tenant/recharge/create', {
amount: rechargeAmount.value,
// 订单层恒 CNY:用户输入的显示货币金额折算为 CNY 下单金额(本位币 CNY 时原样)
amount: displayToCny(rechargeAmount.value),
payment_channel: selectedChannel.value,
payment_method: selectedPaymentMethod.value,
})
@@ -263,9 +281,10 @@ const redeemHistoryColumns = computed<DataTableColumns<any>>(() => [
title: '面值',
key: 'value',
width: 130,
// 兑换码面值属 bil 层(本位币),兑换入账恒为正数
render: (row) =>
row.type === 'quota'
? h('span', { class: 'font-mono' }, `+${Number(row.value).toFixed(6)}`)
? h('span', { class: 'font-mono' }, formatBilling(row.value, 6, true))
: h('span', { class: 'font-mono' }, '-'),
},
{
@@ -414,14 +433,13 @@ onBeforeUnmount(() => {
<p class="mt-0.5 text-xs text-slate-400">当前可用于模型调用</p>
</div>
</div>
<span class="currency-badge">{{ wallet?.currency || 'USD' }}</span>
<span class="currency-badge">{{ displayCurrency }}</span>
</div>
<div class="mt-8">
<p class="text-xs font-medium text-slate-400">余额</p>
<div class="mt-1 flex items-baseline gap-1.5">
<span class="text-xl font-semibold text-slate-400">$</span>
<strong class="balance-value">{{ wallet?.balance?.toFixed(2) ?? '0.00' }}</strong>
<strong class="balance-value">{{ formatBilling(wallet?.balance, 2) }}</strong>
</div>
</div>
@@ -431,7 +449,7 @@ onBeforeUnmount(() => {
<span class="text-sm text-slate-500">冻结金额</span>
</div>
<div class="flex items-center gap-2">
<strong class="text-sm font-semibold tabular-nums text-slate-700">${{ wallet?.frozen_balance?.toFixed(2) ?? '0.00' }}</strong>
<strong class="text-sm font-semibold tabular-nums text-slate-700">{{ formatBilling(wallet?.frozen_balance, 2) }}</strong>
<Icon name="chevronRight" size="xs" class="text-slate-300" />
</div>
</button>
@@ -442,7 +460,7 @@ onBeforeUnmount(() => {
<span class="text-sm text-slate-500">余额预警</span>
</div>
<div class="flex items-center gap-2">
<strong v-if="thresholdActive" class="text-sm font-semibold tabular-nums text-slate-700">${{ Number(wallet?.warning_threshold).toFixed(2) }}</strong>
<strong v-if="thresholdActive" class="text-sm font-semibold tabular-nums text-slate-700">{{ formatBilling(wallet?.warning_threshold, 2) }}</strong>
<span v-else class="text-sm text-slate-400">已关闭</span>
<Icon name="chevronRight" size="xs" class="text-slate-300" />
</div>
@@ -484,25 +502,26 @@ onBeforeUnmount(() => {
<div>
<div class="flex items-center justify-between">
<label class="text-xs font-semibold text-slate-600">充值金额</label>
<span class="text-[11px] text-slate-400">最低 ¥{{ minimumAmount.toFixed(2) }}</span>
<span class="text-[11px] text-slate-400">最低 {{ formatOrder(minimumAmount, 2) }}</span>
</div>
<div class="mt-3 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
<button
v-for="amount in presetAmounts"
:key="amount"
class="amount-pill"
:class="{ 'amount-pill-active': rechargeAmount === amount && !customAmount }"
:class="{ 'amount-pill-active': selectedPresetCny === amount && !customAmount }"
@click="selectPresetAmount(amount)"
>
<span><small>¥</small>{{ amount }}</span>
<!-- amount 为 CNY 原面额:本位币 USD 时按汇率折算展示 -->
<span><small>{{ displayCurrency === 'CNY' ? '¥' : '$' }}</small>{{ cnyToDisplay(amount) }}</span>
<em v-if="discountText(amount)">{{ discountText(amount) }}</em>
</button>
</div>
<div class="relative mt-3">
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">¥</span>
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">{{ displayCurrency === 'CNY' ? '¥' : '$' }}</span>
<n-input-number
v-model:value="customAmount"
:min="minimumAmount"
:min="displayMinAmount"
:step="0.01"
:status="rechargeValidationMessage ? 'error' : undefined"
placeholder="输入其他金额"
@@ -545,10 +564,10 @@ onBeforeUnmount(() => {
<div class="min-w-0">
<p class="text-[11px] text-slate-400">本次实付</p>
<div class="mt-0.5 flex items-baseline gap-2">
<strong class="text-xl font-bold tabular-nums text-slate-800">¥{{ finalPayAmount.toFixed(2) }}</strong>
<span v-if="selectedDiscount < 1" class="text-xs text-slate-400 line-through">¥{{ rechargeAmount?.toFixed(2) }}</span>
<strong class="text-xl font-bold tabular-nums text-slate-800">{{ formatBilling(finalPayAmount, 2) }}</strong>
<span v-if="selectedDiscount < 1" class="text-xs text-slate-400 line-through">{{ formatBilling(rechargeAmount, 2) }}</span>
</div>
<p v-if="selectedDiscount < 1" class="mt-0.5 text-[11px] text-emerald-600">到账仍按 ¥{{ rechargeAmount?.toFixed(2) }} 全额折算</p>
<p v-if="selectedDiscount < 1" class="mt-0.5 text-[11px] text-emerald-600">到账仍按 {{ formatBilling(rechargeAmount, 2) }} 全额折算</p>
</div>
<button class="btn btn-primary min-w-32" :disabled="!rechargeReady || rechargeLoading" @click="handleRecharge">
<Icon v-if="rechargeLoading" name="refresh" size="sm" class="animate-spin" />
@@ -559,7 +578,8 @@ onBeforeUnmount(() => {
<div class="recharge-note mt-3">
<Icon name="infoCircle" size="xs" class="mt-0.5 flex-shrink-0" />
<p>支付金额为人民币,到账后按平台汇率换算为美元钱包余额。</p>
<p v-if="displayCurrency === 'CNY'">支付金额为人民币,直接入账钱包。</p>
<p v-else>支付金额为人民币,到账后按平台汇率换算为美元钱包余额。</p>
</div>
</div>
</div>
@@ -598,7 +618,7 @@ onBeforeUnmount(() => {
</p>
</div>
<div class="text-right ml-3 flex-shrink-0">
<p class="text-sm font-semibold text-amber-600">${{ item.amount?.toFixed(4) }}</p>
<p class="text-sm font-semibold text-amber-600">{{ formatBilling(item.amount, 4) }}</p>
<p class="text-xs text-gray-400">剩余 {{ formatRemaining(item.remaining) }}</p>
</div>
</div>
@@ -718,9 +738,9 @@ onBeforeUnmount(() => {
<p>当可用余额低于预警线时,向组织 owner / admin 发送通知。设为 0 可关闭预警。</p>
</div>
<div>
<label class="text-xs font-semibold text-slate-600">预警阈值(USD)</label>
<label class="text-xs font-semibold text-slate-600">预警阈值({{ displayCurrency }})</label>
<div class="relative mt-3">
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">$</span>
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-semibold text-slate-400">{{ displayCurrency === 'CNY' ? '¥' : '$' }}</span>
<n-input-number
v-model:value="thresholdInput"
:min="0"
@@ -735,10 +755,11 @@ onBeforeUnmount(() => {
<p v-else class="mt-1.5 text-xs text-slate-400">设为 0 表示关闭预警</p>
</div>
<div class="flex flex-wrap gap-2">
<!-- 快捷按钮展示跟随本位币,thresholdInput 表单值保持存储币种原值不变 -->
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 0 }" @click="thresholdInput = 0">关闭</button>
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 1 }" @click="thresholdInput = 1">$1</button>
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 5 }" @click="thresholdInput = 5">$5</button>
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 10 }" @click="thresholdInput = 10">$10</button>
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 1 }" @click="thresholdInput = 1">{{ formatBilling(1) }}</button>
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 5 }" @click="thresholdInput = 5">{{ formatBilling(5) }}</button>
<button type="button" class="threshold-quick" :class="{ 'threshold-quick-active': Number(thresholdInput) === 10 }" @click="thresholdInput = 10">{{ formatBilling(10) }}</button>
</div>
</div>
<template #footer>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '@/components/common/Icon.vue'
import type { MarketplaceModel } from '@/api/marketplace'
import { formatPrice, formatTokens, getCategoryMeta, getCapabilityList } from '../marketplaceMeta'
const props = defineProps<{
model: MarketplaceModel
index: number
}>()
const emit = defineEmits<{
open: [model: MarketplaceModel]
copy: [modelId: string]
}>()
const meta = computed(() => getCategoryMeta(props.model.category))
const capabilities = computed(() => getCapabilityList(props.model, 4))
// 价格文案按计费模式派生,避免模板里堆叠条件
const priceView = computed(() => {
const model = props.model
if (model.billing_mode === 'per_request') {
return { caption: '按次计费', main: formatPrice(model.per_request_price), secondary: '', unit: '/ 次' }
}
return {
caption: model.billing_mode === 'tiered' ? '输入 / 输出(首档起)' : '输入 / 输出',
main: formatPrice(model.input_price),
secondary: formatPrice(model.output_price),
unit: '/ 1M tokens',
}
})
// 类别渐变注入 CSS 变量;入场延迟按序号递增形成 stagger(封顶 11 张)
const cardStyle = computed(() => ({
'--cat-from': meta.value.from,
'--cat-to': meta.value.to,
'--cat-glow': meta.value.glow,
animationDelay: `${Math.min(props.index, 11) * 45}ms`,
}))
</script>
<template>
<article
class="card-in group relative flex cursor-pointer flex-col overflow-hidden rounded-[20px] border border-white/80 bg-white/85 shadow-card backdrop-blur-xl transition-all duration-300 hover:-translate-y-1.5 hover:border-primary-200/70 hover:shadow-card-hover"
:style="cardStyle"
@click="emit('open', model)"
>
<!-- 顶部类别渐变线:hover 时从左展开 -->
<span
aria-hidden="true"
class="absolute inset-x-0 top-0 z-10 h-[3px] origin-left scale-x-0 bg-gradient-to-r from-[var(--cat-from)] to-[var(--cat-to)] transition-transform duration-300 group-hover:scale-x-100"
></span>
<!-- 右上角类别光晕:hover 淡入 -->
<span
aria-hidden="true"
class="pointer-events-none absolute -right-12 -top-12 h-32 w-32 rounded-full opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-25"
style="background: var(--cat-to)"
></span>
<div class="relative flex-1 pb-4">
<!-- 头部:类别磁贴 + 名称 / 模型 ID -->
<div class="flex items-start gap-4 p-5 pb-0">
<div
class="grid h-14 w-14 shrink-0 place-items-center rounded-2xl text-white transition-transform duration-300 group-hover:-rotate-3 group-hover:scale-105"
:style="{ background: 'linear-gradient(135deg, var(--cat-from), var(--cat-to))', boxShadow: '0 8px 24px rgba(var(--cat-glow), 0.35)' }"
>
<Icon :name="meta.icon" size="lg" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<h3 class="line-clamp-1 text-base font-semibold text-gray-900">{{ model.model_name || model.model_id }}</h3>
<span v-if="model.discount_label" class="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">
{{ model.discount_label }}
</span>
</div>
<button
type="button"
:aria-label="`复制模型 ID ${model.model_id}`"
title="复制模型 ID"
class="group/copy mt-1 flex max-w-full items-center gap-1.5 text-left"
@click.stop="emit('copy', model.model_id)"
>
<code class="truncate font-mono text-xs text-gray-400 transition-colors group-hover/copy:text-primary-600">{{ model.model_id }}</code>
<Icon name="copy" size="xs" class="shrink-0 text-gray-300 transition-colors group-hover/copy:text-primary-600" />
</button>
</div>
</div>
<!-- 简介 -->
<p class="line-clamp-2 min-h-[2.5rem] px-5 pt-3 text-sm leading-relaxed text-gray-500">
{{ model.description || '暂无模型描述,点击查看技术规格与计费信息。' }}
</p>
<!-- 规格:上下文 / 最大输出 -->
<div class="flex gap-2 px-5 pt-3">
<div class="flex-1 rounded-xl bg-gray-50/90 px-3 py-2">
<span class="text-[11px] text-gray-400">上下文</span>
<strong class="block text-sm font-semibold tabular-nums text-gray-800">{{ formatTokens(model.max_context_tokens) }}</strong>
</div>
<div class="flex-1 rounded-xl bg-gray-50/90 px-3 py-2">
<span class="text-[11px] text-gray-400">最大输出</span>
<strong class="block text-sm font-semibold tabular-nums text-gray-800">{{ formatTokens(model.max_output_tokens) }}</strong>
</div>
</div>
<!-- 能力芯片:最多 3 个 -->
<div v-if="capabilities.length" class="flex flex-wrap gap-1.5 px-5 pt-3">
<span
v-for="item in capabilities"
:key="item"
class="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[11px] font-medium text-primary-700"
>
<Icon name="check" size="xs" />{{ item }}
</span>
</div>
</div>
<!-- 底栏:价格 + 详情入口 -->
<div class="flex items-end justify-between gap-3 border-t border-gray-100/80 px-5 pb-4 pt-4">
<div class="min-w-0">
<span class="text-[11px] text-gray-400">{{ priceView.caption }}</span>
<div class="flex items-baseline gap-1">
<strong class="text-lg font-bold tabular-nums text-gray-900">{{ priceView.main }}</strong>
<strong v-if="priceView.secondary" class="text-sm font-semibold tabular-nums text-gray-600">{{ priceView.secondary }}</strong>
<span class="whitespace-nowrap text-[11px] text-gray-400">{{ priceView.unit }}</span>
</div>
</div>
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-xl px-3.5 py-2 text-sm font-medium text-primary-600 transition-all duration-200 hover:bg-primary-50 active:scale-[0.98] group-hover:bg-primary-500 group-hover:text-white group-hover:shadow-glow"
@click.stop="emit('open', model)"
>
详情
<Icon name="arrowRight" size="sm" class="transition-transform duration-200 group-hover:translate-x-0.5" />
</button>
</div>
</article>
</template>
<style scoped>
/* 卡片入场:backwards 填充保证延迟期间隐藏、结束后不覆盖 hover 位移 */
.card-in {
animation: card-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.card-in {
animation: none;
}
}
</style>
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { NDrawer, NDrawerContent } from 'naive-ui'
import Icon from '@/components/common/Icon.vue'
import type { MarketplaceModel } from '@/api/marketplace'
import { useIsMobile } from '@/composables/useIsMobile'
import { displayCurrency } from '@/composables/useCurrency'
import {
billingModeLabel,
formatPrice,
formatTokens,
getCapabilityList,
getCategoryMeta,
timePriceText,
timeWindow,
} from '../marketplaceMeta'
const props = defineProps<{
show: boolean
model: MarketplaceModel | null
loading: boolean
isLoggedIn: boolean
primaryActionRoute: RouteLocationRaw
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
copy: [modelId: string]
}>()
const { isMobile } = useIsMobile()
const meta = computed(() => getCategoryMeta(props.model?.category))
// 计费瓦片:按计费模式决定展示哪些价格,有值的缓存价附加在后面
// 价格走 formatPrice(本位币),单位文案跟随 displayCurrency(computed 内读取,配置变化自动重渲染)
const priceTiles = computed(() => {
const model = props.model
if (!model) return []
if (model.billing_mode === 'per_request') {
return [{ label: '单次调用', value: formatPrice(model.per_request_price), unit: `${displayCurrency.value} / 次`, primary: true, wide: true }]
}
const tiles = [
{ label: '输入', value: formatPrice(model.input_price), unit: `${displayCurrency.value} / 1M tokens`, primary: true, wide: false },
{ label: '输出', value: formatPrice(model.output_price), unit: `${displayCurrency.value} / 1M tokens`, primary: false, wide: false },
]
// 后端未设置缓存价时返回 0 而非 null,0 价瓦片属于展示噪音,直接隐藏
if (model.cache_read_price) {
tiles.push({ label: '缓存读取', value: formatPrice(model.cache_read_price), unit: `${displayCurrency.value} / 1M tokens`, primary: false, wide: false })
}
if (model.cache_creation_price) {
tiles.push({ label: '缓存创建', value: formatPrice(model.cache_creation_price), unit: `${displayCurrency.value} / 1M tokens`, primary: false, wide: false })
}
return tiles
})
// 抽屉内展示全部能力,不做数量截断
const capabilities = computed(() => getCapabilityList(props.model ?? ({} as MarketplaceModel), Number.MAX_SAFE_INTEGER))
const tags = computed(() => props.model?.tags || [])
const tileStyle = computed(() => ({
background: `linear-gradient(135deg, ${meta.value.from}, ${meta.value.to})`,
boxShadow: `0 8px 24px rgba(${meta.value.glow}, 0.35)`,
}))
const categoryBadgeStyle = computed(() => ({
color: meta.value.to,
backgroundColor: `rgba(${meta.value.glow}, 0.1)`,
}))
const drawerWidth = computed(() => (isMobile.value ? '100%' : 560))
</script>
<template>
<n-drawer
:show="show"
:width="drawerWidth"
placement="right"
:auto-focus="false"
@update:show="(value: boolean) => emit('update:show', value)"
>
<n-drawer-content v-if="model" closable :body-content-style="{ padding: 0 }">
<template #header>
<div class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
<div class="grid h-14 w-14 shrink-0 place-items-center rounded-2xl text-white" :style="tileStyle">
<Icon :name="meta.icon" size="lg" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h2 class="truncate text-lg font-bold text-gray-900">{{ model.model_name || model.model_id }}</h2>
<span v-if="model.discount_label" class="badge badge-warning shrink-0">{{ model.discount_label }}</span>
</div>
<button type="button" class="group mt-0.5 flex max-w-full items-center gap-1.5" title="复制模型 ID" @click="emit('copy', model.model_id)">
<code class="truncate font-mono text-xs text-gray-500">{{ model.model_id }}</code>
<Icon name="copy" size="xs" class="shrink-0 text-gray-400 transition-colors group-hover:text-primary-600" />
</button>
</div>
<span class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium" :style="categoryBadgeStyle">{{ meta.label }}</span>
</div>
</template>
<div class="animate-fade-in space-y-6 px-5 py-5 sm:px-6">
<!-- 详情接口加载中:列表数据先行展示,仅提示补充信息加载 -->
<p v-if="loading" class="flex items-center gap-2 text-xs text-gray-400">
<span class="spinner border-primary-500" />正在加载完整模型信息...
</p>
<!-- 模型简介 -->
<section>
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Icon name="document" size="sm" class="text-primary-500" />模型简介
</h3>
<p class="text-sm leading-relaxed text-gray-600">{{ model.description || '暂无模型描述。' }}</p>
</section>
<!-- 计费信息 -->
<section>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Icon name="creditCard" size="sm" class="text-primary-500" />计费信息
<span v-if="model.billing_mode === 'tiered'" class="rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700">
阶梯计费 · 以下为首档起价
</span>
</h3>
<div class="rounded-2xl border border-primary-100/80 bg-gradient-to-br from-primary-50/70 via-white to-white p-4 shadow-glass-sm">
<div class="grid grid-cols-2 gap-3">
<div
v-for="tile in priceTiles"
:key="tile.label"
class="rounded-xl bg-white/85 p-3 shadow-sm ring-1 ring-gray-100"
:class="tile.wide ? 'col-span-2' : ''"
>
<span class="text-xs text-gray-400">{{ tile.label }}</span>
<div class="mt-0.5 text-xl font-bold tabular-nums" :class="tile.primary ? 'text-primary-700' : 'text-gray-900'">
{{ tile.value }}
</div>
<span class="text-[11px] text-gray-400">{{ tile.unit }}</span>
</div>
</div>
<!-- 分时段价目 -->
<div v-if="model.time_prices?.length" class="mt-4 border-t border-primary-100/70 pt-3">
<p class="mb-1 text-xs font-medium text-gray-500">
分时段价格
<small class="ml-1 font-normal text-gray-400">命中时段按下方价格计费,未命中按默认价</small>
</p>
<div v-for="(tp, index) in model.time_prices" :key="index" class="flex items-center justify-between gap-3 py-1.5">
<span class="min-w-0 text-xs text-gray-600">
{{ tp.name }}
<small class="block text-[11px] text-gray-400">{{ timeWindow(tp) }}</small>
</span>
<strong class="shrink-0 text-right text-xs font-semibold tabular-nums text-gray-800">{{ timePriceText(model, tp) }}</strong>
</div>
</div>
<p v-if="model.price_change_note" class="mt-3 flex items-start gap-1.5 text-xs text-amber-700">
<Icon name="infoCircle" size="xs" class="mt-0.5 shrink-0" />{{ model.price_change_note }}
</p>
</div>
</section>
<!-- 技术规格 -->
<section>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Icon name="cog" size="sm" class="text-primary-500" />技术规格
</h3>
<div class="divide-y divide-gray-100 rounded-2xl border border-gray-100 bg-white/70">
<div class="flex items-center justify-between px-4 py-3">
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="document" size="sm" class="text-gray-400" />最大上下文</span>
<strong class="text-sm font-semibold tabular-nums text-gray-900">{{ formatTokens(model.max_context_tokens) }} tokens</strong>
</div>
<div class="flex items-center justify-between px-4 py-3">
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="expand" size="sm" class="text-gray-400" />最大输出</span>
<strong class="text-sm font-semibold tabular-nums text-gray-900">{{ formatTokens(model.max_output_tokens) }} tokens</strong>
</div>
<div class="flex items-center justify-between px-4 py-3">
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="grid" size="sm" class="text-gray-400" />模型类别</span>
<strong class="text-sm font-semibold text-gray-900">{{ meta.label }}</strong>
</div>
<div class="flex items-center justify-between px-4 py-3">
<span class="flex items-center gap-2 text-sm text-gray-500"><Icon name="creditCard" size="sm" class="text-gray-400" />计费模式</span>
<strong class="text-sm font-semibold text-gray-900">{{ billingModeLabel(model.billing_mode) }}</strong>
</div>
</div>
</section>
<!-- 能力支持 -->
<section>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Icon name="checkCircle" size="sm" class="text-primary-500" />能力支持
</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="item in capabilities"
:key="item"
class="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700"
>
<Icon name="checkCircle" size="xs" />{{ item }}
</span>
<span v-if="capabilities.length === 0" class="text-xs text-gray-400">标准 API 接入</span>
</div>
</section>
<!-- 模型标签 -->
<section v-if="tags.length">
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
<Icon name="link" size="sm" class="text-primary-500" />模型标签
</h3>
<div class="flex flex-wrap gap-2">
<span v-for="tag in tags" :key="tag" class="rounded-lg bg-gray-100 px-2 py-1 text-xs text-gray-600">{{ tag }}</span>
</div>
</section>
</div>
<template #footer>
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-gray-400">实际可用范围以账户套餐与 API 密钥权限为准</p>
<router-link :to="primaryActionRoute" class="btn btn-primary btn-sm shrink-0" @click="emit('update:show', false)">
{{ isLoggedIn ? '前往使用' : '免费开始' }}
<Icon name="arrowRight" size="sm" />
</router-link>
</div>
</template>
</n-drawer-content>
</n-drawer>
</template>
@@ -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<string, string> = {
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' ? ' 起' : ''}`
}
+4 -2
View File
@@ -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) {
@@ -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<DataTableColumns<ComparisonItem>>(() => [
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<DataTableColumns<ComparisonItem>>(() => [
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<DataTableColumns<ComparisonItem>>(() => [
<Icon name="creditCard" size="lg" />
</div>
<div class="min-w-0 flex-1 text-right">
<div class="stat-value">${{ formatCost(summary.total_cost) }}</div>
<div class="stat-value">{{ formatCost(summary.total_cost) }}</div>
<div class="stat-label">总费用</div>
</div>
</div>
@@ -362,7 +364,7 @@ const columns = computed<DataTableColumns<ComparisonItem>>(() => [
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)}`"
></div>
</template>
</div>
+3 -1
View File
@@ -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 {
@@ -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<string, string> = {
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 {
@@ -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 {
@@ -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)
}
@@ -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(() => {
</div>
<div class="flex items-center gap-2 text-sm text-gray-600">
<Icon name="currencyDollar" size="sm" class="text-gray-400" />
<span>累计充值 <strong class="text-gray-900">${{ levelBenefits.cumulative_recharge.toFixed(2) }}</strong></span>
<span>累计充值 <strong class="text-gray-900">{{ formatBilling(levelBenefits.cumulative_recharge, 2) }}</strong></span>
</div>
<div v-if="getNextLevelThreshold() !== null" class="flex-1 w-full sm:w-auto">
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
@@ -482,7 +484,7 @@ onMounted(() => {
></div>
</div>
<p class="text-xs text-gray-400 mt-1">
再充值 ${{ (getNextLevelThreshold()! - levelBenefits.cumulative_recharge).toFixed(2) }} 即可升级
再充值 {{ formatBilling(getNextLevelThreshold()! - levelBenefits.cumulative_recharge, 2) }} 即可升级
</p>
</div>
<div v-else class="text-xs text-primary-600 font-medium">
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more