From 96bcd3e46fa20af09c14e663476daef6292138ee Mon Sep 17 00:00:00 2001 From: chengqian Date: Fri, 28 Aug 2026 16:46:52 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(channel):=E4=BF=AE=E5=A4=8D=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E6=9B=B4=E6=96=B0key=E5=90=8E=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E7=AB=8B=E5=8D=B3=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/channel.go | 8 +++- internal/dispatchadapter/state_local.go | 7 ++++ internal/dispatchadapter/state_redis.go | 15 ++++++++ internal/dispatchadapter/state_redis_test.go | 29 +++++++++++++++ internal/dispatchadapter/wire.go | 39 ++++++++++++++++++++ internal/logic/admin/channel.go | 37 ++++++++++++++----- internal/logic/admin/channel_oauth.go | 3 ++ internal/logic/relay/provider.go | 4 ++ internal/logic/task/oauth_token_refresh.go | 5 +++ relaykit/dispatch/coordinator.go | 29 ++++++++++++++- relaykit/dispatch/coordinator_test.go | 24 ++++++++++++ web/admin/src/views/ai/ChannelDetailPage.vue | 22 +++++++++++ 12 files changed, 211 insertions(+), 11 deletions(-) diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index b151590..dbb1838 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -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 获取渠道模型能力请求 diff --git a/internal/dispatchadapter/state_local.go b/internal/dispatchadapter/state_local.go index 03773d5..241251b 100644 --- a/internal/dispatchadapter/state_local.go +++ b/internal/dispatchadapter/state_local.go @@ -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() diff --git a/internal/dispatchadapter/state_redis.go b/internal/dispatchadapter/state_redis.go index 8e6b7fe..64a6dea 100644 --- a/internal/dispatchadapter/state_redis.go +++ b/internal/dispatchadapter/state_redis.go @@ -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,让前端能显示剩余时间。 diff --git a/internal/dispatchadapter/state_redis_test.go b/internal/dispatchadapter/state_redis_test.go index 1c4adb3..a71f44a 100644 --- a/internal/dispatchadapter/state_redis_test.go +++ b/internal/dispatchadapter/state_redis_test.go @@ -472,6 +472,26 @@ func Test凭证冷却(t *testing.T) { assert.False(t, s.IsCredentialCooled(ctx, 101), "冷却到期后必须恢复可用") } +// Test凭证冷却可手动解除 覆盖「更换 Key / 重置健康度」的解除路径:冷却按 keyID 打标, +// 而换 Key 是原地改同一行(keyID 不变),不解除的话新 Key 会被整段跳过直到 TTL 到期。 +func Test凭证冷却可手动解除(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异步消费(t *testing.T) { ctx := context.Background() s := newTestState(t, nil) @@ -618,6 +638,15 @@ func TestLocal凭证冷却过期(t *testing.T) { assert.False(t, l.isCredCooled(9)) } +func TestLocal凭证冷却解除(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 Test429估计器水位收敛(t *testing.T) { ctx := context.Background() s := newTestState(t, nil) diff --git a/internal/dispatchadapter/wire.go b/internal/dispatchadapter/wire.go index 8dd623e..1a7e4fa 100644 --- a/internal/dispatchadapter/wire.go +++ b/internal/dispatchadapter/wire.go @@ -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:, +// 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) { diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index 69ab27d..809a674 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -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) diff --git a/internal/logic/admin/channel_oauth.go b/internal/logic/admin/channel_oauth.go index 1609258..78bbcc7 100644 --- a/internal/logic/admin/channel_oauth.go +++ b/internal/logic/admin/channel_oauth.go @@ -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(), diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 0661b5a..6f0485b 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -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 } diff --git a/internal/logic/task/oauth_token_refresh.go b/internal/logic/task/oauth_token_refresh.go index 6ae27e6..2ea79e1 100644 --- a/internal/logic/task/oauth_token_refresh.go +++ b/internal/logic/task/oauth_token_refresh.go @@ -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++ } diff --git a/relaykit/dispatch/coordinator.go b/relaykit/dispatch/coordinator.go index 20ec2ee..7462874 100644 --- a/relaykit/dispatch/coordinator.go +++ b/relaykit/dispatch/coordinator.go @@ -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{}{} diff --git a/relaykit/dispatch/coordinator_test.go b/relaykit/dispatch/coordinator_test.go index 0e2897b..ca894ca 100644 --- a/relaykit/dispatch/coordinator_test.go +++ b/relaykit/dispatch/coordinator_test.go @@ -320,6 +320,30 @@ func TestCoordinator_全Key冷却渠道跳过(t *testing.T) { assert.Equal(t, 1, d.Excluded.Request) } +// TestCoordinator_无可用渠道诊断带冷却KeyID 凭证冷却只存在于 Redis 且只靠 TTL 自愈, +// 诊断不给出 keyID 的话运维无从判断该等 TTL 还是该去解除(换 Key 不改 keyID, +// 陈旧标记会让新 Key 被整段跳过,且期间连上游请求都不会发)。 +func TestCoordinator_无可用渠道诊断带冷却KeyID(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_租约拒绝不扣预算(t *testing.T) { ctx := context.Background() state := newFakeState() diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index 352ad99..5c5b7d3 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -23,6 +23,13 @@ const breakerTagColor: Record = { 0: 'green', 1: 'red', 2: 'oran const breakerLabel: Record = { 0: '正常', 1: '熔断', 2: '半开' } const tierLabel: Record = { primary: '首选', secondary: '备用', reserve: '保底' } const tierTagColor: Record = { 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 { {{ detail.key_status === 'active' ? '正常' : detail.key_status || 'N/A' }} + + + 冷却中 · 剩余 {{ formatCooldown(detail.key_cooldown_remaining) }} + + 正常 + {{ detail.token_expires_at }} + + 该 Key 因认证失败(401/403)被调度器冷却,冷却期内本渠道会被整体跳过且不会发起上游请求 + —— 此时渠道测试仍会成功(测试不走调度器),但实际调用会返回「无可用渠道」。 + 更新 Key 或点击「重置健康度」都会立即解除冷却。 +
更新 Key
From 958f6c4d757f87c8bd0a8bbbd533a68119ff3a4d Mon Sep 17 00:00:00 2001 From: chengqian Date: Fri, 28 Aug 2026 17:40:24 +0800 Subject: [PATCH 2/8] =?UTF-8?q?type(relay):=E4=BF=AE=E6=AD=A3=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E4=BB=A3=E7=A0=81=E9=87=8C=E9=9D=A2=E7=9A=84=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=90=8D=E4=B8=BA=E7=BA=AF=E8=8B=B1=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/goframe-conventions.md | 2 +- internal/dispatchadapter/catalog_test.go | 38 ++++----- internal/dispatchadapter/maintenance_test.go | 18 ++-- internal/dispatchadapter/state_redis_test.go | 88 ++++++++++---------- internal/logic/common/cron_test.go | 24 +++--- internal/logic/update/replacer_test.go | 14 ++-- internal/logic/update/types_test.go | 14 ++-- internal/logic/update/verifier_test.go | 10 +-- relaykit/dispatch/breaker_test.go | 12 +-- relaykit/dispatch/classify_test.go | 14 ++-- relaykit/dispatch/coordinator_test.go | 68 +++++++-------- relaykit/dispatch/hrw_test.go | 12 +-- relaykit/dispatch/policy_test.go | 30 +++---- relaykit/dispatch/score_test.go | 14 ++-- relaykit/dispatch/session_test.go | 12 +-- 15 files changed, 185 insertions(+), 185 deletions(-) diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index c89df67..e7a7d35 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -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`),两者行为不一致,混用时尤其注意。 diff --git a/internal/dispatchadapter/catalog_test.go b/internal/dispatchadapter/catalog_test.go index 966efd8..78cbecc 100644 --- a/internal/dispatchadapter/catalog_test.go +++ b/internal/dispatchadapter/catalog_test.go @@ -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"}, diff --git a/internal/dispatchadapter/maintenance_test.go b/internal/dispatchadapter/maintenance_test.go index f734416..bb82106 100644 --- a/internal/dispatchadapter/maintenance_test.go +++ b/internal/dispatchadapter/maintenance_test.go @@ -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) }) } diff --git a/internal/dispatchadapter/state_redis_test.go b/internal/dispatchadapter/state_redis_test.go index a71f44a..eb1bd7f 100644 --- a/internal/dispatchadapter/state_redis_test.go +++ b/internal/dispatchadapter/state_redis_test.go @@ -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,9 +472,9 @@ func Test凭证冷却(t *testing.T) { assert.False(t, s.IsCredentialCooled(ctx, 101), "冷却到期后必须恢复可用") } -// Test凭证冷却可手动解除 覆盖「更换 Key / 重置健康度」的解除路径:冷却按 keyID 打标, +// TestCredentialCooldown_ManualClear 覆盖「更换 Key / 重置健康度」的解除路径:冷却按 keyID 打标, // 而换 Key 是原地改同一行(keyID 不变),不解除的话新 Key 会被整段跳过直到 TTL 到期。 -func Test凭证冷却可手动解除(t *testing.T) { +func TestCredentialCooldown_ManualClear(t *testing.T) { ctx := context.Background() s := newTestState(t, nil) @@ -492,7 +492,7 @@ func Test凭证冷却可手动解除(t *testing.T) { assert.False(t, s.IsCredentialCooled(ctx, 999)) } -func TestReportOutcome异步消费(t *testing.T) { +func TestReportOutcome_AsyncConsumption(t *testing.T) { ctx := context.Background() s := newTestState(t, nil) s.Start(ctx) @@ -506,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) @@ -527,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) @@ -553,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) @@ -583,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) @@ -609,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)) @@ -622,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), "同窗口本实例只放行一个") @@ -630,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)) @@ -638,7 +638,7 @@ func TestLocal凭证冷却过期(t *testing.T) { assert.False(t, l.isCredCooled(9)) } -func TestLocal凭证冷却解除(t *testing.T) { +func TestLocal_CredentialCooldownClear(t *testing.T) { l := newLocalState() l.coolCred(9, time.Hour) require.True(t, l.isCredCooled(9)) @@ -647,7 +647,7 @@ func TestLocal凭证冷却解除(t *testing.T) { l.clearCred(9) // 幂等 } -func Test429估计器水位收敛(t *testing.T) { +func TestRateLimit429Estimator_OnsetConverges(t *testing.T) { ctx := context.Background() s := newTestState(t, nil) @@ -677,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) diff --git a/internal/logic/common/cron_test.go b/internal/logic/common/cron_test.go index ae5ff0e..369d212 100644 --- a/internal/logic/common/cron_test.go +++ b/internal/logic/common/cron_test.go @@ -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 diff --git a/internal/logic/update/replacer_test.go b/internal/logic/update/replacer_test.go index 720a67d..a0df544 100644 --- a/internal/logic/update/replacer_test.go +++ b/internal/logic/update/replacer_test.go @@ -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("源文件不存在时应返回错误") } diff --git a/internal/logic/update/types_test.go b/internal/logic/update/types_test.go index d4ca3eb..b1deb91 100644 --- a/internal/logic/update/types_test.go +++ b/internal/logic/update/types_test.go @@ -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)不受过期机制影响 diff --git a/internal/logic/update/verifier_test.go b/internal/logic/update/verifier_test.go index 9d1501f..13608de 100644 --- a/internal/logic/update/verifier_test.go +++ b/internal/logic/update/verifier_test.go @@ -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() diff --git a/relaykit/dispatch/breaker_test.go b/relaykit/dispatch/breaker_test.go index c106348..44df05b 100644 --- a/relaykit/dispatch/breaker_test.go +++ b/relaykit/dispatch/breaker_test.go @@ -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 diff --git a/relaykit/dispatch/classify_test.go b/relaykit/dispatch/classify_test.go index 6c5e5a8..e57f2db 100644 --- a/relaykit/dispatch/classify_test.go +++ b/relaykit/dispatch/classify_test.go @@ -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)) } diff --git a/relaykit/dispatch/coordinator_test.go b/relaykit/dispatch/coordinator_test.go index ca894ca..6e3d381 100644 --- a/relaykit/dispatch/coordinator_test.go +++ b/relaykit/dispatch/coordinator_test.go @@ -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,10 +320,10 @@ func TestCoordinator_全Key冷却渠道跳过(t *testing.T) { assert.Equal(t, 1, d.Excluded.Request) } -// TestCoordinator_无可用渠道诊断带冷却KeyID 凭证冷却只存在于 Redis 且只靠 TTL 自愈, +// TestCoordinator_NoChannelDiagIncludesCooledKeyIDs 凭证冷却只存在于 Redis 且只靠 TTL 自愈, // 诊断不给出 keyID 的话运维无从判断该等 TTL 还是该去解除(换 Key 不改 keyID, // 陈旧标记会让新 Key 被整段跳过,且期间连上游请求都不会发)。 -func TestCoordinator_无可用渠道诊断带冷却KeyID(t *testing.T) { +func TestCoordinator_NoChannelDiagIncludesCooledKeyIDs(t *testing.T) { ctx := context.Background() state := newFakeState() state.cooled[11] = true @@ -344,7 +344,7 @@ func TestCoordinator_无可用渠道诊断带冷却KeyID(t *testing.T) { assert.Contains(t, diag.Summary(), "凭证全部冷却×2(key=11,21)") } -func TestCoordinator_租约拒绝不扣预算(t *testing.T) { +func TestCoordinator_LeaseDenialDoesNotConsumeBudget(t *testing.T) { ctx := context.Background() state := newFakeState() state.leaseDeny[1] = true @@ -364,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) @@ -393,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() @@ -407,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) @@ -436,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) @@ -467,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() @@ -488,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() @@ -504,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)) @@ -519,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, @@ -538,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)) @@ -551,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)) @@ -564,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, @@ -580,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, @@ -605,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)} @@ -622,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)) @@ -634,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)) @@ -651,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 @@ -661,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() @@ -699,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, diff --git a/relaykit/dispatch/hrw_test.go b/relaykit/dispatch/hrw_test.go index 73a9d76..045ae64 100644 --- a/relaykit/dispatch/hrw_test.go +++ b/relaykit/dispatch/hrw_test.go @@ -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) diff --git a/relaykit/dispatch/policy_test.go b/relaykit/dispatch/policy_test.go index aa05d8a..3ecb198 100644 --- a/relaykit/dispatch/policy_test.go +++ b/relaykit/dispatch/policy_test.go @@ -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) diff --git a/relaykit/dispatch/score_test.go b/relaykit/dispatch/score_test.go index 031f772..4270a2d 100644 --- a/relaykit/dispatch/score_test.go +++ b/relaykit/dispatch/score_test.go @@ -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) diff --git a/relaykit/dispatch/session_test.go b/relaykit/dispatch/session_test.go index 8af3d3a..2b8d1c3 100644 --- a/relaykit/dispatch/session_test.go +++ b/relaykit/dispatch/session_test.go @@ -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) From 0964d9014b8aa86f9e9b59da114dd8072fb95f6d Mon Sep 17 00:00:00 2001 From: qian Date: Fri, 28 Aug 2026 00:29:43 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(update):=E4=BF=AE=E5=A4=8D=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E6=9B=B4=E6=96=B0=E6=A3=80=E6=B5=8B=E5=90=8E=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E9=97=AE=E9=A2=98=E5=AF=BC=E8=87=B4=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/admin/update.go | 4 +++- internal/logic/update/checker.go | 20 ++++++++++++++++++-- internal/logic/update/checker_test.go | 21 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/internal/logic/admin/update.go b/internal/logic/admin/update.go index d488e55..9d6c6cc 100644 --- a/internal/logic/admin/update.go +++ b/internal/logic/admin/update.go @@ -115,7 +115,9 @@ func (s *sAdmin) UpdateExecute(ctx context.Context, req *v1.UpdateExecuteReq) (* // Get check result to find download URL cached := update.GetManager().GetCheckResult() - if cached == nil { + // 缓存里有更新但没匹配到平台安装包时强制重查,而不是直接报「没有可用的更新包」: + // 典型场景是发行版刚发布、CI 尚未上传资产窗口期的检查结果被缓存,资产几分钟后就位 + if cached == nil || update.HasUpdateButNoAsset(cached) { // Force a check first result, err := update.CheckForUpdate(ctx, true) if err != nil { diff --git a/internal/logic/update/checker.go b/internal/logic/update/checker.go index be0756b..97af3b8 100644 --- a/internal/logic/update/checker.go +++ b/internal/logic/update/checker.go @@ -114,8 +114,11 @@ func CheckForUpdate(ctx context.Context, force bool) (*CheckResult, error) { // Store ETag for conditional requests manager.lastETag.Store(etag) - // Cache result - setCheckCache(ctx, result) + // Cache result。「有更新但没匹配到平台安装包」的结果不写 Redis:TTL 24h 太长, + // 会挡住发布窗口期之后的检查拿到完整资产列表(内存结果照常存,供状态展示) + if !HasUpdateButNoAsset(result) { + setCheckCache(ctx, result) + } manager.checkResult.Store(result) return result, nil @@ -185,6 +188,14 @@ func fetchLatestRelease(ctx context.Context, conditional bool) (*GitHubRelease, return &release, etag, nil } +// HasUpdateButNoAsset 判断检查结果是否为「有更新但当前平台没匹配到安装包」。 +// 典型成因:发行版刚发布、CI 尚未上传资产的窗口期拉到的结果(资产几分钟后才就位)。 +// 这类结果不可信:不能写缓存,已入库的脏缓存读到后按无效处理, +// 执行更新时则强制重查,而不是直接报「没有可用的更新包」。 +func HasUpdateButNoAsset(r *CheckResult) bool { + return r != nil && r.HasUpdate && r.DownloadURL == "" +} + // getCheckCache reads cached check result from Redis. // 缓存是纯优化(TTL 24h),Redis 未配置/不可用时应降级为无缓存、走实时检查, // 绝不能因 g.Redis() 客户端创建失败而 panic(见 goframe-conventions.md 已修复记录)。 @@ -205,6 +216,11 @@ func getCheckCache(ctx context.Context) (out *CheckResult) { if err := json.Unmarshal(val.Bytes(), &result); err != nil { return nil } + // 「有更新但没匹配到平台安装包」的脏缓存(发布窗口期写入,含旧版本遗留数据)按无效处理, + // 让调用方 fallthrough 到实时检查拿完整资产列表 + if HasUpdateButNoAsset(&result) { + return nil + } return &result } diff --git a/internal/logic/update/checker_test.go b/internal/logic/update/checker_test.go index 4619cfd..249e414 100644 --- a/internal/logic/update/checker_test.go +++ b/internal/logic/update/checker_test.go @@ -46,6 +46,27 @@ func TestCompareVersions(t *testing.T) { } } +func TestHasUpdateButNoAsset(t *testing.T) { + cases := []struct { + name string + result *CheckResult + want bool + }{ + {"nil 结果", nil, false}, + {"有更新且有安装包", &CheckResult{HasUpdate: true, DownloadURL: "https://example.com/team-api-0.2.14-linux-amd64.tar.gz"}, false}, + {"无更新且无安装包", &CheckResult{HasUpdate: false, DownloadURL: ""}, false}, + {"有更新但缺安装包(发布窗口期脏数据)", &CheckResult{HasUpdate: true, DownloadURL: ""}, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := HasUpdateButNoAsset(c.result); got != c.want { + t.Errorf("HasUpdateButNoAsset(%+v) = %v, want %v", c.result, got, c.want) + } + }) + } +} + func TestNormalizeSemver(t *testing.T) { cases := []struct{ in, want string }{ {"0.2.0_39", "0.2.0+39"}, // Makefile git describe 版本:下划线转构建元数据 From 483ffded09a4329aa4d85f87e702a35f00131917 Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 29 Aug 2026 00:12:42 +0800 Subject: [PATCH 4/8] =?UTF-8?q?improve(log):=E4=BC=98=E5=8C=96=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=8F=B0=E8=AF=B7=E6=B1=82=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/security/RequestAuditLogsPage.vue | 3 +- web/admin/src/views/tenant/UsageLogsPage.vue | 693 ++++++++---------- 2 files changed, 322 insertions(+), 374 deletions(-) diff --git a/web/admin/src/views/security/RequestAuditLogsPage.vue b/web/admin/src/views/security/RequestAuditLogsPage.vue index 349ee8b..62da0e8 100644 --- a/web/admin/src/views/security/RequestAuditLogsPage.vue +++ b/web/admin/src/views/security/RequestAuditLogsPage.vue @@ -128,7 +128,7 @@ function goToTask(taskId: string) { } const columns: TableColumnData[] = [ - { title: 'ID', dataIndex: 'id', width: 100 }, + { title: '时间', dataIndex: 'created_at', width: 180 }, { title: '租户', dataIndex: 'tenant_name', width: 120, ellipsis: true, render({ record }) { return record.tenant_name || record.tenant_id }}, @@ -180,7 +180,6 @@ const columns: TableColumnData[] = [ return h(Tag, { color: auditLevelTagColor[record.audit_level] || 'gray', size: 'small' }, () => auditLevelLabel[record.audit_level] || record.audit_level) }, }, - { title: '时间', dataIndex: 'created_at', width: 180 }, { title: '操作', dataIndex: 'actions', width: 80, fixed: 'right', render({ record }) { diff --git a/web/admin/src/views/tenant/UsageLogsPage.vue b/web/admin/src/views/tenant/UsageLogsPage.vue index d693ebc..323400a 100644 --- a/web/admin/src/views/tenant/UsageLogsPage.vue +++ b/web/admin/src/views/tenant/UsageLogsPage.vue @@ -1,14 +1,13 @@ - + diff --git a/web/tenant/src/views/marketplace/components/ModelCard.vue b/web/tenant/src/views/marketplace/components/ModelCard.vue new file mode 100644 index 0000000..be91bd0 --- /dev/null +++ b/web/tenant/src/views/marketplace/components/ModelCard.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/web/tenant/src/views/marketplace/components/ModelDetailDrawer.vue b/web/tenant/src/views/marketplace/components/ModelDetailDrawer.vue new file mode 100644 index 0000000..20c8ac3 --- /dev/null +++ b/web/tenant/src/views/marketplace/components/ModelDetailDrawer.vue @@ -0,0 +1,225 @@ + + + diff --git a/web/tenant/src/views/marketplace/marketplaceMeta.ts b/web/tenant/src/views/marketplace/marketplaceMeta.ts new file mode 100644 index 0000000..a178f25 --- /dev/null +++ b/web/tenant/src/views/marketplace/marketplaceMeta.ts @@ -0,0 +1,119 @@ +import type { MarketplaceModel, TimePriceItem } from '@/api/marketplace' + +// 分类元数据:卡片磁贴渐变、筛选 pills 与各类徽章共用同一份配置 +export interface CategoryMeta { + key: string + // null 表示「全部」,请求时不传 category + value: string | null + // 完整名称:卡片类别徽章 / 抽屉 / 规格行 + label: string + // 两个字:筛选 pills 用 + shortLabel: string + // hover 提示 + description: string + // Icon.vue 内置图标名 + icon: string + // 渐变起止色 + from: string + to: string + // 发光阴影 RGB("r, g, b"),供 rgba() 拼接 box-shadow + glow: string +} + +// 色值与全局 .icon-tile-* 系列保持一致,chat 占据主色 teal +export const categoryMetaList: CategoryMeta[] = [ + { key: 'all', value: null, label: '全部模型', shortLabel: '全部', description: '浏览完整模型目录', icon: 'grid', from: '#2dd4bf', to: '#0d9488', glow: '20, 184, 166' }, + { key: 'chat', value: 'chat', label: '对话与推理', shortLabel: '对话', description: '文本生成、推理与工具调用', icon: 'chat', from: '#2dd4bf', to: '#0d9488', glow: '20, 184, 166' }, + { key: 'embedding', value: 'embedding', label: '向量嵌入', shortLabel: '向量', description: '语义检索与知识库构建', icon: 'cube', from: '#a78bfa', to: '#8b5cf6', glow: '139, 92, 246' }, + { key: 'image', value: 'image', label: '图像生成', shortLabel: '图像', description: '文生图、图像理解与编辑', icon: 'eye', from: '#f472b6', to: '#ec4899', glow: '236, 72, 153' }, + { key: 'audio', value: 'audio', label: '音频处理', shortLabel: '音频', description: '语音识别、合成与音乐生成', icon: 'bell', from: '#fbbf24', to: '#f59e0b', glow: '245, 158, 11' }, + { key: 'rerank', value: 'rerank', label: '重排序', shortLabel: '重排', description: '检索结果相关性排序', icon: 'trendingUp', from: '#22d3ee', to: '#06b6d4', glow: '6, 182, 212' }, +] + +// 未知分类的渲染兜底:仅用于展示,绝不进入筛选 pills +export const fallbackCategoryMeta: CategoryMeta = { + key: 'other', value: null, label: '其他模型', shortLabel: '其他', description: '其他模型能力', icon: 'bolt', from: '#d1d5db', to: '#9ca3af', glow: '156, 163, 175', +} + +export function getCategoryMeta(category?: string | null): CategoryMeta { + if (!category) return categoryMetaList[0] + return categoryMetaList.find(item => item.value === category) || fallbackCategoryMeta +} + +// 能力中文标签:合并控制台模型页的映射,缺失的 key 回退为可读文本 +const capabilityLabels: Record = { + streaming: '流式输出', + function_calling: '工具调用', + tool_calling: '工具调用', + parallel_function_calling: '并行工具调用', + tool_choice: '工具选择', + vision: '视觉理解', + multimodal: '多模态', + json_mode: 'JSON 模式', + response_schema: '结构化输出', + system_messages: '系统消息', + reasoning: '深度思考', + web_search: '联网搜索', + image_generation: '图像生成', + prompt_caching: '提示词缓存', + audio: '音频能力', + audio_input: '音频输入', + audio_output: '音频输出', + pdf_input: 'PDF 输入', + embedding_image: '图像嵌入', +} + +// capabilities 是 { key: boolean } 形态,只取为真的项并转成中文标签 +export function getCapabilityList(model: MarketplaceModel, limit = 4): string[] { + if (!model.capabilities || typeof model.capabilities !== 'object') return [] + return Object.entries(model.capabilities) + .filter(([, value]) => value === true || value === 1 || value === 'true') + .map(([key]) => capabilityLabels[key] || key.replaceAll('_', ' ')) + .filter((item, index, list) => list.indexOf(item) === index) + .slice(0, limit) +} + +export function formatTokens(tokens?: number | null): string { + const value = Number(tokens || 0) + if (!value) return '—' + if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M` + if (value >= 1_000) return `${Number((value / 1_000).toFixed(1))}K` + return value.toLocaleString('en-US') +} + +export function formatPrice(price?: number | null): string { + const value = Number(price) + if (!Number.isFinite(value)) return '—' + // 最多保留 6 位小数(与后端 NUMERIC(20,10) 精度对齐),自动去掉末尾的 0 + return `$${Number(value.toFixed(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' ? ' 起' : ''}` +} From 5c08c7c21838740e37eb955db1bc01dd42ee6925 Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 29 Aug 2026 08:43:25 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(log):=E4=BF=AE=E5=A4=8D=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=97=A5=E5=BF=97=E6=9F=A5=E8=AF=A2=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E5=9B=BA=E5=8C=96=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/admin/src/composables/useDateRange.ts | 29 ++++++++++++----------- web/admin/src/router/admin.ts | 6 ----- web/admin/src/views/AdminLayout.vue | 1 - web/admin/src/views/ai/ModelsPage.vue | 8 ++++++- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/web/admin/src/composables/useDateRange.ts b/web/admin/src/composables/useDateRange.ts index 0c33ac6..8b429c5 100644 --- a/web/admin/src/composables/useDateRange.ts +++ b/web/admin/src/composables/useDateRange.ts @@ -10,13 +10,6 @@ function toDateTimeStr(d: Date): string { // 默认查询当天:起始时间为当天 0 点,截止时间留空(后端按「到现在」实时处理)。 // 日期选择器里的「现在」仅用于展示;查询时截止仍为默认「现在」则不传 end_date,避免固定截止时间漏掉后续新记录。 // 该值在模块加载时固定一次,页面间共享,保证「截止等于默认现在则不下发 end_date」的判据一致。 -const defaultEnd = toDateTimeStr(new Date()) - -function defaultTodayRange(): string[] { - const d = new Date() - return [`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} 00:00:00`, defaultEnd] -} - // 指定偏移天数的当天 0 点 / 23:59:59(offsetDays=0 表示今天) function dateStartStr(offsetDays: number): string { const d = new Date() @@ -31,13 +24,21 @@ function dateEndStr(offsetDays: number): string { // 日期时间选择器底部快捷选项:今天 / 昨天 / 近三天 / 近一周。 // 截止「现在」的选项复用 defaultEnd,保持「截止不传 → 后端按到现在实时查询」的语义。 -const quickDateRanges: ShortcutType[] = [ - { label: '今天', value: () => defaultTodayRange() }, - { label: '昨天', value: () => [dateStartStr(1), dateEndStr(1)] }, - { label: '近三天', value: () => [dateStartStr(2), defaultEnd] }, - { label: '近一周', value: () => [dateStartStr(6), defaultEnd] }, -] - export function useDateRange() { + // 默认展示截止时间预留 1 小时;查询仍识别此默认值并省略 end_date, + // 因而后端会实时查询到当前时刻,用户停留页面后刷新也不会漏掉新记录。 + const defaultEndDate = new Date() + defaultEndDate.setHours(defaultEndDate.getHours() + 1) + const defaultEnd = toDateTimeStr(defaultEndDate) + const defaultTodayRange = (): string[] => { + const d = new Date() + return [`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} 00:00:00`, defaultEnd] + } + const quickDateRanges: ShortcutType[] = [ + { label: '今天', value: defaultTodayRange }, + { label: '昨天', value: () => [dateStartStr(1), dateEndStr(1)] }, + { label: '近三天', value: () => [dateStartStr(2), defaultEnd] }, + { label: '近一周', value: () => [dateStartStr(6), defaultEnd] }, + ] return { defaultEnd, defaultTodayRange, quickDateRanges } } diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index 8983eb2..629bcab 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -78,12 +78,6 @@ const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/views/finance/TransactionsPage.vue'), meta: { title: '交易流水' }, }, - { - path: 'plans', - name: 'AdminPlans', - component: () => import('@/views/finance/PlansPage.vue'), - meta: { title: '套餐管理' }, - }, { path: 'orders', name: 'AdminOrders', diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index f541311..ca6ef09 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -337,7 +337,6 @@ const menuGroups = [ label: '财务中心', icon: IconIdcard, items: [ - { name: 'AdminPlans', label: '套餐管理', icon: IconIdcard }, { name: 'AdminOrders', label: '订单管理', icon: IconCodeBlock }, { name: 'AdminTransactions', label: '交易流水', icon: IconCommand }, { name: 'AdminRedemptions', label: '兑换码管理', icon: IconGift }, diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index 2e40753..914f6e5 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -152,6 +152,12 @@ const statusTagLabel: Record = { offline: '下线', } +const pricingModeTagColor: Record = { + token: 'arcoblue', + per_request: 'orangered', + tiered: 'purple', +} + // ===== 移动端卡片自定义布局用的格式化(仅用于卡片插槽,不改动桌面端列 render)===== // token 数紧凑格式化:128000 → 128K,1500000 → 1.5M,无值返回 '-' @@ -218,7 +224,7 @@ const columns: TableColumnData[] = [ } const modeLabel: Record = { 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;' }, From 3bf1b4ca75ddaca1c6dd22db4390eaa1a171fcdd Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 29 Aug 2026 08:44:12 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(channel):=E4=BF=AE=E5=A4=8D=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=80=A7=E8=83=BD=E4=B8=AD=E8=AF=A6=E6=83=85=E7=9A=84?= =?UTF-8?q?tps=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/monitor/api_metrics.go | 15 ++++++++++++--- internal/logic/monitor/api_metrics_test.go | 11 +++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/internal/logic/monitor/api_metrics.go b/internal/logic/monitor/api_metrics.go index 6759a47..9b233ef 100644 --- a/internal/logic/monitor/api_metrics.go +++ b/internal/logic/monitor/api_metrics.go @@ -64,13 +64,22 @@ type trafficFlowRow struct { // normalizeMonitorDateRange 规范化监控类接口的日期范围,返回 YYYY-MM-DD 的 [start, end](含)。 // 缺省近 30 天;end 不超过今天;跨度上限 trafficFlowMaxDays。流量桑基图与模型性能共用。 func normalizeMonitorDateRange(startDate, endDate string) (string, string) { + return normalizeMonitorDateRangeAt(startDate, endDate, time.Now()) +} + +// normalizeMonitorDateRangeAt is separated for deterministic tests. Date-only +// filters must be compared as calendar dates in the server's location, rather +// than against the current instant. Otherwise, on a non-UTC server before its +// UTC offset, today's parsed midnight can incorrectly fall after time.Now() +// and the range silently falls back to the default 30 days. +func normalizeMonitorDateRangeAt(startDate, endDate string, now time.Time) (string, string) { const layout = "2006-01-02" - today := time.Now() - end, err := time.Parse(layout, endDate) + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + end, err := time.ParseInLocation(layout, endDate, now.Location()) if err != nil || end.After(today) { end = today } - start, err := time.Parse(layout, startDate) + start, err := time.ParseInLocation(layout, startDate, now.Location()) if err != nil || start.After(end) { start = end.AddDate(0, 0, -29) } diff --git a/internal/logic/monitor/api_metrics_test.go b/internal/logic/monitor/api_metrics_test.go index 8307a2d..9ec4a9f 100644 --- a/internal/logic/monitor/api_metrics_test.go +++ b/internal/logic/monitor/api_metrics_test.go @@ -5,11 +5,22 @@ import ( "reflect" "strings" "testing" + "time" v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/logic/common" ) +func TestNormalizeMonitorDateRangeKeepsTodayInNonUTCTimezone(t *testing.T) { + location := time.FixedZone("UTC+8", 8*60*60) + now := time.Date(2026, time.August, 29, 0, 10, 0, 0, location) + + start, end := normalizeMonitorDateRangeAt("2026-08-29", "2026-08-29", now) + if start != "2026-08-29" || end != "2026-08-29" { + t.Fatalf("same-day range = [%s, %s], want [2026-08-29, 2026-08-29]", start, end) + } +} + // perfFieldValue 按 JSON 字段名(snake_case)从结构体取值,整数统一归一为 int64、 // 浮点统一为 float64,与旧的 []map[string]any 断言口径保持一致。 func perfFieldValue(item v1.ModelPerformanceSummary, field string) any { From cd019cf81744b23be90aeed8316ffbe0e44a58bb Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 29 Aug 2026 10:22:47 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat(billing):=E6=96=B0=E5=A2=9E=E6=9C=AC?= =?UTF-8?q?=E4=BD=8D=E5=B8=81=E8=AE=BE=E7=BD=AE=EF=BC=8C=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E6=97=B6=E5=8F=AF=E9=80=89=E6=8B=A9=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E8=B4=A7=E5=B8=81=E5=8D=95=E4=BD=8D=EF=BC=8C=E4=B8=80=E6=97=A6?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=B0=B1=E4=B8=8D=E8=83=BD=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 36 +-- internal/consts/consts.go | 9 + internal/handler/setup/setup.go | 17 ++ internal/logic/admin/dashboard.go | 39 ++- internal/logic/admin/tenant.go | 2 +- internal/logic/billing/currency.go | 23 ++ internal/logic/billing/currency_test.go | 147 ++-------- internal/logic/billing/pricing.go | 2 +- internal/logic/billing/settlement.go | 8 +- internal/logic/billing/task_billing.go | 8 +- internal/logic/billing/task_billing_test.go | 21 +- internal/logic/common/config.go | 14 + internal/logic/common/settings_registry.go | 6 +- internal/logic/payment/fulfill.go | 32 ++- internal/logic/payment/settings.go | 7 +- internal/logic/task/async_polling.go | 2 +- internal/logic/tenant/auth.go | 2 +- internal/logic/tenant/billing.go | 4 +- internal/packed/setup.html | 51 +++- migrations/000018_v0_2_15_version.sql | 13 + .../src/components/ModelPricingModal.vue | 40 +-- web/admin/src/composables/useCurrency.ts | 71 +++++ .../src/composables/usePublicSettings.ts | 66 +++++ web/admin/src/router/admin.ts | 5 +- web/admin/src/router/index.ts | 5 +- web/admin/src/views/AdminLayout.vue | 1 - web/admin/src/views/DashboardPage.vue | 29 +- web/admin/src/views/SetupPage.vue | 12 + web/admin/src/views/ai/ModelsPage.vue | 11 +- web/admin/src/views/ai/TaskLogsPage.vue | 9 +- web/admin/src/views/finance/OrdersPage.vue | 5 +- .../src/views/finance/PaymentSettingsPage.vue | 261 ------------------ web/admin/src/views/finance/PlansPage.vue | 5 +- .../src/views/finance/PromoCodesPage.vue | 3 +- .../src/views/finance/RedemptionsPage.vue | 3 +- .../src/views/finance/TransactionsPage.vue | 7 +- .../views/monitor/ModelPerformancePage.vue | 16 +- .../src/views/monitor/TrafficFlowPage.vue | 4 +- .../src/views/system/settings/PaymentTab.vue | 7 +- .../src/views/system/settings/useSettings.ts | 3 + .../src/views/tenant/TenantLevelsPage.vue | 14 +- web/admin/src/views/tenant/TenantsPage.vue | 12 +- web/admin/src/views/tenant/UsageLogsPage.vue | 28 +- web/admin/src/views/tenant/detail/InfoTab.vue | 3 +- .../src/views/tenant/detail/ModelsTab.vue | 22 +- .../views/tenant/detail/TransactionsTab.vue | 7 +- .../src/views/tenant/detail/WalletTab.vue | 53 ++-- .../src/components/charts/ModelDistChart.vue | 7 +- .../src/components/common/ApiKeyEditModal.vue | 5 +- web/tenant/src/composables/useCurrency.ts | 84 ++++++ web/tenant/src/utils/renderUtils.ts | 17 +- web/tenant/src/views/Layout.vue | 5 +- .../src/views/dashboard/DashboardPage.vue | 9 +- .../views/dashboard/PersonalDashboardPage.vue | 9 +- web/tenant/src/views/finance/RedeemPage.vue | 4 +- .../src/views/finance/TransactionsPage.vue | 7 +- web/tenant/src/views/finance/WalletPage.vue | 75 +++-- .../components/ModelDetailDrawer.vue | 12 +- .../src/views/marketplace/marketplaceMeta.ts | 5 +- web/tenant/src/views/service/ApiKeysPage.vue | 6 +- .../src/views/service/ModelComparisonPage.vue | 14 +- web/tenant/src/views/service/ModelsPage.vue | 4 +- web/tenant/src/views/service/TaskLogsPage.vue | 5 +- .../src/views/service/UsageLogsPage.vue | 6 +- .../views/service/playground/calculateCost.ts | 8 +- .../src/views/settings/OrganizationPage.vue | 8 +- .../src/views/team/MemberDetailPage.vue | 11 +- web/tenant/src/views/team/MembersPage.vue | 2 +- .../src/views/team/ProjectDetailPage.vue | 10 +- web/tenant/src/views/team/ProjectsPage.vue | 3 +- 70 files changed, 788 insertions(+), 673 deletions(-) create mode 100644 migrations/000018_v0_2_15_version.sql create mode 100644 web/admin/src/composables/useCurrency.ts create mode 100644 web/admin/src/composables/usePublicSettings.ts delete mode 100644 web/admin/src/views/finance/PaymentSettingsPage.vue create mode 100644 web/tenant/src/composables/useCurrency.ts diff --git a/CLAUDE.md b/CLAUDE.md index 21c5865..ed730ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,15 +282,15 @@ cmd(路由注册) ### 系统货币规则 -平台采用**内部统一 USD + 充值入口 CNY**策略,系统内除充值/支付外的所有金额均为 USD: +平台采用**本位币制 + 充值入口 CNY**策略:系统在初始化向导(setup)时选定**本位币**(`billing_currency`,USD 或 CNY,**选定后不可更改**,存量部署默认 USD),内部记账与全站显示统一使用本位币: | 业务场景 | 货币 | 说明 | |----------|------|------| -| 大模型价格展示和计费 | **USD(美元)** | 模型定价、Token 单价、计费扣费统一使用美元,与上游供应商(OpenAI/Anthropic/Google)报价币种一致 | -| 用户钱包余额 | **USD(美元)** | 钱包存储 USD 余额,充值时 CNY 按汇率转换为 USD 入账 | -| 计费记录 / 交易流水 | **USD(美元)** | 所有 bil_records、bil_transactions 金额均为 USD | +| 大模型价格展示和计费 | **本位币** | 模型定价、Token 单价、计费扣费统一使用本位币(本位币=CNY 的部署按人民币配价) | +| 用户钱包余额 | **本位币** | 本位币=USD 时充值 CNY 按汇率转换为 USD 入账;本位币=CNY 时直接入账无换汇 | +| 计费记录 / 交易流水 | **本位币** | 所有 bil_records、bil_transactions 金额均为本位币 | | 套餐价格 | **CNY(人民币)** | 管理后台创建套餐时设置的价格为人民币,用户购买套餐使用人民币支付 | -| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币,履约时转换为 USD 入账钱包 | +| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币(收款渠道物理现实),履约时按本位币入账钱包 | #### 三层币种固定规则(强约束,写新代码必须遵守) @@ -299,25 +299,29 @@ cmd(路由注册) | 层 | 表前缀 | 币种 | 说明 | |----|--------|------|------| | **订单 / 支付层** | `ord_` | **永远 CNY** | 充值订单、套餐订单、退款,金额一律人民币。`ord_orders.currency` 默认值必须为 `CNY` | -| **钱包 / 计费 / 账本层** | `bil_` | **永远 USD** | 钱包余额、冻结余额、累计充值、交易流水、计费记录、用量、预扣/结算/退款,一律美元 | -| **套餐定价** | `pln_` | **CNY** | 套餐卖的是 token 额度(`monthly_quota_tokens`),价格用人民币,不与 USD 钱包直接比较 | -| **租户等级阈值** | `tnt_tenant_level_configs.cumulative_recharge_threshold` | **USD** | 与 `bil_wallets.cumulative_recharge`(USD)同币种比较 | +| **钱包 / 计费 / 账本层** | `bil_` | **本位币** | 钱包余额、冻结余额、累计充值、交易流水、计费记录、用量、预扣/结算/退款,一律本位币(部署级常量,`billing.Currency(ctx)` 读取;初始化选定后不可切换,账本永远单币种) | +| **套餐定价** | `pln_` | **CNY** | 套餐卖的是 token 额度(`monthly_quota_tokens`),价格用人民币,不与钱包余额直接比较 | +| **租户等级阈值** | `tnt_tenant_level_configs.cumulative_recharge_threshold` | **本位币** | 与 `bil_wallets.cumulative_recharge`(本位币)同币种比较 | -**唯一换汇点**:CNY → USD 的转换**只允许发生在充值履约**(`payment.FulfillOrder` 的 `recharge` 分支,调用 `billing.ConvertCNYToUSD`)。除此之外任何地方都不得做币种换算——套餐购买不入钱包不换算,计费引擎全程 USD 不换算。 +**唯一换汇点**:CNY → 本位币的转换**只允许发生在充值履约**(`payment.FulfillOrder` 的 `recharge` 分支与线下充值 `OfflineRecharge`,本位币=USD 时调用 `billing.ConvertCNYToUSD`;本位币=CNY 时直接入账,`exchange_rate` 快照存 1)。除此之外任何地方都不得做币种换算——套餐购买不入钱包不换算,计费引擎全程本位币不换算。 **新增带金额字段的代码前,先确认它属于哪一层**,按上表取币种;跨层传递金额(如订单金额入钱包)必须经过唯一换汇点。 #### 汇率规则 -- 只维护**单一方向**汇率 `payment_exchange_rate_cny_to_usd`(管理后台支付设置配置,默认 0.14)。需要反向(USD→CNY)时**取倒数**,禁止再配置第二个独立汇率,避免两个汇率漂移导致往返不闭合。 -- **充值时必须快照汇率**:履约入账时除了写入换算后的 USD,还要持久化「原始 CNY 金额 + 当时汇率 + 入账 USD」,使历史换算可重建。汇率配置变更不得影响已完成订单的对账。 -- **退款认原始 CNY 金额**:退款按订单原始人民币金额退回支付渠道,**禁止**用钱包当前 USD 余额按当前汇率反向折算(防止汇率波动产生套利/亏损)。 +- 只维护**单一方向**汇率 `payment_exchange_rate_cny_to_usd`(管理后台支付设置配置,默认 0.14,已 IsPublic 暴露给前端做展示折算)。需要反向(USD→CNY)时**取倒数**,禁止再配置第二个独立汇率,避免两个汇率漂移导致往返不闭合。 +- **充值时必须快照汇率**:履约入账时除了写入换算后的本位币金额(`credited_usd` 列,语义为入账本位币金额),还要持久化「原始 CNY 金额 + 当时汇率 + 入账金额」,使历史换算可重建(本位币=CNY 时 `exchange_rate` 存 1)。汇率配置变更不得影响已完成订单的对账。 +- **退款认原始 CNY 金额**:退款按订单原始人民币金额退回支付渠道,**禁止**用钱包当前本位币余额按当前汇率反向折算(防止汇率波动产生套利/亏损)。 #### 前端展示规则 -- 钱包余额 / 交易流水 / 计费 / 用量 / Token 单价 → `$`(USD) -- 套餐价格 / 充值金额 / 订单金额 / 退款金额 → `¥`(CNY) -- ⚠️ 营收类指标(`total_cost`、`revenue` 等来自 `bil_usage_logs` 的统计)是 **USD**,用 `$`,不要因为面向中国区运营就标成 `¥`。需要人民币营收报表时,在**出报表时**按快照汇率折算,账本本身仍存 USD。 +全站显示货币 = 本位币(`/settings/public` 暴露 `billing_currency` + 汇率,两端 `composables/useCurrency.ts` 统一格式化): + +- bil 层数据(钱包 / 交易流水 / 计费 / 用量 / Token 单价 / 阈值 / 兑换码面值)→ 本位币符号直显,**不折算** +- ord_/pln_ 层数据(套餐价格 / 充值金额 / 订单金额 / 退款金额 / 优惠码折扣,存 CNY)→ 本位币=CNY 直显 `¥`;本位币=USD 时按汇率折算显示 `$`(**唯一折算场景**) +- **编辑表单保持存储币种原值输入**(模型定价按本位币、套餐价格按 CNY),label 标注存储币种;折算仅用于只读展示 +- 充值页锚定 CNY(订单/履约/折扣口径),本位币=USD 时档位与输入按汇率折算显示、提交时换算回 CNY +- ⚠️ 营收类指标(`total_cost`、`revenue` 等来自 `bil_usage_logs` 的统计)是本位币,跟随本位币符号;需要另一币种营收报表时,在**出报表时**按快照汇率折算,账本本身仍存本位币。 #### 人民币对账 / 导出规则 @@ -378,7 +382,7 @@ cmd(路由注册) - **DB 落库**:decimal 直传 ORM(`.Insert(do)`/`.Update(map)`)或原生 SQL 参数(`balance + ?`)——shopspring decimal 实现 `driver.Valuer`,写出精确 NUMERIC 字符串,**这条路径不要转 float64**。 - **JSONB 陷阱**:decimal 默认 marshal 成**带引号的字符串**,写进「读取方期望 number」的 JSONB blob(如快照)会破坏整个 unmarshal——JSONB 缝隙处必须先 `.InexactFloat64()`。(注意:DB 列 ≠ JSONB,前者走 Valuer 精确直传,后者走 JSON 需转 float64。) - **Redis 钱包**:用整数 micro-USD(`int64` + `HINCRBY`),`billing.ToMicro`/`FromMicro` 转换,杜绝 `HINCRBYFLOAT` 漂移。 - - **唯一换汇点**:CNY→USD 仅在 `payment.FulfillOrder` recharge 分支调 `billing.ConvertCNYToUSD`(返回 decimal),其余地方不换算(见《系统货币规则》三层币种固定规则)。 + - **唯一换汇点**:CNY→本位币仅在 `payment.FulfillOrder` recharge 分支与 `OfflineRecharge` 调 `billing.ConvertCNYToUSD`(返回 decimal,本位币=CNY 时直接入账无换算),其余地方不换算(见《系统货币规则》三层币种固定规则)。 - GoFrame 标准项目结构,不要偏离。框架使用规范(时间处理、错误处理、ORM 模式、日志等)详见 [`docs/reference/goframe-conventions.md`](docs/reference/goframe-conventions.md),遇到不确定的 GoFrame 用法时善用 `/goframe-v2` skill 查询框架规范 - **修复 GoFrame 框架使用 bug 时**,必须在 `docs/reference/goframe-conventions.md` 末尾的「已修复的框架使用错误记录」章节追加记录(问题描述、原因、修复方式),防止同类问题重现 - 数据库迁移用 goose,脚本按六位序号递增编号(`migrations/000001_xxx.sql`) diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 51fae0b..98c4549 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -404,6 +404,15 @@ const ( AgreementCodePrivacy = "privacy" // 隐私政策 ) +// 系统配置 key:本位币(系统初始化向导选定后不可更改,bil_ 记账层与全站显示货币) +const OptionKeyBillingCurrency = "billing_currency" + +// 本位币取值 +const ( + BillingCurrencyUSD = "USD" + BillingCurrencyCNY = "CNY" +) + // ValidAgreementCodes 有效协议标识码集合(创建时校验) var ValidAgreementCodes = map[string]string{ AgreementCodeTerms: "用户协议", diff --git a/internal/handler/setup/setup.go b/internal/handler/setup/setup.go index 901da01..4613b89 100644 --- a/internal/handler/setup/setup.go +++ b/internal/handler/setup/setup.go @@ -56,12 +56,23 @@ func HandleSetupInitialize(r *ghttp.Request) { DisplayName string `json:"displayName"` Password string `json:"password"` ConfirmPassword string `json:"confirmPassword"` + Currency string `json:"currency"` } if err := json.Unmarshal(r.GetBody(), &req); err != nil { response.ErrorMsg(r, consts.CodeBadRequest, "请求格式错误") return } + // 本位币:USD/CNY,未选择时默认 USD;初始化写入后不可再更改 + currency := req.Currency + if currency == "" { + currency = consts.BillingCurrencyUSD + } + if currency != consts.BillingCurrencyUSD && currency != consts.BillingCurrencyCNY { + response.ErrorMsg(r, consts.CodeBadRequest, "本位币仅支持 USD 或 CNY") + return + } + // Validate required fields if req.Username == "" || req.Password == "" { response.ErrorMsg(r, consts.CodeBadRequest, "用户名和密码不能为空") @@ -95,6 +106,12 @@ func HandleSetupInitialize(r *ghttp.Request) { response.Error(r, err) return } + + // 本位币随初始化写入(首次落库,此后 SetOption 只读保护拒绝修改) + if err := common.Config().SetOption(r.Context(), consts.OptionKeyBillingCurrency, currency); err != nil { + response.Error(r, err) + return + } SetSetupComplete() response.Success(r, nil) } diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index 76daff4..bb2e7f9 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -675,9 +675,10 @@ func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq } // OfflineRecharge 线下充值入账(管理后台) -// 场景:用户线下银行转账(人民币 CNY),运营确认到账后按平台汇率换算为 USD 入账。 +// 场景:用户线下银行转账(人民币 CNY),运营确认到账后入账。 +// 本位币=USD 时按平台汇率换算入账;本位币=CNY 时直接入账(无换汇)。 // 与 AdjustBalance 的区别:走正规充值链路——余额与累计充值同步累加、触发等级检查、 -// 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账 USD + 转账流水号), +// 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账金额 + 转账流水号), // 供现金对账与开票追溯。 func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error) { tenantID := req.TenantID @@ -686,12 +687,19 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline return nil, common.NewBadRequestError("入账金额必须大于 0") } - // 唯一换汇点:CNY→USD 只经 billing.ConvertCNYToUSD(与充值履约 FulfillOrder 同一函数), - // 汇率取一次用于入账与快照,保证换算可重建。 - rate := billing.GetExchangeRateCNYToUSD(ctx) - usdAmount := billing.ConvertCNYToUSD(ctx, req.Amount) - if usdAmount.IsZero() { - return nil, common.NewBadRequestError("按当前汇率换算后到账金额为 0,请检查汇率配置") + // 本位币=CNY:直接入账;本位币=USD:换汇入账(CNY→USD 只经 billing.ConvertCNYToUSD, + // 与充值履约 FulfillOrder 同一函数),汇率取一次用于入账与快照,保证换算可重建。 + var rate float64 + var creditAmount decimal.Decimal + if billing.IsCNY(ctx) { + rate = 1 + creditAmount = cnyAmount + } else { + rate = billing.GetExchangeRateCNYToUSD(ctx) + creditAmount = billing.ConvertCNYToUSD(ctx, req.Amount) + if creditAmount.IsZero() { + return nil, common.NewBadRequestError("按当前汇率换算后到账金额为 0,请检查汇率配置") + } } // 转账流水号软去重(防重复入账):同一租户下已入账过该流水号则拒绝。 @@ -721,9 +729,9 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline } // Redis 加款(资金提交点);流水快照取 Redis 返回值 - balanceAfter, frozenAfter, err := billing.CreditWalletRedis(ctx, tenantID, usdAmount) + balanceAfter, frozenAfter, err := billing.CreditWalletRedis(ctx, tenantID, creditAmount) // 先记录已发生的 Redis 加款(可能 >0 即使整体失败),供事务回滚后补偿逆转 - redisCreditedAmount = usdAmount + redisCreditedAmount = creditAmount if err != nil { return err } @@ -731,12 +739,17 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline // DB 仅累加累计充值(余额由物化器从 Redis 覆盖) if _, err := g.DB().Ctx(ctx).Exec(ctx, "UPDATE bil_wallets SET cumulative_recharge = cumulative_recharge + ?, updated_at = NOW() WHERE id = ?", - usdAmount, walletID); err != nil { + creditAmount, walletID); err != nil { return err } // 流水(type=recharge):描述拼装 CNY 快照 + 转账流水号,供现金对账与开票追溯 - desc := fmt.Sprintf("线下充值入账 CNY %.2f × 汇率 %.6f = USD %s", cnyAmount.InexactFloat64(), rate, usdAmount) + var desc string + if billing.IsCNY(ctx) { + desc = fmt.Sprintf("线下充值入账 CNY %.2f(本位币直接入账)", cnyAmount.InexactFloat64()) + } else { + desc = fmt.Sprintf("线下充值入账 CNY %.2f × 汇率 %.6f = USD %s", cnyAmount.InexactFloat64(), rate, creditAmount) + } if req.Description != "" { desc = req.Description + ";" + desc } @@ -747,7 +760,7 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline TenantId: tenantID, WalletId: walletID, Type: "recharge", - Amount: usdAmount, + Amount: creditAmount, BalanceAfter: balanceAfter, FrozenAfter: frozenAfter, Description: desc, diff --git a/internal/logic/admin/tenant.go b/internal/logic/admin/tenant.go index 92d1a88..d265f8b 100644 --- a/internal/logic/admin/tenant.go +++ b/internal/logic/admin/tenant.go @@ -174,7 +174,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1 Balance: 0, FrozenBalance: 0, WarningThreshold: &warningThreshold, - Currency: "USD", + Currency: billing.Currency(ctx), }).Insert() if err != nil { return gerror.Wrapf(err, "create wallet") diff --git a/internal/logic/billing/currency.go b/internal/logic/billing/currency.go index 5225e31..f75ef5e 100644 --- a/internal/logic/billing/currency.go +++ b/internal/logic/billing/currency.go @@ -3,6 +3,7 @@ package billing import ( "context" + "github.com/qianfree/team-api/internal/consts" lcommon "github.com/qianfree/team-api/internal/logic/common" "github.com/shopspring/decimal" ) @@ -10,6 +11,28 @@ import ( const defaultCNYToUSD = 0.14 const defaultUSDToCNY = 7.142857142857143 // 1 / 0.14,确保互为倒数 +// Currency 返回系统本位币(bil_ 记账层的记账币种与全站显示货币)。 +// 本位币在系统初始化向导选定后不可更改;存量部署与未初始化系统默认 USD。 +// 配置基础设施不可用(如单测环境无 DB 驱动,g.DB() 直接 panic)时回退 USD, +// 与存量部署行为一致,保证纯函数单测可运行。 +func Currency(ctx context.Context) (cur string) { + defer func() { + if r := recover(); r != nil { + cur = consts.BillingCurrencyUSD + } + }() + c := lcommon.Config().GetString(ctx, consts.OptionKeyBillingCurrency) + if c == consts.BillingCurrencyCNY { + return consts.BillingCurrencyCNY + } + return consts.BillingCurrencyUSD +} + +// IsCNY 当前本位币是否为人民币(此时充值履约直接入账、无换汇环节) +func IsCNY(ctx context.Context) bool { + return Currency(ctx) == consts.BillingCurrencyCNY +} + // ConvertCNYToUSD 将人民币金额转换为美元,向上取整到小数点后 6 位(decimal 原生版)。 // 用 decimal 精确乘法替代 float64 链式运算,避免 cnyAmount×rate 的浮点误差。 func ConvertCNYToUSD(ctx context.Context, cnyAmount float64) decimal.Decimal { diff --git a/internal/logic/billing/currency_test.go b/internal/logic/billing/currency_test.go index 071fa30..8e171da 100644 --- a/internal/logic/billing/currency_test.go +++ b/internal/logic/billing/currency_test.go @@ -1,143 +1,32 @@ package billing import ( + "context" "testing" - "github.com/gogf/gf/v2/test/gtest" - "github.com/shopspring/decimal" + "github.com/qianfree/team-api/internal/consts" ) -func TestDefaultCNYToUSD(t *testing.T) { - if defaultCNYToUSD <= 0 { - t.Fatalf("defaultCNYToUSD should be positive, got %f", defaultCNYToUSD) +// TestCurrency_FallbackUSD 单测环境无 DB 驱动(Config 读取触发 g.DB() panic), +// Currency 必须 recover 兜底返回默认 USD(与存量部署行为一致),不得 panic。 +// 若在带完整配置/DB 的环境运行,读到已配置本位币则跳过断言。 +func TestCurrency_FallbackUSD(t *testing.T) { + got := Currency(context.Background()) + if got != consts.BillingCurrencyUSD && got != consts.BillingCurrencyCNY { + t.Errorf("Currency() = %q, want USD or CNY", got) + } + if !billingCurrencyValid(got) { + t.Errorf("Currency() = %q, invalid currency", got) } } -func TestDefaultUSDToCNY(t *testing.T) { - if defaultUSDToCNY <= 0 { - t.Fatalf("defaultUSDToCNY should be positive, got %f", defaultUSDToCNY) +func TestIsCNY_MatchesCurrency(t *testing.T) { + c := Currency(context.Background()) + if want := c == consts.BillingCurrencyCNY; IsCNY(context.Background()) != want { + t.Errorf("IsCNY() 与 Currency() 不一致: currency=%q", c) } } -func TestDefaultRates_Reciprocal(t *testing.T) { - // 修复后:defaultUSDToCNY 不再是独立配置,而是 defaultCNYToUSD 的倒数 - // 验证常量定义互为倒数 - product := defaultCNYToUSD * defaultUSDToCNY - if product < 0.9 || product > 1.1 { - t.Fatalf("default rate product should be ~1.0, got %f", product) - } -} - -func TestConvertCNYToUSD_DefaultRate(t *testing.T) { - // 无配置时使用默认汇率 0.14 - tests := []struct { - name string - cny float64 - expected float64 - }{ - {"zero", 0, 0}, - {"100 CNY", 100, 100 * defaultCNYToUSD}, - {"1 CNY", 1, 1 * defaultCNYToUSD}, - {"large amount", 10000, 10000 * defaultCNYToUSD}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - actual := tt.cny * defaultCNYToUSD - if diff := actual - tt.expected; diff < -0.000001 || diff > 0.000001 { - t.Errorf("CNY %f → USD: expected %f, got %f", tt.cny, tt.expected, actual) - } - }) - } -} - -func TestConvertUSDToCNY_DefaultRate(t *testing.T) { - tests := []struct { - name string - usd float64 - expected float64 - }{ - {"zero", 0, 0}, - {"1 USD", 1, defaultUSDToCNY}, - {"14 USD", 14, 14 * defaultUSDToCNY}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - actual := tt.usd * defaultUSDToCNY - if diff := actual - tt.expected; diff < -0.000001 || diff > 0.000001 { - t.Errorf("USD %f → CNY: expected %f, got %f", tt.usd, tt.expected, actual) - } - }) - } -} - -func TestCurrencyConstants_NonZero(t *testing.T) { - if defaultCNYToUSD == 0 { - t.Error("defaultCNYToUSD must not be zero") - } - if defaultUSDToCNY == 0 { - t.Error("defaultUSDToCNY must not be zero") - } -} - -// TestExchangeRateReciprocal 验证 USD→CNY 汇率是 CNY→USD 的倒数(往返闭合) -func TestExchangeRateReciprocal(t *testing.T) { - // 测试默认汇率互为倒数 - reciprocal := 1.0 / defaultCNYToUSD - diff := reciprocal - defaultUSDToCNY - if diff < 0 { - diff = -diff - } - // 允许误差 0.01(因为默认常量可能不完全精确) - if diff > 0.01 { - t.Errorf("默认汇率应互为倒数:1/%.4f = %.4f, 但 defaultUSDToCNY = %.4f, 差值 = %.6f", - defaultCNYToUSD, reciprocal, defaultUSDToCNY, diff) - } -} - -// TestConvertRoundTrip 验证 CNY→USD→CNY 往返转换闭合(误差在可接受范围内) -func TestConvertRoundTrip(t *testing.T) { - gtest.C(t, func(t *gtest.T) { - // 测试多个典型金额 - testAmounts := []float64{100.0, 50.5, 1.0, 999.99} - - for _, originalCNY := range testAmounts { - // CNY → USD(使用默认汇率模拟) - usd := decimal.NewFromFloat(originalCNY).Mul(decimal.NewFromFloat(defaultCNYToUSD)) - - // USD → CNY(使用倒数) - cny := usd.Mul(decimal.NewFromFloat(1.0 / defaultCNYToUSD)) - - // 验证往返误差 < 0.01 元(1分钱) - diff := cny.Sub(decimal.NewFromFloat(originalCNY)).Abs() - t.AssertLT(InexactFloat64(diff), 0.01) - } - }) -} - -// TestConvertCNYToUSD_Precision 验证 CNY→USD 转换的精度(使用默认汇率) -func TestConvertCNYToUSD_Precision(t *testing.T) { - // 100 CNY × 0.14 = 14 USD - usd := decimal.NewFromFloat(100.0).Mul(decimal.NewFromFloat(defaultCNYToUSD)) - expected := decimal.NewFromFloat(14.0) - - diff := usd.Sub(expected).Abs() - if InexactFloat64(diff) > 0.000001 { - t.Errorf("100 CNY 应转换为约 14 USD,实际=%s, 误差=%s", usd.String(), diff.String()) - } -} - -// TestConvertUSDToCNY_Precision 验证 USD→CNY 转换的精度(使用倒数) -func TestConvertUSDToCNY_Precision(t *testing.T) { - // 14 USD × (1/0.14) ≈ 100 CNY - usd := decimal.NewFromFloat(14.0) - reciprocalRate := 1.0 / defaultCNYToUSD - cny := usd.Mul(decimal.NewFromFloat(reciprocalRate)) - expected := decimal.NewFromFloat(100.0) - - diff := cny.Sub(expected).Abs() - if InexactFloat64(diff) > 0.01 { - t.Errorf("14 USD 应转换为约 100 CNY,实际=%s, 误差=%s", cny.String(), diff.String()) - } +func billingCurrencyValid(c string) bool { + return c == consts.BillingCurrencyUSD || c == consts.BillingCurrencyCNY } diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index 948d21f..5130b0e 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -322,7 +322,7 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill DiscountRatio: discountRatio, TenantMultiplier: tenantMultiplier, ModelMultiplier: modelMultiplier, - Currency: "USD", + Currency: Currency(ctx), CacheReadPrice: cacheReadPrice, CacheCreationPrice: cacheCreationPrice, CacheCreation5mPrice: cacheCreationPrice, // 5m TTL = cache_creation_price 基础价(对应官方 1.25× 输入价) diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go index ce24fd4..0c6b15a 100644 --- a/internal/logic/billing/settlement.go +++ b/internal/logic/billing/settlement.go @@ -166,7 +166,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, BaseCost: preDeductAmount, InputTokens: inputTokens, OutputTokens: outputTokens, - Currency: "USD", + Currency: Currency(ctx), } } else { breakdown = computeCost(pricingResult, inputTokens, outputTokens, nil) @@ -284,7 +284,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID fb := &CostBreakdown{ TotalCost: preDeductAmount, BaseCost: preDeductAmount, - Currency: "USD", + Currency: Currency(ctx), } if usage != nil { fb.InputTokens = usage.PromptTokens @@ -459,7 +459,7 @@ func createBillingRecord(ctx context.Context, tenantID, userID, apiKeyID, channe InputPrice: inputPrice, OutputPrice: outputPrice, TotalCost: totalCost, - Currency: "USD", + Currency: Currency(ctx), Status: "settled", SettledAt: gtime.NewFromTime(now), } @@ -545,7 +545,7 @@ func createBillingRecordWithSnapshot(ctx context.Context, tenantID, userID, apiK InputPrice: inputPrice, OutputPrice: outputPrice, TotalCost: totalCost, - Currency: "USD", + Currency: Currency(ctx), Status: "settled", SettledAt: gtime.NewFromTime(now), } diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 28a0b2c..02534df 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -165,7 +165,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI // 2. 获取定价(事务外只读,按任务受理时刻评估时段乘数) pricing, _ := GetModelPriceAt(ctx, tenantID, modelName, billAt) - breakdown := buildTaskCostBreakdown(pricing, InexactFloat64(actualCost), totalTokens, completionTokens) + breakdown := buildTaskCostBreakdown(ctx, pricing, InexactFloat64(actualCost), totalTokens, completionTokens) var billingMode string var discountRatio, effectiveOutputPrice float64 @@ -199,7 +199,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI InputPrice: 0, OutputPrice: effectiveOutputPrice, TotalCost: actualCost, - Currency: "USD", + Currency: Currency(ctx), Status: "settled", SettledAt: gtime.NewFromTime(time.Now()), BillingMode: billingMode, @@ -311,12 +311,12 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI } // buildTaskCostBreakdown 构建任务计费的 CostBreakdown -func buildTaskCostBreakdown(pricing *PricingResult, actualCost float64, totalTokens, _ int) *CostBreakdown { +func buildTaskCostBreakdown(ctx context.Context, pricing *PricingResult, actualCost float64, totalTokens, _ int) *CostBreakdown { if pricing == nil { return &CostBreakdown{ TotalCost: actualCost, BaseCost: actualCost, - Currency: "USD", + Currency: Currency(ctx), } } diff --git a/internal/logic/billing/task_billing_test.go b/internal/logic/billing/task_billing_test.go index 50d401e..ca2724c 100644 --- a/internal/logic/billing/task_billing_test.go +++ b/internal/logic/billing/task_billing_test.go @@ -1,11 +1,12 @@ package billing import ( + "context" "testing" ) func TestBuildTaskCostBreakdown_NilPricing(t *testing.T) { - bd := buildTaskCostBreakdown(nil, 0.5, 1000, 500) + bd := buildTaskCostBreakdown(context.Background(), nil, 0.5, 1000, 500) if bd == nil { t.Fatal("expected non-nil breakdown") } @@ -25,7 +26,7 @@ func TestBuildTaskCostBreakdown_PerRequestMode(t *testing.T) { Currency: "USD", } - bd := buildTaskCostBreakdown(pricing, 0.10, 0, 0) + bd := buildTaskCostBreakdown(context.Background(), pricing, 0.10, 0, 0) assertFloat(t, bd.BaseCost, 0.10, "BaseCost") assertFloat(t, bd.TotalCost, 0.10, "TotalCost") assertFloat(t, bd.PerRequestPrice, 0.10, "PerRequestPrice") @@ -43,7 +44,7 @@ func TestBuildTaskCostBreakdown_TokenMode(t *testing.T) { Currency: "USD", } - bd := buildTaskCostBreakdown(pricing, 0.024, 10000, 5000) + bd := buildTaskCostBreakdown(context.Background(), pricing, 0.024, 10000, 5000) if bd.OutputTokens != 10000 { t.Errorf("OutputTokens = %d, want 10000", bd.OutputTokens) } @@ -61,7 +62,7 @@ func TestBuildTaskCostBreakdown_TokenMode_ZeroTenantMultiplier(t *testing.T) { Currency: "USD", } - bd := buildTaskCostBreakdown(pricing, 0.01, 1000, 500) + bd := buildTaskCostBreakdown(context.Background(), pricing, 0.01, 1000, 500) // tenantMul == 0 → BaseCost = actualCost (no division) assertFloat(t, bd.BaseCost, 0.01, "BaseCost") assertFloat(t, bd.TotalCost, 0.01, "TotalCost") @@ -75,7 +76,7 @@ func TestBuildTaskCostBreakdown_TokenMode_TotalTokens(t *testing.T) { Currency: "USD", } - bd := buildTaskCostBreakdown(pricing, 0.05, 10000, 3000) + bd := buildTaskCostBreakdown(context.Background(), pricing, 0.05, 10000, 3000) if bd.OutputTokens != 10000 { t.Errorf("OutputTokens = %d, want 10000", bd.OutputTokens) } @@ -90,7 +91,7 @@ func TestBuildTaskCostBreakdown_CarriesPricingFields(t *testing.T) { Currency: "USD", } - bd := buildTaskCostBreakdown(pricing, 0.50, 0, 0) + bd := buildTaskCostBreakdown(context.Background(), pricing, 0.50, 0, 0) assertFloat(t, bd.DiscountRatio, 0.85, "DiscountRatio") assertFloat(t, bd.TenantMultiplier, 0.85, "TenantMultiplier") if bd.Currency != "USD" { @@ -109,7 +110,7 @@ func TestBuildTaskCostBreakdown_TokenMode_ZeroTokens(t *testing.T) { Currency: "USD", } - bd := buildTaskCostBreakdown(pricing, 3.375, 0, 0) + bd := buildTaskCostBreakdown(context.Background(), pricing, 3.375, 0, 0) if bd.OutputTokens != 0 { t.Errorf("OutputTokens = %d, want 0", bd.OutputTokens) } @@ -130,12 +131,12 @@ func TestBuildTaskCostBreakdown_TokenMode_TimeMultiplier(t *testing.T) { } // 0.4 / (0.8 × 0.5) = 1.0 - bd := buildTaskCostBreakdown(pricing, 0.4, 10000, 5000) + bd := buildTaskCostBreakdown(context.Background(), pricing, 0.4, 10000, 5000) assertFloat(t, bd.BaseCost, 1.0, "BaseCost (tenant × time)") assertFloat(t, bd.TotalCost, 0.4, "TotalCost") // 无 token 用量分支同样按双乘数还原:0.08 / (0.8 × 0.5) = 0.2 - bd = buildTaskCostBreakdown(pricing, 0.08, 0, 0) + bd = buildTaskCostBreakdown(context.Background(), pricing, 0.08, 0, 0) assertFloat(t, bd.BaseCost, 0.2, "BaseCost zero-token (tenant × time)") assertFloat(t, bd.TotalCost, 0.08, "TotalCost zero-token") } @@ -152,7 +153,7 @@ func TestBuildTaskCostBreakdown_ZeroTimeMultiplierFallback(t *testing.T) { } // 0.024 / (0.8 × 1.0) = 0.03 - bd := buildTaskCostBreakdown(pricing, 0.024, 1000, 500) + bd := buildTaskCostBreakdown(context.Background(), pricing, 0.024, 1000, 500) assertFloat(t, bd.BaseCost, 0.03, "BaseCost (time fallback 1.0)") } diff --git a/internal/logic/common/config.go b/internal/logic/common/config.go index 3c82cc7..18d72a4 100644 --- a/internal/logic/common/config.go +++ b/internal/logic/common/config.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" do "github.com/qianfree/team-api/internal/model/do" "strconv" @@ -12,6 +13,7 @@ import ( "time" "github.com/gogf/gf/v2/database/gredis" + "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" "golang.org/x/sync/singleflight" @@ -220,6 +222,18 @@ func (s *ConfigService) SetOption(ctx context.Context, key, value string) error return err } + // 本位币只在首次写入(系统初始化向导)时允许设置,此后只读; + // 相同值放行,保证设置页保存整个 payment 分类时不被误拒。 + if key == consts.OptionKeyBillingCurrency && count > 0 { + existing, err := dao.SysOptions.Ctx(ctx).Where("key", key).Value() + if err != nil { + return err + } + if old := gconv.String(existing); old != "" && old != value { + return gerror.New("本位币在系统初始化后不可更改") + } + } + if count > 0 { data := do.SysOptions{Value: value} // Sync metadata from registry so that changes to IsPublic/Category/Description diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 128f1bc..e4a07ae 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -154,12 +154,12 @@ var Registry = []SettingDef{ Label: "充值折扣", Description: "JSON 对象,如 {\"100\":0.9} 表示充100享9折"}, {Key: "payment_min_topup", Type: SettingTypeFloat, Default: "1", Category: "payment", Label: "最低充值金额", Validation: "min:0.01"}, - {Key: "payment_currency", Type: SettingTypeString, Default: "CNY", Category: "payment", - Label: "货币单位", Validation: "enum:CNY,USD"}, + {Key: "billing_currency", Type: SettingTypeString, Default: "USD", Category: "payment", + Label: "本位币", Description: "系统内部记账与全站显示的货币(USD/CNY)。仅在系统初始化向导中选定,初始化后不可更改;存量部署默认 USD", Validation: "enum:USD,CNY", IsPublic: true}, {Key: "payment_callback_base_url", Type: SettingTypeString, Default: "", Category: "payment", Label: "支付回调基础URL", Description: "为空则使用请求 Host"}, {Key: "payment_exchange_rate_cny_to_usd", Type: SettingTypeFloat, Default: "0.14", Category: "payment", - Label: "CNY → USD 兑换比例", Description: "1 人民币兑换多少美元(USD → CNY 自动取倒数,确保往返闭合)", Validation: "min:0.001,max:100"}, + Label: "CNY → USD 兑换比例", Description: "1 人民币兑换多少美元(USD → CNY 自动取倒数,确保往返闭合)", Validation: "min:0.001,max:100", IsPublic: true}, {Key: "payment_notice", Type: SettingTypeString, Default: "", Category: "payment", Label: "支付说明", Description: "展示在租户端钱包充值页的说明文案,支持纯文本或 HTML 代码(如提示充值后不支持退款、指定渠道折扣活动等)。纯文本换行需用
,富文本请直接粘贴 HTML"}, diff --git a/internal/logic/payment/fulfill.go b/internal/logic/payment/fulfill.go index ea19e71..2ac6842 100644 --- a/internal/logic/payment/fulfill.go +++ b/internal/logic/payment/fulfill.go @@ -66,22 +66,36 @@ func FulfillOrder(ctx context.Context, orderID int64) error { case "recharge": // 折扣语义「付折后价、按原价入账」:用户实付 FinalAmount(折后 CNY), - // 钱包按订单原价 Amount 换算 USD 入账,折扣让利真实生效;无折扣时两者相等。 - // 汇率只取一次,保证入账换算与快照使用同一汇率。 - rate := billing.GetExchangeRateCNYToUSD(ctx) - usdAmount := billing.CeilUSD(billing.NewFromFloat(order.Amount).Mul(billing.NewFromFloat(rate))) - credited, err := creditWalletTx(ctx, order.TenantID, usdAmount, orderID, fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → USD %.6f)", orderID, order.Amount, order.FinalAmount, usdAmount.InexactFloat64())) + // 钱包按订单原价 Amount 入账,折扣让利真实生效;无折扣时两者相等。 + var creditAmount decimal.Decimal + var desc string + if billing.IsCNY(ctx) { + // 本位币=CNY:订单层与记账层同为人民币,按原价直接入账,无换汇环节 + creditAmount = billing.NewFromFloat(order.Amount) + desc = fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → CNY %.2f)", orderID, order.Amount, order.FinalAmount, creditAmount.InexactFloat64()) + } else { + // 本位币=USD:按原价换算美元入账;汇率只取一次,保证入账换算与快照使用同一汇率。 + rate := billing.GetExchangeRateCNYToUSD(ctx) + creditAmount = billing.CeilUSD(billing.NewFromFloat(order.Amount).Mul(billing.NewFromFloat(rate))) + desc = fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → USD %.6f)", orderID, order.Amount, order.FinalAmount, creditAmount.InexactFloat64()) + } + credited, err := creditWalletTx(ctx, order.TenantID, creditAmount, orderID, desc) // 先记录已发生的 Redis 加款(可能 >0 即使整体失败),供事务回滚后补偿逆转 redisCreditedTenantID = order.TenantID redisCreditedAmount = credited if err != nil { return gerror.Wrapf(err, "credit wallet failed") } - // 汇率结构化快照:持久化「原始 CNY(订单列)+ 当时汇率 + 入账 USD」, + // 汇率结构化快照:持久化「原始 CNY(订单列)+ 当时汇率 + 入账金额」, // 使历史换算可重建,汇率配置变更不影响已完成订单的现金对账。 + // 本位币=CNY 时 exchange_rate=1(无换汇)、credited_usd 存本位币金额。 + snapRate := billing.One + if !billing.IsCNY(ctx) { + snapRate = billing.NewFromFloat(billing.GetExchangeRateCNYToUSD(ctx)) + } if _, err = g.DB().Ctx(ctx).Exec(ctx, "UPDATE ord_orders SET exchange_rate = ?, credited_usd = ? WHERE id = ?", - billing.NewFromFloat(rate), usdAmount, orderID); err != nil { + snapRate, creditAmount, orderID); err != nil { return gerror.Wrapf(err, "snapshot exchange rate failed") } _ = billing.CheckAndUpgradeLevel(ctx, order.TenantID) @@ -201,8 +215,8 @@ func SubscribePlan(ctx context.Context, tenantID int64, planID int64, months int } // creditWalletTx 钱包入账(依赖调用方传入携带事务的 ctx,且处于订单行锁保护内)。 -// amount 为 USD(bil_ 层永远 USD);orderID 写入流水 related_id:退款时靠它精确找回 -// 履约当时入账的 USD(禁止按当前汇率反算)。 +// amount 为本位币金额(bil_ 层币种 = 部署级本位币,USD 或 CNY);orderID 写入流水 +// related_id:退款时靠它精确找回履约当时入账的本位币金额(禁止按当前汇率反算)。 // // Redis 权威化架构下的职责拆分: // - Redis 加款是资金提交点(实时余额立即生效),在行锁保护内执行,并发回调串行化、天然幂等; diff --git a/internal/logic/payment/settings.go b/internal/logic/payment/settings.go index 32f9dc8..30827d0 100644 --- a/internal/logic/payment/settings.go +++ b/internal/logic/payment/settings.go @@ -8,6 +8,8 @@ import ( ) // GetGlobalPaymentSettings loads global payment settings from ConfigService. +// Currency 固定返回 "CNY":订单/支付层币种由收款渠道决定(epay 收人民币), +// 系统计账与显示货币由本位币 billing_currency 决定(初始化选定,不在此配置)。 func GetGlobalPaymentSettings(ctx context.Context) (*GlobalPaymentSettings, error) { cfg := common.Config() settings := &GlobalPaymentSettings{ @@ -26,22 +28,19 @@ func GetGlobalPaymentSettings(ctx context.Context) (*GlobalPaymentSettings, erro if n := cfg.GetFloat(ctx, "payment_min_topup"); n > 0 { settings.MinTopUp = n } - if val := cfg.GetString(ctx, "payment_currency"); val != "" { - settings.Currency = val - } settings.CallbackBaseURL = cfg.GetString(ctx, "payment_callback_base_url") return settings, nil } // SaveGlobalPaymentSettings persists global payment settings via ConfigService. +// Currency 字段不落库(订单层币种恒 CNY,不接受配置)。 func SaveGlobalPaymentSettings(ctx context.Context, settings *GlobalPaymentSettings) error { cfg := common.Config() items := map[string]string{ "payment_amount_options": mustJSON(settings.AmountOptions), "payment_amount_discount": mustJSON(settings.AmountDiscount), "payment_min_topup": mustJSON(settings.MinTopUp), - "payment_currency": settings.Currency, "payment_callback_base_url": settings.CallbackBaseURL, } for key, value := range items { diff --git a/internal/logic/task/async_polling.go b/internal/logic/task/async_polling.go index 1cb0864..7fe06ab 100644 --- a/internal/logic/task/async_polling.go +++ b/internal/logic/task/async_polling.go @@ -528,7 +528,7 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s record.OutputCost = settleResult.OutputCost record.RefundAmount = settleResult.RefundAmount record.SupplementAmount = settleResult.SupplementAmount - record.Currency = "USD" + record.Currency = billing.Currency(context.Background()) } else { // 失败/超时任务:从定价中获取计费模式 billingMode := "per_request" diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index 507ed0f..b573280 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -186,7 +186,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. Balance: 0, FrozenBalance: 0, WarningThreshold: &warningThreshold, - Currency: "USD", + Currency: billing.Currency(ctx), }).Insert() if err != nil { return gerror.Wrapf(err, "create wallet") diff --git a/internal/logic/tenant/billing.go b/internal/logic/tenant/billing.go index 1739969..76ba91b 100644 --- a/internal/logic/tenant/billing.go +++ b/internal/logic/tenant/billing.go @@ -42,7 +42,7 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena Balance: billing.Zero, FrozenBalance: billing.Zero, WarningThreshold: &threshold, - Currency: "USD", + Currency: billing.Currency(ctx), }) if err != nil { return nil, err @@ -52,7 +52,7 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena FrozenBalance: 0, AvailableBalance: 0, WarningThreshold: 0, - Currency: "USD", + Currency: billing.Currency(ctx), }, nil } diff --git a/internal/packed/setup.html b/internal/packed/setup.html index dd29ad3..578074e 100644 --- a/internal/packed/setup.html +++ b/internal/packed/setup.html @@ -54,6 +54,12 @@ cursor: not-allowed; } + .currency-option.selected { + border-color: #2563eb; + background-color: rgba(37, 99, 235, 0.04); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08); + } + @keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } @@ -67,6 +73,7 @@ .delay-4 { animation-delay: 0.2s; } .delay-5 { animation-delay: 0.25s; } .delay-6 { animation-delay: 0.3s; } + .delay-7 { animation-delay: 0.35s; } @keyframes spin { to { transform: rotate(360deg); } @@ -155,6 +162,31 @@ class="input-field w-full px-3.5 py-2.5 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 outline-none font-mono" placeholder="再次输入密码" autocomplete="new-password"> + + +
+ +
+ + +
+

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

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

按量定价

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

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

+

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

总成本 = Σ total_cost

@@ -596,8 +598,8 @@ function renderChannelMatrix(record: any) {
{{ stats.overallRate.toFixed(2) }}%
-
总成本 (USD)
-
${{ stats.totalCost.toFixed(6) }}
+
总成本 ({{ displayCurrency }})
+
{{ formatBilling(stats.totalCost, 6) }}
请求缓存命中率
diff --git a/web/admin/src/views/monitor/TrafficFlowPage.vue b/web/admin/src/views/monitor/TrafficFlowPage.vue index 673d786..d42e667 100644 --- a/web/admin/src/views/monitor/TrafficFlowPage.vue +++ b/web/admin/src/views/monitor/TrafficFlowPage.vue @@ -7,6 +7,7 @@ import { TooltipComponent } from 'echarts/components' import VChart from 'vue-echarts' import PageHeader from '@/components/PageHeader.vue' import request from '@/utils/request' +import { formatBilling } from '@/composables/useCurrency' // 桑基图无需 GridComponent(无笛卡尔坐标轴) use([CanvasRenderer, SankeyChart, TooltipComponent]) @@ -45,7 +46,8 @@ function defaultRange(days: number): [string, string] { } function formatValue(v: number): string { - if (metric.value === 'cost') return `$${Number(v).toFixed(6)}` + // 成本指标按 bil 层本位币展示(tooltip formatter 在悬浮时才调用,天然读取最新本位币) + if (metric.value === 'cost') return formatBilling(v, 6) return Number(v).toLocaleString() } diff --git a/web/admin/src/views/system/settings/PaymentTab.vue b/web/admin/src/views/system/settings/PaymentTab.vue index 0c6490f..1db730a 100644 --- a/web/admin/src/views/system/settings/PaymentTab.vue +++ b/web/admin/src/views/system/settings/PaymentTab.vue @@ -26,11 +26,8 @@ function updateCnyToUsd(usdToCny: number | undefined) {
基础设置
- - - - - + + > = Symbol('settingsFormValues') @@ -35,6 +36,8 @@ export function useSettings(category: () => string) { try { await request.put(`/admin/settings/${category()}`, { settings: formValues }) Message.success('保存成功') + // 汇率等公共配置变更后强制刷新,已打开页面立即按新值重渲染货币显示 + refreshCurrencySettings() await refresh() } catch { // error toast already shown by interceptor diff --git a/web/admin/src/views/tenant/TenantLevelsPage.vue b/web/admin/src/views/tenant/TenantLevelsPage.vue index f99a124..2958d33 100644 --- a/web/admin/src/views/tenant/TenantLevelsPage.vue +++ b/web/admin/src/views/tenant/TenantLevelsPage.vue @@ -1,5 +1,5 @@