Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
commit
fef105aea3
28 files changed
+696
-261
No files matched your search
@@ -97,13 +97,10 @@ type AdminWorkbenchSummaryRes struct {
|
||||
// AdminWorkbenchBadgeReq 轻量计数接口,供左侧菜单红点轮询。
|
||||
// 与 summary 共用同一份缓存,不会额外压库。
|
||||
type AdminWorkbenchBadgeReq struct {
|
||||
g.Meta `path:"/workbench/badges" method:"get" mime:"json" tags:"管理后台-工作台" summary:"菜单红点计数"`
|
||||
g.Meta `path:"/workbench/badges" method:"get" mime:"json" tags:"管理后台-工作台" summary:"工作台菜单角标计数"`
|
||||
}
|
||||
|
||||
type AdminWorkbenchBadgeRes struct {
|
||||
Total int `json:"total" dc:"待办总数"`
|
||||
Urgent int `json:"urgent" dc:"P0 数量"`
|
||||
Domains map[string]int `json:"domains" dc:"各域待办数,键为域标识"`
|
||||
// Menus 前端菜单路由名 → 待办数,直接挂角标,前端不需要自己映射域到菜单。
|
||||
Menus map[string]int `json:"menus" dc:"菜单路由名 → 待办数"`
|
||||
Total int `json:"total" dc:"待办总数,仅挂在工作台菜单角标"`
|
||||
Urgent int `json:"urgent" dc:"P0 数量,控制角标转红"`
|
||||
}
|
||||
@@ -148,13 +148,13 @@ func runMaintenance(ctx context.Context) {
|
||||
|
||||
// 覆盖摘要:目录只装载 active × 有启用能力行的渠道,未覆盖渠道的健康分不会落盘
|
||||
// (探测却正常的渠道长期停在历史分数时,先看这里是否被跳过)
|
||||
g.Log().Infof(ctx, "[ChannelHealth] 健康快照维护轮次完成: 覆盖渠道 %d 个 %v", len(covered), covered)
|
||||
g.Log().Debugf(ctx, "[ChannelHealth] 健康快照维护轮次完成: 覆盖渠道 %d 个 %v", len(covered), covered)
|
||||
if len(skippedDegraded) > 0 {
|
||||
g.Log().Warningf(ctx, "[ChannelHealth] 本轮 %d 个渠道因 Redis 健康读取失败跳过落盘(保留库中旧值): %v",
|
||||
len(skippedDegraded), skippedDegraded)
|
||||
}
|
||||
if len(skippedNoData) > 0 {
|
||||
g.Log().Infof(ctx, "[ChannelHealth] 本轮 %d 个渠道全部模型无真实上报,保留库中旧值: %v",
|
||||
g.Log().Debugf(ctx, "[ChannelHealth] 本轮 %d 个渠道全部模型无真实上报,保留库中旧值: %v",
|
||||
len(skippedNoData), skippedNoData)
|
||||
}
|
||||
}
|
||||
@@ -258,7 +258,7 @@ func snapshotHealthScore(ctx context.Context, channelID int64, succ, latMs, alph
|
||||
// 分数有实际变化才记 Info;稳态渠道降为 Debug(避免每 5 分钟 × 渠道数的固定噪音)。
|
||||
// 两种情况都带各模型 succ 明细:排查"健康分不符合预期"时可直接定位是哪个模型拉低了均值。
|
||||
if math.Abs(healthScore-prevScore) >= 0.05 {
|
||||
g.Log().Infof(ctx, "[ChannelHealth] 渠道 %d 健康分落盘: %.1f → %.1f | avg_succ %.4f | 模型: %s",
|
||||
g.Log().Debugf(ctx, "[ChannelHealth] 渠道 %d 健康分落盘: %.1f → %.1f | avg_succ %.4f | 模型: %s",
|
||||
channelID, prevScore, healthScore, succ, modelDetail)
|
||||
} else {
|
||||
g.Log().Debugf(ctx, "[ChannelHealth] 渠道 %d 健康快照无变化: %.1f | avg_succ %.4f | 模型: %s",
|
||||
|
||||
@@ -798,7 +798,9 @@ func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOffline
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetWalletInfo 获取租户钱包信息(管理后台)
|
||||
// GetWalletInfo 获取租户钱包信息(管理后台)。
|
||||
// balance/frozen_balance 以 Redis 权威值为准(Redis 是资金提交点,调整余额/线下入账后立即可见),
|
||||
// DB 侧余额是物化器周期覆盖的滞后副本,仅作钱包行存在性校验与 Redis 不可用时的降级展示。
|
||||
func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) {
|
||||
type walletRow struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -815,11 +817,19 @@ func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq)
|
||||
return nil, common.NewNotFoundError("钱包")
|
||||
}
|
||||
|
||||
return &v1.AdminWalletInfoRes{
|
||||
res := &v1.AdminWalletInfoRes{
|
||||
Balance: w.Balance,
|
||||
FrozenBalance: w.FrozenBalance,
|
||||
WarningThreshold: w.WarningThreshold,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 覆盖为 Redis 实时余额(钱包 hash 缺失时 GetWallet 内部触发灾难恢复重建)
|
||||
if wallet, err := billing.GetWallet(ctx, req.TenantID); err == nil {
|
||||
res.Balance = wallet.Balance
|
||||
res.FrozenBalance = wallet.FrozenBalance
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GetWalletTransactions 获取租户钱包交易流水(管理后台)
|
||||
@@ -905,6 +915,9 @@ func (s *sAdmin) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSet
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 清除钱包静态字段缓存,让 GetWallet 读到新阈值(对齐租户端 UpdateWarningThreshold)
|
||||
billing.InvalidateWalletStaticCache(ctx, req.TenantID)
|
||||
|
||||
// 阈值变更后重置预警标记,使新阈值能触发新的预警
|
||||
billing.ResetLowBalanceNotified(ctx, req.TenantID)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
@@ -424,16 +425,20 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan
|
||||
memberCount, _ := dao.TntUsers.Ctx(ctx).
|
||||
Where("tenant_id", req.Id).Count()
|
||||
|
||||
// Get wallet balance
|
||||
var wallet *struct {
|
||||
Balance string `json:"balance"`
|
||||
}
|
||||
_ = dao.BilWallets.Ctx(ctx).
|
||||
Where("tenant_id", req.Id).Scan(&wallet)
|
||||
|
||||
// Get wallet balance:优先读 Redis 权威值(调整余额/入账后详情页立即可见),DB 物化值兜底
|
||||
walletBalance := "0"
|
||||
if wallet != nil && wallet.Balance != "" {
|
||||
walletBalance = wallet.Balance
|
||||
if w, err := billing.GetWallet(ctx, req.Id); err == nil {
|
||||
walletBalance = strconv.FormatFloat(w.Balance, 'f', -1, 64)
|
||||
} else {
|
||||
var wallet *struct {
|
||||
Balance string `json:"balance"`
|
||||
}
|
||||
_ = dao.BilWallets.Ctx(ctx).
|
||||
Where("tenant_id", req.Id).Scan(&wallet)
|
||||
|
||||
if wallet != nil && wallet.Balance != "" {
|
||||
walletBalance = wallet.Balance
|
||||
}
|
||||
}
|
||||
ownerName := ""
|
||||
if owner != nil {
|
||||
|
||||
@@ -39,11 +39,14 @@ const (
|
||||
wbOrderUnfulfilledMin = 15 // 已支付超过 15 分钟仍未履约视为卡单
|
||||
wbOAuthExpiryHours = 24 // OAuth 令牌 24 小时内过期即预警
|
||||
wbHealthDropPoints = 30 // 健康分 1 小时内跌幅超过 30 分视为骤降
|
||||
wbCostSpikeMultiple = 8 // 单笔消费超过近期均值 8 倍视为异常
|
||||
wbEmailFailThreshold = 5 // 1 小时内邮件失败超过 5 封视为链路异常
|
||||
wbPlanExpiringDays = 7 // 套餐 7 天内到期
|
||||
wbTicketSLAHours = 2 // 工单首次响应 SLA
|
||||
wbWalletDriftRatio = 0.02 // Redis↔DB 余额偏差超过 2% 且绝对额可观时告警
|
||||
|
||||
// wbCronStallFallbackInterval 停摆判定的调度周期兜底值:任务不在注册表中
|
||||
// (改名/下线后的遗留行)或 cron 表达式解析失败时按「日任务」保守处理。
|
||||
wbCronStallFallbackInterval = 24 * time.Hour
|
||||
)
|
||||
|
||||
// wbItem 内部待办结构:比 API 的 v1.WorkbenchItem 多一个 perm 字段。
|
||||
@@ -63,14 +66,6 @@ type wbCollected struct {
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
// 域 → 前端菜单路由名,供菜单红点直接挂角标。
|
||||
var wbDomainMenus = map[string][]string{
|
||||
v1.WorkbenchDomainAvailability: {"AdminChannels"},
|
||||
v1.WorkbenchDomainMoney: {"AdminOrders", "AdminTransactions"},
|
||||
v1.WorkbenchDomainCustomer: {"AdminTickets", "AdminFeedback"},
|
||||
v1.WorkbenchDomainSystem: {"AdminAlertEvents", "AdminErrorLogs", "AdminCronJobs"},
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 对外接口
|
||||
// ============================================================
|
||||
@@ -126,30 +121,21 @@ func (s *sAdmin) GetWorkbenchSummary(ctx context.Context, _ *v1.AdminWorkbenchSu
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GetWorkbenchBadges 菜单红点计数(与 summary 共用缓存,不额外压库)。
|
||||
// GetWorkbenchBadges 工作台菜单角标计数(与 summary 共用缓存,不额外压库)。
|
||||
//
|
||||
// 角标只挂工作台菜单一项:待办的排查线索只存在于工作台的描述文案里,
|
||||
// 业务菜单里没有对应的定位入口,往各业务菜单挂数字只会带来
|
||||
// 「进去了却找不到问题」的困惑(红点引路却无路可走)。
|
||||
func (s *sAdmin) GetWorkbenchBadges(ctx context.Context, _ *v1.AdminWorkbenchBadgeReq) (*v1.AdminWorkbenchBadgeRes, error) {
|
||||
col := s.collectWorkbenchCached(ctx)
|
||||
items := s.filterWorkbenchItems(ctx, col.Items)
|
||||
|
||||
res := &v1.AdminWorkbenchBadgeRes{
|
||||
Domains: map[string]int{},
|
||||
Menus: map[string]int{},
|
||||
}
|
||||
res := &v1.AdminWorkbenchBadgeRes{}
|
||||
for _, it := range items {
|
||||
res.Total++
|
||||
if it.Severity == v1.WorkbenchSeverityP0 {
|
||||
res.Urgent++
|
||||
}
|
||||
res.Domains[it.Domain]++
|
||||
}
|
||||
for domain, menus := range wbDomainMenus {
|
||||
n := res.Domains[domain]
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
for _, m := range menus {
|
||||
res.Menus[m] += n
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/dispatchadapter"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
@@ -235,7 +236,6 @@ func (s *sAdmin) wbCollectMoney(ctx context.Context) []wbItem {
|
||||
items = append(items, s.wbWalletDrift(ctx)...)
|
||||
items = append(items, s.wbFrozenStale(ctx)...)
|
||||
items = append(items, s.wbOrderUnfulfilled(ctx)...)
|
||||
items = append(items, s.wbCostSpike(ctx)...)
|
||||
items = append(items, s.wbLowBalanceTenants(ctx)...)
|
||||
return items
|
||||
}
|
||||
@@ -383,58 +383,6 @@ func (s *sAdmin) wbOrderUnfulfilled(ctx context.Context) []wbItem {
|
||||
return items
|
||||
}
|
||||
|
||||
// wbCostSpike 单笔消费异常。
|
||||
// 用途有三:防刷、防客户被恶意调用、防定价配置写错一位(乘数多个 0 的杀伤力最大)。
|
||||
// 判据是「相对该租户自身近 7 天均值的倍数」,而非全局绝对值 —— 大客户的正常单笔
|
||||
// 对小客户就是天价,全局阈值必然两头不讨好。
|
||||
func (s *sAdmin) wbCostSpike(ctx context.Context) []wbItem {
|
||||
rows, err := g.DB().Ctx(ctx).Query(ctx,
|
||||
`WITH recent AS (
|
||||
SELECT tenant_id, id, total_cost, model_name, created_at
|
||||
FROM bil_usage_logs
|
||||
WHERE created_at >= NOW() - INTERVAL '2 hours' AND total_cost > 0
|
||||
), baseline AS (
|
||||
SELECT tenant_id, AVG(total_cost) AS avg_cost
|
||||
FROM bil_usage_logs
|
||||
WHERE created_at >= NOW() - INTERVAL '7 days' AND total_cost > 0
|
||||
GROUP BY tenant_id
|
||||
HAVING COUNT(*) >= 50
|
||||
)
|
||||
SELECT r.id, r.tenant_id, r.total_cost, r.model_name, r.created_at,
|
||||
b.avg_cost, t.name AS tenant_name
|
||||
FROM recent r
|
||||
JOIN baseline b ON b.tenant_id = r.tenant_id
|
||||
LEFT JOIN tnt_tenants t ON t.id = r.tenant_id
|
||||
WHERE r.total_cost >= b.avg_cost * ?
|
||||
ORDER BY r.total_cost DESC
|
||||
LIMIT 5`, wbCostSpikeMultiple)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "workbench: 查询消费尖刺失败: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
items := make([]wbItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
cost := r["total_cost"].Float64()
|
||||
avg := r["avg_cost"].Float64()
|
||||
mult := 0.0
|
||||
if avg > 0 {
|
||||
mult = cost / avg
|
||||
}
|
||||
items = append(items, wbNew(
|
||||
fmt.Sprintf("usage_cost_spike:%d", r["id"].Int64()),
|
||||
v1.WorkbenchSeverityP1, v1.WorkbenchDomainMoney, "billing:view",
|
||||
fmt.Sprintf("租户「%s」单笔消费异常", r["tenant_name"].String()),
|
||||
fmt.Sprintf("模型 %s 单笔 %.6f,为该租户近 7 天均值的 %.1f 倍。"+
|
||||
"需确认是正常业务、刷量,还是定价配置写错",
|
||||
r["model_name"].String(), cost, mult),
|
||||
"查看用量日志", "AdminUsageLogs",
|
||||
map[string]string{"tenant_id": r["tenant_id"].String()}, r["created_at"].GTime(),
|
||||
))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// wbLowBalanceTenants 余额低于预警阈值的租户。
|
||||
// 一个信号两个用途:流失预警 + 催充窗口。聚合成一条,不逐租户刷屏。
|
||||
func (s *sAdmin) wbLowBalanceTenants(ctx context.Context) []wbItem {
|
||||
@@ -606,38 +554,53 @@ func (s *sAdmin) wbCronStalled(ctx context.Context) []wbItem {
|
||||
rows, err := g.DB().Ctx(ctx).Query(ctx,
|
||||
`SELECT job_name, last_status, last_started_at, last_error_message
|
||||
FROM sys_cron_jobs
|
||||
WHERE last_status = 'failed'
|
||||
OR last_started_at IS NULL
|
||||
OR last_started_at <= NOW() - INTERVAL '1 hour'
|
||||
ORDER BY last_started_at ASC NULLS FIRST
|
||||
LIMIT 10`)
|
||||
ORDER BY last_started_at ASC NULLS FIRST`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "workbench: 查询定时任务状态失败: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 注册表快照:任务名 → 调度表达式。停摆判定必须按各任务自身的调度周期换算,
|
||||
// 固定 1 小时停摆线会把日任务(日对账、日清理等)一天里 23 个小时都误报成停摆。
|
||||
schedules := make(map[string]string, 16)
|
||||
for _, j := range common.GetCronScheduler().ListJobs() {
|
||||
schedules[j.Name] = j.Schedule
|
||||
}
|
||||
|
||||
items := make([]wbItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
name := r["job_name"].String()
|
||||
startedAt := r["last_started_at"].GTime()
|
||||
failed := r["last_status"].String() == "failed"
|
||||
|
||||
// 停摆 = 距上次执行超过「正常间隔 × 2」,下限 1 小时——
|
||||
// 下限避免分钟级任务被一次调度抖动(锁等待、实例重启)误报
|
||||
interval := wbCronExpectedInterval(schedules, name)
|
||||
stallAfter := max(2*interval, time.Hour)
|
||||
stalled := startedAt != nil && time.Since(startedAt.Time) > stallAfter
|
||||
|
||||
if !failed && !stalled && startedAt != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 资金相关任务提级为 P0:这些停摆会累积资损,不能等到明天
|
||||
sev := v1.WorkbenchSeverityP1
|
||||
if isMoneyCriticalJob(name) {
|
||||
sev = v1.WorkbenchSeverityP0
|
||||
}
|
||||
|
||||
desc := ""
|
||||
if failed {
|
||||
var desc string
|
||||
switch {
|
||||
case failed:
|
||||
desc = "最近一次执行失败"
|
||||
if e := strings.TrimSpace(r["last_error_message"].String()); e != "" {
|
||||
desc += ":" + truncate(e, 150)
|
||||
}
|
||||
} else if startedAt == nil {
|
||||
case startedAt == nil:
|
||||
desc = "自服务启动以来从未执行过,调度器可能未正常注册该任务"
|
||||
} else {
|
||||
desc = fmt.Sprintf("已 %s 未执行,任务可能已停摆", humanizeSince(startedAt))
|
||||
default:
|
||||
desc = fmt.Sprintf("正常约 %s 执行一次,已 %s 未执行,任务可能已停摆",
|
||||
humanizeDuration(interval), humanizeSince(startedAt))
|
||||
}
|
||||
if isMoneyCriticalJob(name) {
|
||||
desc += "。该任务涉及资金结算,停摆会持续累积差错"
|
||||
@@ -654,6 +617,19 @@ func (s *sAdmin) wbCronStalled(ctx context.Context) []wbItem {
|
||||
return items
|
||||
}
|
||||
|
||||
// wbCronExpectedInterval 从任务注册表查正常执行间隔。
|
||||
// 任务不在注册表中(改名/下线后的遗留行)或表达式解析失败时退回保守兜底值:
|
||||
// 宁可晚报一条真正死掉的任务,也不要每天误报一串健康任务。
|
||||
func wbCronExpectedInterval(schedules map[string]string, name string) time.Duration {
|
||||
interval := wbCronStallFallbackInterval
|
||||
if schedule, ok := schedules[name]; ok {
|
||||
if d, err := common.ScheduleInterval(schedule); err == nil {
|
||||
interval = d
|
||||
}
|
||||
}
|
||||
return interval
|
||||
}
|
||||
|
||||
// isMoneyCriticalJob 判断任务停摆是否会造成资损。
|
||||
func isMoneyCriticalJob(name string) bool {
|
||||
n := strings.ToLower(name)
|
||||
@@ -922,7 +898,11 @@ func humanizeSince(t *gtime.Time) string {
|
||||
if t == nil {
|
||||
return "未知时长"
|
||||
}
|
||||
d := time.Since(t.Time)
|
||||
return humanizeDuration(time.Since(t.Time))
|
||||
}
|
||||
|
||||
// humanizeDuration 把时长写成人话(分钟/小时/天)。
|
||||
func humanizeDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return "不到 1 分钟"
|
||||
}
|
||||
|
||||
@@ -370,6 +370,21 @@ func (cs *CronScheduler) Reschedule(ctx context.Context, name, schedule string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScheduleInterval 解析 cron 表达式,估算两次触发之间的正常间隔。
|
||||
// 取「接下来两次触发时刻之差」,对分钟/小时/日级表达式及 @every 描述符均适用
|
||||
// (@every 的触发时刻相对计算起点平移,间隔不受影响)。解析失败(非法表达式)返回错误,
|
||||
// 由调用方决定兜底策略——工作台的停摆判定靠它把「固定 1 小时」换成「按任务自身周期」。
|
||||
func ScheduleInterval(schedule string) (time.Duration, error) {
|
||||
sched, err := cron.ParseStandard(schedule)
|
||||
if err != nil {
|
||||
return 0, gerror.Wrapf(err, "invalid cron schedule %q", schedule)
|
||||
}
|
||||
now := time.Now()
|
||||
next1 := sched.Next(now)
|
||||
next2 := sched.Next(next1)
|
||||
return next2.Sub(next1), nil
|
||||
}
|
||||
|
||||
// cronLockTTL 分布式锁过期时间:仅用于实例崩溃时的兜底自动释放(正常完成会主动释放)。
|
||||
// 取值需大于绝大多数 job 的执行时长;过长则崩溃后会阻塞该 job 至多这么久。
|
||||
const cronLockTTL = 10 * time.Minute
|
||||
|
||||
@@ -3,6 +3,7 @@ package common
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -93,6 +94,32 @@ func TestReschedule_ValidExprReplacesEntry(t *testing.T) {
|
||||
assert.NotEqual(t, oldID, cs.entryIDs["job_c"], "条目 ID 应已更新")
|
||||
}
|
||||
|
||||
// TestScheduleInterval 停摆判定靠它把「固定 1 小时」换成「按任务自身周期」,
|
||||
// 各类表达式的间隔必须算准——尤其日任务(24h),算错就会退回误报。
|
||||
func TestScheduleInterval(t *testing.T) {
|
||||
cases := []struct {
|
||||
schedule string
|
||||
want time.Duration
|
||||
}{
|
||||
{"* * * * *", time.Minute}, // 每分钟
|
||||
{"*/5 * * * *", 5 * time.Minute}, // 每 5 分钟
|
||||
{"*/10 * * * *", 10 * time.Minute}, // 每 10 分钟
|
||||
{"0 */6 * * *", 6 * time.Hour}, // 每 6 小时(update_check)
|
||||
{"0 3 * * *", 24 * time.Hour}, // 每日 3 点(各类日清理)
|
||||
{"20 5 * * *", 24 * time.Hour}, // 每日 5:20(计费日对账)
|
||||
{"@every 5m", 5 * time.Minute}, // @every 描述符(渠道自动探测默认)
|
||||
{"@every 90m", 90 * time.Minute}, // @every 大间隔(*/N 在 N>=60 时会失真,故用 @every)
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ScheduleInterval(c.schedule)
|
||||
require.NoError(t, err, "schedule %q 应可解析", c.schedule)
|
||||
assert.Equal(t, c.want, got, "schedule %q 的正常间隔不符", c.schedule)
|
||||
}
|
||||
|
||||
_, err := ScheduleInterval("不是合法表达式")
|
||||
assert.Error(t, err, "非法表达式应返回错误,由调用方兜底")
|
||||
}
|
||||
|
||||
// TestOnSettingsChanged_CallbackPanicDoesNotAffectOthers 回调在订阅 goroutine 内串行执行,
|
||||
// 一个回调 panic 不能带崩订阅循环或阻断后续回调。
|
||||
func TestOnSettingsChanged_CallbackPanicDoesNotAffectOthers(t *testing.T) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/qianfree/team-api/internal/logic/relay"
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
"github.com/qianfree/team-api/relay/constant"
|
||||
"github.com/qianfree/team-api/relay/helper"
|
||||
"github.com/qianfree/team-api/relay/taskchannel"
|
||||
)
|
||||
|
||||
@@ -492,21 +493,22 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
|
||||
}
|
||||
|
||||
record := &common.UsageRecord{
|
||||
TenantID: task.TenantID,
|
||||
UserID: task.UserID,
|
||||
ApiKeyID: task.ApiKeyID,
|
||||
ChannelID: task.ChannelID,
|
||||
ChannelName: channelName,
|
||||
ChannelType: channelType,
|
||||
ModelName: task.ModelName,
|
||||
RelayMode: int(constant.RelayModeVideoGenerations),
|
||||
RequestType: 3, // async
|
||||
LatencyMs: float64(latencyMs),
|
||||
IsStream: false,
|
||||
Success: success,
|
||||
RequestID: task.RequestID,
|
||||
Status: status,
|
||||
ErrorMessage: errMsg,
|
||||
TenantID: task.TenantID,
|
||||
UserID: task.UserID,
|
||||
ApiKeyID: task.ApiKeyID,
|
||||
ChannelID: task.ChannelID,
|
||||
ChannelName: channelName,
|
||||
ChannelType: channelType,
|
||||
ModelName: task.ModelName,
|
||||
RelayMode: int(constant.RelayModeVideoGenerations),
|
||||
RequestType: 3, // async
|
||||
LatencyMs: float64(latencyMs),
|
||||
IsStream: false,
|
||||
Success: success,
|
||||
RequestID: task.RequestID,
|
||||
Status: status,
|
||||
// errMsg 可能来自上游 FailReason 原文,租户端用量日志可见,抹除其中的 URL/IP
|
||||
ErrorMessage: helper.RedactMessage(errMsg),
|
||||
PromptTokens: task.PromptTokens,
|
||||
CompletionTokens: task.CompletionTokens,
|
||||
TotalTokens: task.TotalTokens,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- +goose Up
|
||||
-- 回填 bil_usage_logs.input_tokens 的「含缓存总输入」口径(恢复被丢失的 974633a8 配套回填)。
|
||||
--
|
||||
-- 背景:974633a8 将写侧 input_tokens 统一为「含缓存总输入」(Claude 口径入库时补加
|
||||
-- cache_read + cache_creation),缓存命中率公式随之改为 cache_read / input_tokens。
|
||||
-- 当时配套的历史数据回填 UPDATE 在 3676cd4d 中被误删、d01dbc79 合并迁移记录时彻底丢失,
|
||||
-- 导致存量 Claude 渠道行(channel_type=2)仍是「不含缓存」旧口径:
|
||||
-- 这些行 cache_read_tokens 可大于 input_tokens,个人工作台按月聚合后缓存命中率超过 100%。
|
||||
--
|
||||
-- 本迁移将 8/17 之前的存量 Claude 渠道行补加缓存 token,使全表口径一致:
|
||||
-- input_tokens = 普通输入 + cache_read + cache_creation
|
||||
-- OpenAI / Gemini 渠道行的 prompt 原生含缓存(cached 为其子集),不受影响;
|
||||
-- 经协议转换已按含缓存口径入库的行(如 openai 适配器解析 Claude 风格 usage,channel_type≠2)同样不触碰。
|
||||
--
|
||||
-- 注意:本迁移假设 channel_type=2 的历史行均为「不含缓存」口径(与 974633a8 原迁移一致)。
|
||||
-- 若曾在 974633a8 合入后、3676cd4d 之前(2026-08-17 10:01 ~ 11:25)对同一库执行过 goose up,
|
||||
-- 原回填已生效,禁止重复执行本迁移(会造成缓存 token 双重累加)。
|
||||
--
|
||||
-- 回填后需重跑用量日聚合刷新 bil_usage_daily(ON CONFLICT 幂等,可重复执行):
|
||||
-- team-api aggregate-usage --from 2026-08-01 --to <今天>
|
||||
|
||||
-- +goose StatementBegin
|
||||
UPDATE bil_usage_logs
|
||||
SET input_tokens = input_tokens + COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0)
|
||||
WHERE channel_type = 2
|
||||
AND (COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0)) > 0;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- 逆转回填:Claude 渠道行恢复「不含缓存」口径(OpenAI/Gemini 行本就含缓存,不受影响)。
|
||||
-- Down 后需再次执行 aggregate-usage 刷新 bil_usage_daily。
|
||||
|
||||
-- +goose StatementBegin
|
||||
UPDATE bil_usage_logs
|
||||
SET input_tokens = input_tokens - COALESCE(cache_read_tokens, 0) - COALESCE(cache_creation_tokens, 0)
|
||||
WHERE channel_type = 2
|
||||
AND (COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0)) > 0;
|
||||
-- +goose StatementEnd
|
||||
@@ -93,8 +93,12 @@ func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response,
|
||||
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
|
||||
}
|
||||
|
||||
// relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
|
||||
// relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
|
||||
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
|
||||
// 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
|
||||
if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
|
||||
return usage, common.ErrStreamInterrupted
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -133,8 +133,12 @@ func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *com
|
||||
|
||||
// handleStreamResponse 流式模式:逐事件读取 Coze SSE,转换为 OpenAI SSE 格式输出
|
||||
func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) {
|
||||
// relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
|
||||
// relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
|
||||
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
|
||||
// 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
|
||||
if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
|
||||
return usage, common.ErrStreamInterrupted
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
@@ -164,7 +168,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
|
||||
// 流中断计费兜底:输出按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
|
||||
interruptedUsage := &common.Usage{}
|
||||
helper.ApplyInterruptedUsageFallback(info, interruptedUsage, transferredTextLen)
|
||||
return interruptedUsage, nil
|
||||
return interruptedUsage, common.ErrStreamInterrupted
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -207,7 +211,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
|
||||
// 流中断计费兜底:输出按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
|
||||
interruptedUsage := &common.Usage{}
|
||||
helper.ApplyInterruptedUsageFallback(info, interruptedUsage, transferredTextLen)
|
||||
return interruptedUsage, nil
|
||||
return interruptedUsage, common.ErrStreamInterrupted
|
||||
}
|
||||
|
||||
case "conversation.message.completed":
|
||||
|
||||
@@ -199,8 +199,12 @@ func (a *Adaptor) handleNonStreamResponse(ctx context.Context, resp *http.Respon
|
||||
|
||||
// handleStreamResponse 处理 Dify streaming 模式 SSE 响应
|
||||
func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) {
|
||||
// relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
|
||||
// relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
|
||||
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
|
||||
// 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
|
||||
if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
|
||||
return usage, common.ErrStreamInterrupted
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
@@ -228,7 +232,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
|
||||
streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted)
|
||||
// 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
|
||||
helper.ApplyInterruptedUsageFallback(info, &usage, transferredTextLen)
|
||||
return &usage, nil
|
||||
return &usage, common.ErrStreamInterrupted
|
||||
default:
|
||||
}
|
||||
|
||||
@@ -267,7 +271,7 @@ func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response,
|
||||
streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted)
|
||||
// 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
|
||||
helper.ApplyInterruptedUsageFallback(info, &usage, transferredTextLen)
|
||||
return &usage, nil
|
||||
return &usage, common.ErrStreamInterrupted
|
||||
}
|
||||
|
||||
case "message_end":
|
||||
|
||||
@@ -240,8 +240,12 @@ func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response,
|
||||
return nil, buildGeminiUpstreamError(body, resp.StatusCode)
|
||||
}
|
||||
|
||||
// relaykit 流式转换(特性开关控制,默认关闭)。未启用/无匹配回退旧路径。
|
||||
// relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。
|
||||
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
|
||||
// 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
|
||||
if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
|
||||
return usage, common.ErrStreamInterrupted
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +240,10 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo
|
||||
|
||||
// relaykit 流式转换(仅 chat;generate/embedding 不迁移,走旧路径)
|
||||
if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok {
|
||||
// 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算
|
||||
if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
|
||||
return usage, common.ErrStreamInterrupted
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1573,6 +1573,10 @@ func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info
|
||||
helper.WriteSSEData(writer, "[DONE]")
|
||||
|
||||
g.Log().Infof(ctx, "[HandleResponsesStreamToChat] completed: usage=%+v, endReason=%s", totalUsage, info.StreamStatus.GetEndReason())
|
||||
// 流中断(客户端断开/写失败/上游 error 主动终止):透传中断信号,上层按已传输部分结算
|
||||
if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
|
||||
return &totalUsage, common.ErrStreamInterrupted
|
||||
}
|
||||
return &totalUsage, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const (
|
||||
StreamEndReasonClientGone StreamEndReason = "client_gone"
|
||||
StreamEndReasonError StreamEndReason = "error"
|
||||
StreamEndReasonPanic StreamEndReason = "panic"
|
||||
StreamEndReasonHandlerStop StreamEndReason = "handler_stop" // dataHandler 调用了 Stop()
|
||||
StreamEndReasonHandlerStop StreamEndReason = "handler_stop" // dataHandler 调用了 Stop()(写客户端失败/上游 error 主动终止,按中断处理)
|
||||
StreamEndReasonPingFail StreamEndReason = "ping_fail" // ping 写入失败
|
||||
StreamEndReasonScannerErr StreamEndReason = "scanner_err" // bufio.Scanner 错误
|
||||
StreamEndReasonEOF StreamEndReason = "eof" // scanner EOF 未收到 [DONE]
|
||||
@@ -71,17 +71,23 @@ func (s *StreamStatus) ErrorCount() int {
|
||||
}
|
||||
|
||||
// IsPartialStreamEnd 流已开始传输但未正常结束,应按已消费 usage 部分结算。
|
||||
// handler_stop(dataHandler 调用 Stop)纳入中断判定:正常结束由 sr.Done()/scanner 的
|
||||
// done 标记,实际走到 Stop 的只有「写客户端失败」(客户端不可达)与「上游 error 事件
|
||||
// 主动终止」两种,均属已向客户端写出部分字节后的非正常结束,应按中断部分结算。
|
||||
// 此前 handler_stop 不算中断,客户端断开时写失败若先于 ctx 取消被观察到(first-writer-wins
|
||||
// 竞态),流会被误判为正常结束、跳过中断计费兜底,导致 0 token / 0 费用结算。
|
||||
func (s *StreamStatus) IsPartialStreamEnd() bool {
|
||||
r := s.GetEndReason()
|
||||
return r == StreamEndReasonClientGone || r == StreamEndReasonScannerErr ||
|
||||
r == StreamEndReasonTimeout || r == StreamEndReasonPingFail
|
||||
r == StreamEndReasonTimeout || r == StreamEndReasonPingFail ||
|
||||
r == StreamEndReasonHandlerStop
|
||||
}
|
||||
|
||||
// IsNormalEnd 是否正常结束
|
||||
// IsNormalEnd 是否正常结束(handler_stop 属异常终止,不算正常结束)
|
||||
func (s *StreamStatus) IsNormalEnd() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.reason == StreamEndReasonDone || s.reason == StreamEndReasonHandlerStop || s.reason == ""
|
||||
return s.reason == StreamEndReasonDone || s.reason == ""
|
||||
}
|
||||
|
||||
// HasErrors 是否有错误
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsPartialStreamEnd_ReasonCoverage 流式结束原因的中断判定覆盖。
|
||||
// handler_stop(写客户端失败/上游 error 主动终止)必须算部分流中断:
|
||||
// 客户端断开时写失败常先于 ctx 取消被观察到(SetEndReason 为 first-writer-wins),
|
||||
// 漏判会跳过中断计费兜底,按 0 token 成功结算。
|
||||
func TestIsPartialStreamEnd_ReasonCoverage(t *testing.T) {
|
||||
partialReasons := []StreamEndReason{
|
||||
StreamEndReasonClientGone,
|
||||
StreamEndReasonScannerErr,
|
||||
StreamEndReasonTimeout,
|
||||
StreamEndReasonPingFail,
|
||||
StreamEndReasonHandlerStop,
|
||||
}
|
||||
for _, reason := range partialReasons {
|
||||
s := NewStreamStatus()
|
||||
s.SetEndReason(reason, errors.New("boom"))
|
||||
if !s.IsPartialStreamEnd() {
|
||||
t.Errorf("IsPartialStreamEnd(%s) = false, want true", reason)
|
||||
}
|
||||
if s.IsNormalEnd() {
|
||||
t.Errorf("IsNormalEnd(%s) = true, want false", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// 正常结束不算中断
|
||||
s := NewStreamStatus()
|
||||
s.SetEndReason(StreamEndReasonDone, nil)
|
||||
if s.IsPartialStreamEnd() {
|
||||
t.Error("IsPartialStreamEnd(done) = true, want false")
|
||||
}
|
||||
if !s.IsNormalEnd() {
|
||||
t.Error("IsNormalEnd(done) = false, want true")
|
||||
}
|
||||
}
|
||||
@@ -374,6 +374,12 @@ func settleSuccessfulRequest(
|
||||
RetryIndex: info.RetryIndex,
|
||||
}
|
||||
|
||||
// 流式追踪:正常结束也落库(done / eof)。eof(上游未发 [DONE] 即结束)按正常结束
|
||||
// 结算但属上游侧异常信号,记录后可在用量日志直接区分,无需翻应用日志。
|
||||
if info.StreamStatus != nil {
|
||||
usageRecord.StreamEndReason = string(info.StreamStatus.GetEndReason())
|
||||
}
|
||||
|
||||
// 填充结算费用数据
|
||||
if settleResult != nil {
|
||||
usageRecord.TotalCost = settleResult.BaseCost
|
||||
@@ -692,7 +698,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
|
||||
if billing != nil && preDeductAmount > 0 {
|
||||
_ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||||
}
|
||||
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err)
|
||||
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus)
|
||||
recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs())
|
||||
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
|
||||
dbgAttempt.MarkFinal(err)
|
||||
@@ -710,14 +716,16 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
|
||||
err = helper.RemapStatusCode(err, info.ChannelMeta.Settings.StatusCodeMapping)
|
||||
|
||||
if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() {
|
||||
g.Log().Warningf(ctx, "[RelayHandler] Stream interrupted: adaptor=%s, model=%s, reason=%s",
|
||||
adaptor.GetChannelName(), v.modelName, info.StreamStatus.Summary())
|
||||
g.Log().Warningf(ctx, "[RelayHandler] Stream interrupted: request=%s tenant=%d channel=%d(%s) adaptor=%s model=%s attempt=%d reason=%s ctx_err=%v usage=%v pre_deduct=%.6f",
|
||||
rc.RequestID, rc.TenantID, selection.ChannelID, selection.ChannelName, adaptor.GetChannelName(), v.modelName,
|
||||
attempt, info.StreamStatus.Summary(), ctx.Err(), usage, preDeductAmount)
|
||||
// 客户端已收到部分流:不可重试(响应已污染),上报健康后按流中断结算
|
||||
_, _ = sess.Report(settleCtx, dispatchStatusCode(err), err, dispatch.DeliveryResponseStarted, info.LatencyMs(), 0)
|
||||
streamUsage := usage
|
||||
if streamUsage == nil {
|
||||
streamUsage = &common.Usage{}
|
||||
}
|
||||
var interruptSettle *common.SettlementResult
|
||||
if billing != nil && preDeductAmount > 0 {
|
||||
settleResult, err := billing.SettleStreamInterrupted(settleCtx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
|
||||
v.modelName, rc.RequestID, v.relayModeStr, streamUsage, preDeductAmount, rc.ProjectID, info.StartTime)
|
||||
@@ -734,8 +742,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
|
||||
billing.IncrMemberQuotaUsed(settleCtx, rc.TenantID, rc.UserID, settleResult.ActualCost)
|
||||
billing.IncrApiKeyQuotaUsed(settleCtx, rc.ApiKeyID, settleResult.ActualCost)
|
||||
}
|
||||
interruptSettle = settleResult
|
||||
}
|
||||
recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage)
|
||||
recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage, interruptSettle, info.StreamStatus)
|
||||
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
|
||||
dbgAttempt.MarkFinal(err)
|
||||
return usage, v.billingResult, err
|
||||
@@ -783,7 +792,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
|
||||
if billing != nil && preDeductAmount > 0 {
|
||||
_ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||||
}
|
||||
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err)
|
||||
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus)
|
||||
recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs())
|
||||
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
|
||||
dbgAttempt.MarkFinal(err)
|
||||
@@ -952,7 +961,7 @@ func handleChannelUnavailable(
|
||||
"当前模型暂时不可用",
|
||||
constant.ErrAllChannelsFailed,
|
||||
)
|
||||
recordFailedUsage(provider, rc, nil, v.modelName, v.relayMode, v.isStream, allFailedErr)
|
||||
recordFailedUsage(provider, rc, nil, v.modelName, v.relayMode, v.isStream, allFailedErr, nil)
|
||||
return &channelUnavailableResult{nil, v.billingResult, allFailedErr}
|
||||
}
|
||||
|
||||
@@ -1027,10 +1036,34 @@ func (e *RelayErrorWithRateLimit) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// applyStreamEndDiag 把流式结束原因落进失败用量记录,替代无归因价值的哨兵文案。
|
||||
// stream_end_reason 区分 client_gone / handler_stop / scanner_err / timeout / ping_fail / eof,
|
||||
// error_message 保留真实底层错误(ctx 取消原因、写客户端失败的 TCP 错误等);
|
||||
// 哨兵错误(ErrStreamInterrupted)或状态里已带底层错误时直接用摘要,其余追加在原始错误后。
|
||||
// error_message 对租户端可见,全部经 RedactMessage/SafeUpstreamErrorMessage 脱敏:
|
||||
// 摘要中的本地连接地址抹 IP,外层错误按传输层归一化,禁止上游域名/URL/IP 落入用量日志。
|
||||
func applyStreamEndDiag(record *common.UsageRecord, streamStatus *common.StreamStatus, err error) {
|
||||
if streamStatus == nil {
|
||||
return
|
||||
}
|
||||
reason := streamStatus.GetEndReason()
|
||||
if reason == "" {
|
||||
return
|
||||
}
|
||||
record.StreamEndReason = string(reason)
|
||||
summary := helper.RedactMessage(streamStatus.Summary())
|
||||
if err == nil || errors.Is(err, common.ErrStreamInterrupted) || streamStatus.Error() != nil {
|
||||
record.ErrorMessage = summary
|
||||
return
|
||||
}
|
||||
record.ErrorMessage = helper.SafeUpstreamErrorMessage(err) + " [stream: " + summary + "]"
|
||||
}
|
||||
|
||||
// recordFailedUsage 记录失败用量。
|
||||
// selection 非 nil 时记录具体失败渠道(ID/名称/类型/上游模型),便于在用量日志定位失败渠道;
|
||||
// nil 表示无单一渠道(全部渠道失败),渠道字段留空,失败详情见 error_message。
|
||||
func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error) {
|
||||
// streamStatus 非 nil 且已有结束原因时,同步落 stream_end_reason 并修正 error_message。
|
||||
func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, streamStatus *common.StreamStatus) {
|
||||
record := &common.UsageRecord{
|
||||
TenantID: rc.TenantID,
|
||||
UserID: rc.UserID,
|
||||
@@ -1043,8 +1076,11 @@ func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection
|
||||
Success: false,
|
||||
RequestID: rc.RequestID,
|
||||
Status: "error",
|
||||
ErrorMessage: err.Error(),
|
||||
// 租户端用量日志可见 error_message,须经脱敏(上游域名/URL/IP 禁止落库);
|
||||
// 完整原始错误保留在应用日志与审计 ForwardingTrace(仅管理员可见)
|
||||
ErrorMessage: helper.SafeUpstreamErrorMessage(err),
|
||||
}
|
||||
applyStreamEndDiag(record, streamStatus, err)
|
||||
if selection != nil {
|
||||
record.ChannelID = selection.ChannelID
|
||||
record.ChannelName = selection.ChannelName
|
||||
@@ -1056,7 +1092,7 @@ func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection
|
||||
|
||||
// recordFailedUsageWithTokens 记录失败用量(含 token 明细,用于流中断等已有部分 usage 的场景)。
|
||||
// 与 recordFailedUsage 的区别:此函数会填充 token 字段,避免报表中流中断记录的 token 全为 0。
|
||||
func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, usage *common.Usage) {
|
||||
func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, usage *common.Usage, settleResult *common.SettlementResult, streamStatus *common.StreamStatus) {
|
||||
record := &common.UsageRecord{
|
||||
TenantID: rc.TenantID,
|
||||
UserID: rc.UserID,
|
||||
@@ -1069,8 +1105,11 @@ func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext,
|
||||
Success: false,
|
||||
RequestID: rc.RequestID,
|
||||
Status: "error",
|
||||
ErrorMessage: err.Error(),
|
||||
// 租户端用量日志可见 error_message,须经脱敏(上游域名/URL/IP 禁止落库);
|
||||
// 完整原始错误保留在应用日志与审计 ForwardingTrace(仅管理员可见)
|
||||
ErrorMessage: helper.SafeUpstreamErrorMessage(err),
|
||||
}
|
||||
applyStreamEndDiag(record, streamStatus, err)
|
||||
if selection != nil {
|
||||
record.ChannelID = selection.ChannelID
|
||||
record.ChannelName = selection.ChannelName
|
||||
@@ -1082,6 +1121,17 @@ func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext,
|
||||
record.CompletionTokens = usage.CompletionTokens
|
||||
record.TotalTokens = usage.TotalTokens
|
||||
}
|
||||
// 流中断结算成功时补充费用字段:中断已按已传输部分实际扣费,用量记录须与账本一致,
|
||||
// 否则后台看到的中断请求永远是 0 费用(结算与用量记录是两条独立写入链路)
|
||||
if settleResult != nil {
|
||||
record.TotalCost = settleResult.BaseCost
|
||||
record.ActualCost = settleResult.ActualCost
|
||||
record.PreDeductAmount = settleResult.PreDeductAmount
|
||||
record.RefundAmount = settleResult.RefundAmount
|
||||
record.SupplementAmount = settleResult.SupplementAmount
|
||||
record.BillingMode = settleResult.BillingMode
|
||||
record.BillingSource = settleResult.BillingSource
|
||||
}
|
||||
provider.RecordUsage(context.Background(), record)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
"github.com/qianfree/team-api/relay/constant"
|
||||
)
|
||||
|
||||
// TestApplyStreamEndDiag_SentinelReplacedBySummary 哨兵错误(ErrStreamInterrupted)本身无归因价值,
|
||||
// error_message 必须换成 StreamStatus 摘要,让用量日志能区分 context canceled / TCP 写失败等真实原因。
|
||||
func TestApplyStreamEndDiag_SentinelReplacedBySummary(t *testing.T) {
|
||||
status := common.NewStreamStatus()
|
||||
status.SetEndReason(common.StreamEndReasonClientGone, context.Canceled)
|
||||
|
||||
record := &common.UsageRecord{ErrorMessage: common.ErrStreamInterrupted.Error()}
|
||||
applyStreamEndDiag(record, status, common.ErrStreamInterrupted)
|
||||
|
||||
if record.StreamEndReason != string(common.StreamEndReasonClientGone) {
|
||||
t.Errorf("StreamEndReason = %q, want %q", record.StreamEndReason, common.StreamEndReasonClientGone)
|
||||
}
|
||||
want := "client_gone: context canceled"
|
||||
if record.ErrorMessage != want {
|
||||
t.Errorf("ErrorMessage = %q, want %q", record.ErrorMessage, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStreamEndDiag_StatusCarriesErrorSummaryWins 状态本身带底层错误(如 handler_stop 的
|
||||
// 写客户端失败)时摘要信息量高于外层错误,直接以摘要作为 error_message。
|
||||
func TestApplyStreamEndDiag_StatusCarriesErrorSummaryWins(t *testing.T) {
|
||||
status := common.NewStreamStatus()
|
||||
writeErr := errors.New("write tcp 127.0.0.1:18888->127.0.0.1:6396: wsasend: connection forcibly closed")
|
||||
status.SetEndReason(common.StreamEndReasonHandlerStop, writeErr)
|
||||
|
||||
record := &common.UsageRecord{ErrorMessage: "upstream error"}
|
||||
applyStreamEndDiag(record, status, errors.New("upstream error"))
|
||||
|
||||
if !strings.Contains(record.ErrorMessage, "write tcp") {
|
||||
t.Errorf("ErrorMessage should keep underlying write error, got %q", record.ErrorMessage)
|
||||
}
|
||||
if record.StreamEndReason != string(common.StreamEndReasonHandlerStop) {
|
||||
t.Errorf("StreamEndReason = %q, want %q", record.StreamEndReason, common.StreamEndReasonHandlerStop)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStreamEndDiag_RealErrorKeptWithSummary 非哨兵外层错误(如上游 5xx)且状态无底层错误时
|
||||
// (如 eof),保留原始错误并把流结束原因追加在后,两条线索都不丢。
|
||||
func TestApplyStreamEndDiag_RealErrorKeptWithSummary(t *testing.T) {
|
||||
status := common.NewStreamStatus()
|
||||
status.SetEndReason(common.StreamEndReasonEOF, nil)
|
||||
|
||||
upstreamErr := errors.New("upstream returned HTTP 502")
|
||||
record := &common.UsageRecord{ErrorMessage: upstreamErr.Error()}
|
||||
applyStreamEndDiag(record, status, upstreamErr)
|
||||
|
||||
if !strings.HasPrefix(record.ErrorMessage, upstreamErr.Error()) {
|
||||
t.Errorf("ErrorMessage should keep original error, got %q", record.ErrorMessage)
|
||||
}
|
||||
if !strings.Contains(record.ErrorMessage, string(common.StreamEndReasonEOF)) {
|
||||
t.Errorf("ErrorMessage should append stream summary, got %q", record.ErrorMessage)
|
||||
}
|
||||
if record.StreamEndReason != string(common.StreamEndReasonEOF) {
|
||||
t.Errorf("StreamEndReason = %q, want %q", record.StreamEndReason, common.StreamEndReasonEOF)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStreamEndDiag_NoopCases nil 状态与未设置结束原因的状态不得改动记录。
|
||||
func TestApplyStreamEndDiag_NoopCases(t *testing.T) {
|
||||
record := &common.UsageRecord{ErrorMessage: "raw"}
|
||||
applyStreamEndDiag(record, nil, errors.New("x"))
|
||||
if record.StreamEndReason != "" || record.ErrorMessage != "raw" {
|
||||
t.Errorf("nil status should be no-op, got reason=%q message=%q", record.StreamEndReason, record.ErrorMessage)
|
||||
}
|
||||
|
||||
empty := common.NewStreamStatus()
|
||||
applyStreamEndDiag(record, empty, errors.New("x"))
|
||||
if record.StreamEndReason != "" || record.ErrorMessage != "raw" {
|
||||
t.Errorf("empty reason should be no-op, got reason=%q message=%q", record.StreamEndReason, record.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStreamEndDiag_UpstreamURLNeverLeaks 用量日志 error_message 对租户可见,
|
||||
// 编排层包装的上游传输错误(*url.Error 含完整上游域名/IP)必须归一化,禁止原样落库。
|
||||
func TestApplyStreamEndDiag_UpstreamURLNeverLeaks(t *testing.T) {
|
||||
// 还原线上泄露形态:NewUpstreamError(502, "请求处理失败", Post "https://api.deepseek.com/...": dial tcp ...)
|
||||
dialErr := &net.OpError{
|
||||
Op: "dial",
|
||||
Net: "tcp",
|
||||
Err: errors.New("connectex: A connection attempt failed because the connected party did not properly respond"),
|
||||
}
|
||||
upstreamErr := &url.Error{Op: "Post", URL: "https://api.deepseek.com/anthropic/v1/messages", Err: dialErr}
|
||||
relayErr := constant.NewUpstreamError(502, "请求处理失败", upstreamErr)
|
||||
|
||||
status := common.NewStreamStatus()
|
||||
status.SetEndReason(common.StreamEndReasonEOF, nil)
|
||||
|
||||
record := &common.UsageRecord{ErrorMessage: relayErr.Error()}
|
||||
applyStreamEndDiag(record, status, relayErr)
|
||||
|
||||
for _, leak := range []string{"api.deepseek.com", "171.105.220.186", "https://", "/anthropic/"} {
|
||||
if strings.Contains(record.ErrorMessage, leak) {
|
||||
t.Errorf("error_message 泄露上游信息 %q: %q", leak, record.ErrorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyStreamEndDiag_SummaryLocalAddrRedacted 摘要中的写客户端 TCP 错误含本地地址,
|
||||
// IP 必须抹除(保留 wsasend 等错误类别供排查)。
|
||||
func TestApplyStreamEndDiag_SummaryLocalAddrRedacted(t *testing.T) {
|
||||
status := common.NewStreamStatus()
|
||||
status.SetEndReason(common.StreamEndReasonHandlerStop,
|
||||
errors.New("write tcp 127.0.0.1:18888->127.0.0.1:6396: wsasend: An existing connection was forcibly closed"))
|
||||
|
||||
record := &common.UsageRecord{ErrorMessage: "x"}
|
||||
applyStreamEndDiag(record, status, common.ErrStreamInterrupted)
|
||||
|
||||
if strings.Contains(record.ErrorMessage, "127.0.0.1") {
|
||||
t.Errorf("摘要未抹除本地 IP: %q", record.ErrorMessage)
|
||||
}
|
||||
if !strings.Contains(record.ErrorMessage, "wsasend") {
|
||||
t.Errorf("应保留错误类别 wsasend: %q", record.ErrorMessage)
|
||||
}
|
||||
if !strings.Contains(record.ErrorMessage, string(common.StreamEndReasonHandlerStop)) {
|
||||
t.Errorf("应保留结束原因 handler_stop: %q", record.ErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,8 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr
|
||||
var (
|
||||
gotFinish bool // 转换器是否已产出带 finish_reason 的结束 chunk
|
||||
firstChunk bool
|
||||
transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算
|
||||
writeFailed bool // 写客户端已失败(连接不可达),客户端断开的可靠信号
|
||||
transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算
|
||||
)
|
||||
|
||||
// chunkWriter:将转换器产出的 *dto.ChatCompletionStreamResponse 序列化为 SSE 写出,
|
||||
@@ -106,7 +107,11 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return helper.WriteSSEData(safeWriter, string(data))
|
||||
if err := helper.WriteSSEData(safeWriter, string(data)); err != nil {
|
||||
writeFailed = true
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeTerminal 在转换器未产出结束 chunk 时补发一个终止 chunk,保证客户端正常收尾。
|
||||
@@ -134,9 +139,17 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr
|
||||
monitor.TrackConverterCall(converterID, string(upstream), string(clientFormat), duration, err)
|
||||
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
// 客户端断开 / 上下文取消:客户端已不可达,不写 [DONE]
|
||||
setEndReason(common.StreamEndReasonClientGone, ctx.Err())
|
||||
// 客户端断开判定:ctx 已取消,或已出现过写客户端失败。后者兜住「写失败先于
|
||||
// ctx 取消被观察到」的竞态与断开信号经代理链路延迟传导的场景——此前仅凭
|
||||
// ctx.Err() 区分,这类情况会被误判为转换失败:跳过中断计费兜底、向死连接
|
||||
// 补写 [DONE],最终按 0 token 成功结算。
|
||||
if writeFailed || ctx.Err() != nil {
|
||||
endErr := ctx.Err()
|
||||
if endErr == nil {
|
||||
endErr = err
|
||||
}
|
||||
// 客户端已不可达,不写 terminal/[DONE]
|
||||
setEndReason(common.StreamEndReasonClientGone, endErr)
|
||||
// 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐
|
||||
helper.ApplyInterruptedUsageFallback(info, capturedUsage, transferredTextLen)
|
||||
return capturedUsage, true
|
||||
|
||||
@@ -2,6 +2,8 @@ package relaykit_bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -192,3 +194,74 @@ func tail(s string, n int) string {
|
||||
}
|
||||
return s[len(s)-n:]
|
||||
}
|
||||
|
||||
// failOnSubstringWriter 写入载荷包含 marker 时返回错误(模拟客户端已断开后的写失败),
|
||||
// 其余写入透传到内层 recorder。
|
||||
type failOnSubstringWriter struct {
|
||||
rec *httptest.ResponseRecorder
|
||||
marker string
|
||||
}
|
||||
|
||||
func (w *failOnSubstringWriter) Header() http.Header { return w.rec.Header() }
|
||||
func (w *failOnSubstringWriter) WriteHeader(code int) { w.rec.WriteHeader(code) }
|
||||
func (w *failOnSubstringWriter) Write(p []byte) (int, error) {
|
||||
if strings.Contains(string(p), w.marker) {
|
||||
return 0, errors.New("write failed: connection reset by peer")
|
||||
}
|
||||
return w.rec.Body.Write(p)
|
||||
}
|
||||
|
||||
// TestConvertStreamViaRelaykit_ClientWriteFailure 复现中断误判竞态:客户端断开由「写失败」
|
||||
// 首先暴露而 ctx 尚未取消(断开信号经代理传导延迟/first-writer-wins 场景)。
|
||||
// 此时必须按流中断处理:end reason=client_gone、usage 走中断兜底(已转发文本 2 字符/token
|
||||
// 估算),且不向死连接补写 [DONE]——而非误判为转换失败导致 0 token / 0 费用结算。
|
||||
func TestConvertStreamViaRelaykit_ClientWriteFailure(t *testing.T) {
|
||||
claudeStream := `data: {"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","model":"claude-3-opus-20240229","usage":{"input_tokens":10,"output_tokens":0,"cache_read_input_tokens":4,"cache_creation_input_tokens":3}}}
|
||||
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text"}}
|
||||
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
|
||||
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", how can I help you?"}}
|
||||
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}
|
||||
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
`
|
||||
info := newStreamTestRelayInfo(constant.ProviderClaude, constant.RelayFormatOpenAI)
|
||||
// 与生产一致(relay_handler buildRelayInfo 设置):流中断时上游 usage 缺失,
|
||||
// 输入 token 按请求侧估算值补齐计费
|
||||
info.SetEstimatePromptTokens(42)
|
||||
// ctx 用 Background(永不取消),精确模拟「写失败先于 ctx 取消被观察到」的竞态
|
||||
w := &failOnSubstringWriter{rec: httptest.NewRecorder(), marker: "how can I help you"}
|
||||
|
||||
usage, ok := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(claudeStream), w)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true (handled), got false")
|
||||
}
|
||||
if usage == nil {
|
||||
t.Fatal("expected non-nil usage")
|
||||
}
|
||||
if reason := info.StreamStatus.GetEndReason(); reason != common.StreamEndReasonClientGone {
|
||||
t.Fatalf("end reason = %q, want %q (client write failure misclassified)", reason, common.StreamEndReasonClientGone)
|
||||
}
|
||||
if !info.StreamStatus.IsPartialStreamEnd() {
|
||||
t.Fatal("IsPartialStreamEnd = false, want true")
|
||||
}
|
||||
// 转换器的 usage chunk 只在流末尾产出,中断时尚未捕获——prompt 按请求侧估算值(42)补齐;
|
||||
// 两个 delta 文本("Hello"+", how can I help you?" = 5+21 = 26 字节)均在写失败前计入
|
||||
// transferredTextLen(累计发生在写之前),中断估算 completion = (26+1)/2 = 13
|
||||
if usage.PromptTokens != 42 {
|
||||
t.Errorf("PromptTokens = %d, want 42 (request-side estimate fallback)", usage.PromptTokens)
|
||||
}
|
||||
if usage.CompletionTokens != 13 {
|
||||
t.Errorf("CompletionTokens = %d, want 13 (interrupted estimate, got 0 => fallback skipped)", usage.CompletionTokens)
|
||||
}
|
||||
// 中断后不得向死连接补写 [DONE]
|
||||
if strings.Contains(w.rec.Body.String(), "[DONE]") {
|
||||
t.Error("output should not contain [DONE] after client disconnect")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ref, reactive, watch, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconCloudDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import request from '@/utils/request'
|
||||
import { displayCurrency, formatBilling } from '@/composables/useCurrency'
|
||||
import { displayCurrency, formatBilling, cnyToUsd } from '@/composables/useCurrency'
|
||||
|
||||
// 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算
|
||||
const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$'))
|
||||
@@ -80,6 +80,27 @@ function getOfficialSourceMeta(source: string) {
|
||||
return officialSourceMeta[source] || { label: source, color: 'gray' }
|
||||
}
|
||||
|
||||
// 官方数据源价格恒为美元;本位币=CNY 时按汇率倒数折算展示与填入(汇率恒为 CNY→USD 单向配置,USD→CNY 取倒数)
|
||||
const usdToBaseRatio = computed(() => {
|
||||
if (displayCurrency.value !== 'CNY') return 1
|
||||
return 1 / cnyToUsd.value
|
||||
})
|
||||
|
||||
// USD 原值 → 本位币值(4 位小数,与定价输入控件精度对齐)
|
||||
function toBasePrice(v: number | null | undefined): number {
|
||||
const usd = Number(v ?? 0)
|
||||
if (usd <= 0) return 0
|
||||
return Math.round(usd * usdToBaseRatio.value * 10000) / 10000
|
||||
}
|
||||
|
||||
// 官方参考价展示:折算为本位币直显;本位币=CNY 时括注美元原值便于核对
|
||||
function formatOfficialPrice(v: number | null | undefined): string {
|
||||
const converted = formatBilling(toBasePrice(v), 2)
|
||||
if (displayCurrency.value !== 'CNY') return converted
|
||||
const usd = Number(Number(v ?? 0).toFixed(2))
|
||||
return usd > 0 ? `${converted} ($${usd})` : converted
|
||||
}
|
||||
|
||||
function resetOfficialData() {
|
||||
officialData.value = null
|
||||
officialError.value = ''
|
||||
@@ -111,10 +132,11 @@ async function fetchOfficialPricing() {
|
||||
function applyOfficialPricing(pricing: any) {
|
||||
if (!pricing) return
|
||||
if (editorItems.length > 0) {
|
||||
editorItems[0].input_price = pricing.input_price
|
||||
editorItems[0].output_price = pricing.output_price
|
||||
editorItems[0].cache_read_price = pricing.cache_read_price
|
||||
editorItems[0].cache_creation_price = pricing.cache_creation_price
|
||||
// 编辑表单按本位币原值输入,官方 USD 价格须先折算为本位币
|
||||
editorItems[0].input_price = toBasePrice(pricing.input_price)
|
||||
editorItems[0].output_price = toBasePrice(pricing.output_price)
|
||||
editorItems[0].cache_read_price = toBasePrice(pricing.cache_read_price)
|
||||
editorItems[0].cache_creation_price = toBasePrice(pricing.cache_creation_price)
|
||||
}
|
||||
if (pricing.billing_mode && pricing.billing_mode !== editorBillingMode.value) {
|
||||
editorBillingMode.value = pricing.billing_mode
|
||||
@@ -485,47 +507,52 @@ watch(() => props.visible, (val) => {
|
||||
<ASpin />
|
||||
</div>
|
||||
<AAlert v-else-if="officialError" type="error" :title="officialError" />
|
||||
<div v-else-if="officialData" class="official-source-grid">
|
||||
<div v-for="src in officialData.sources" :key="src.source" class="official-source-card">
|
||||
<div class="official-source-header">
|
||||
<ATag size="small" :color="getOfficialSourceMeta(src.source).color">
|
||||
{{ getOfficialSourceMeta(src.source).label }}
|
||||
</ATag>
|
||||
<template v-else-if="officialData">
|
||||
<div v-if="displayCurrency === 'CNY'" class="official-reference-hint">
|
||||
数据源价格单位为美元,已按 1 USD ≈ ¥{{ (1 / cnyToUsd).toFixed(4) }} 折算为人民币,括号内为美元原值
|
||||
</div>
|
||||
<div class="official-source-grid">
|
||||
<div v-for="src in officialData.sources" :key="src.source" class="official-source-card">
|
||||
<div class="official-source-header">
|
||||
<ATag size="small" :color="getOfficialSourceMeta(src.source).color">
|
||||
{{ getOfficialSourceMeta(src.source).label }}
|
||||
</ATag>
|
||||
<template v-if="src.found && src.pricing">
|
||||
<ATag v-if="src.provider" size="small" color="orangered">{{ src.provider }}</ATag>
|
||||
<ATag v-if="src.mode" size="small">{{ src.mode }}</ATag>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="src.found && src.pricing">
|
||||
<ATag v-if="src.provider" size="small" color="orangered">{{ src.provider }}</ATag>
|
||||
<ATag v-if="src.mode" size="small">{{ src.mode }}</ATag>
|
||||
<div v-if="src.max_context_tokens || src.max_output_tokens" class="official-source-meta">
|
||||
<span v-if="src.max_context_tokens">上下文 {{ (src.max_context_tokens / 1000).toFixed(0) }}K</span>
|
||||
<span v-if="src.max_output_tokens">输出 {{ (src.max_output_tokens / 1000).toFixed(0) }}K</span>
|
||||
</div>
|
||||
<div class="official-prices">
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">输入价格</span>
|
||||
<span class="official-price-value">{{ formatOfficialPrice(src.pricing.input_price) }} / 1M</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">输出价格</span>
|
||||
<span class="official-price-value">{{ formatOfficialPrice(src.pricing.output_price) }} / 1M</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">缓存读取</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_read_price ? formatOfficialPrice(src.pricing.cache_read_price) + ' / 1M' : '—' }}</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">缓存创建</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_creation_price ? formatOfficialPrice(src.pricing.cache_creation_price) + ' / 1M' : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AButton type="primary" size="small" long @click="applyOfficialPricing(src.pricing)">应用此价格</AButton>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="src.found && src.pricing">
|
||||
<div v-if="src.max_context_tokens || src.max_output_tokens" class="official-source-meta">
|
||||
<span v-if="src.max_context_tokens">上下文 {{ (src.max_context_tokens / 1000).toFixed(0) }}K</span>
|
||||
<span v-if="src.max_output_tokens">输出 {{ (src.max_output_tokens / 1000).toFixed(0) }}K</span>
|
||||
<div v-else class="official-not-found" :class="{ 'official-not-found--error': src.error }">
|
||||
{{ src.error ? '数据源暂时不可用' : '未找到该模型' }}
|
||||
</div>
|
||||
<div class="official-prices">
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">输入价格</span>
|
||||
<span class="official-price-value">{{ formatBilling(src.pricing.input_price, 2) }} / 1M</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">输出价格</span>
|
||||
<span class="official-price-value">{{ formatBilling(src.pricing.output_price, 2) }} / 1M</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">缓存读取</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_read_price ? formatBilling(src.pricing.cache_read_price, 2) + ' / 1M' : '—' }}</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">缓存创建</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_creation_price ? formatBilling(src.pricing.cache_creation_price, 2) + ' / 1M' : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AButton type="primary" size="small" long @click="applyOfficialPricing(src.pricing)">应用此价格</AButton>
|
||||
</template>
|
||||
<div v-else class="official-not-found" :class="{ 'official-not-found--error': src.error }">
|
||||
{{ src.error ? '数据源暂时不可用' : '未找到该模型' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</APopover>
|
||||
@@ -988,6 +1015,15 @@ watch(() => props.visible, (val) => {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.official-reference-hint {
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ta-text-secondary);
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.official-source-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -9,7 +9,7 @@ import RouteErrorBoundary from '@/components/RouteErrorBoundary.vue'
|
||||
import { useWatermark } from '@/composables/useWatermark'
|
||||
import { useSiteName } from '@/composables/useSiteName'
|
||||
import {
|
||||
IconThunderbolt,
|
||||
IconDesktop,
|
||||
IconDashboard,
|
||||
IconUserGroup,
|
||||
IconApps,
|
||||
@@ -306,7 +306,7 @@ const menuGroups = [
|
||||
label: '数据看板',
|
||||
icon: IconDashboard,
|
||||
items: [
|
||||
{ name: 'AdminWorkbench', label: '工作台', icon: IconThunderbolt },
|
||||
{ name: 'AdminWorkbench', label: '工作台', icon: IconDesktop },
|
||||
{ name: 'AdminDashboard', label: '仪表盘', icon: IconDashboard },
|
||||
{ name: 'AdminRealtimeMonitor', label: '实时监控', icon: IconCommand },
|
||||
],
|
||||
@@ -459,15 +459,15 @@ function handleClickOutside(e: MouseEvent) {
|
||||
// 工作台是聚合视图,红点才是常驻发现入口:管理员不会每天主动点开工作台,
|
||||
// 但会看见菜单上的角标。后端 badges 与 summary 共用同一份 30s Redis 缓存,
|
||||
// 60s 轮询不会额外压库。
|
||||
const workbenchBadges = ref<Record<string, number>>({})
|
||||
const workbenchUrgent = ref(0)
|
||||
const workbenchTotal = ref(0)
|
||||
let badgeTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 菜单项角标:工作台本身显示待办总数,其余菜单显示后端映射到该路由的待办数
|
||||
// 角标只挂工作台菜单:待办的排查线索只在工作台的描述文案里,
|
||||
// 业务菜单里没有对应的定位入口,挂数字只会带来「进去了却找不到问题」的困惑
|
||||
function badgeOf(name: string): number {
|
||||
if (name === 'AdminWorkbench') return workbenchTotal.value
|
||||
return workbenchBadges.value[name] || 0
|
||||
return 0
|
||||
}
|
||||
|
||||
async function fetchWorkbenchBadges() {
|
||||
@@ -475,7 +475,6 @@ async function fetchWorkbenchBadges() {
|
||||
const res = await request.get('/admin/workbench/badges')
|
||||
const data = res.data?.data
|
||||
if (!data) return
|
||||
workbenchBadges.value = data.menus || {}
|
||||
workbenchUrgent.value = data.urgent || 0
|
||||
workbenchTotal.value = data.total || 0
|
||||
} catch {
|
||||
|
||||
@@ -533,6 +533,22 @@ onBeforeUnmount(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 卡片固定高度:告警再多也在卡片内部滚动,不无限撑开页面 */
|
||||
.wb-flow :deep(.arco-card-body) {
|
||||
height: 640px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 细滚动条,贴合整体视觉 */
|
||||
.wb-flow :deep(.arco-card-body)::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.wb-flow :deep(.arco-card-body)::-webkit-scrollbar-thumb {
|
||||
background: var(--color-fill-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.wb-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -596,8 +612,13 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
/* ===== 空态:清空是正常状态 ===== */
|
||||
/* 卡片 body 定高后,空态在固定高度内垂直居中 */
|
||||
.wb-empty {
|
||||
padding: 48px 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -793,6 +814,11 @@ onBeforeUnmount(() => {
|
||||
.wb-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* 单列布局下视口纵向空间更紧张,卡片定高同步收紧 */
|
||||
.wb-flow :deep(.arco-card-body) {
|
||||
height: 560px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@@ -814,5 +840,9 @@ onBeforeUnmount(() => {
|
||||
.wb-item__title {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.wb-flow :deep(.arco-card-body) {
|
||||
height: 480px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,7 @@ const values = useFormValues()
|
||||
<AInput v-model="values['email_smtp_from']" placeholder="noreply@example.com" />
|
||||
</AFormItem>
|
||||
<AFormItem label="发件人名称" help="收件人看到的发件人显示名,留空则使用站点名称">
|
||||
<AInput v-model="values['email_smtp_from_name']" placeholder="aifree" />
|
||||
<AInput v-model="values['email_smtp_from_name']" placeholder="team-api" />
|
||||
</AFormItem>
|
||||
<div class="switch-row">
|
||||
<span class="switch-label">启用 TLS</span>
|
||||
|
||||
@@ -130,8 +130,8 @@ const memberUsageLoading = ref(false)
|
||||
const alertsLoading = ref(false)
|
||||
const healthLoading = ref(false)
|
||||
const budgetLoading = ref(false)
|
||||
// 管理者看趋势默认看 30 天:经营视角关心的是月度走势,不是最近一周
|
||||
const selectedDays = ref(30)
|
||||
// 消费趋势默认看最近 7 天
|
||||
const selectedDays = ref(7)
|
||||
const dashboardData = ref<DashboardData | null>(null)
|
||||
const trendData = ref<TrendPoint[]>([])
|
||||
const modelData = ref<ModelItem[]>([])
|
||||
@@ -416,7 +416,6 @@ onBeforeUnmount(() => {
|
||||
<div class="dashboard-shell">
|
||||
<section class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-primary-500">{{ authStore.tenant?.name || '租户控制台' }}</p>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-slate-900 md:text-[28px]">
|
||||
{{ greeting }},{{ authStore.user?.username || 'Admin' }} <span class="inline-block origin-bottom-right animate-wave">👋</span>
|
||||
</h1>
|
||||
|
||||
@@ -239,7 +239,6 @@ onMounted(refreshAll)
|
||||
<div class="personal-dashboard">
|
||||
<section class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-primary-500">{{ authStore.tenant?.name || '个人工作台' }}</p>
|
||||
<h1 class="text-2xl font-bold text-slate-900 md:text-[28px]">
|
||||
{{ greeting }},{{ authStore.user?.username || '用户' }}
|
||||
</h1>
|
||||
@@ -438,27 +437,15 @@ onMounted(refreshAll)
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-5 md:grid-cols-[minmax(0,.85fr)_minmax(0,1.15fr)]">
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="quality-metric">
|
||||
<p class="text-[11px] text-slate-400">平均响应</p>
|
||||
<p class="mt-1 text-base font-bold tabular-nums text-slate-700">{{ formatMs(overviewData.latency.avg_ms) }}</p>
|
||||
</div>
|
||||
<div class="quality-metric">
|
||||
<p class="text-[11px] text-slate-400">首 Token</p>
|
||||
<p class="mt-1 text-base font-bold tabular-nums text-slate-700">{{ formatMs(overviewData.latency.avg_first_token_ms) }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 self-start">
|
||||
<div class="quality-metric">
|
||||
<p class="text-[11px] text-slate-400">平均响应</p>
|
||||
<p class="mt-1 text-base font-bold tabular-nums text-slate-700">{{ formatMs(overviewData.latency.avg_ms) }}</p>
|
||||
</div>
|
||||
<div class="quality-metric">
|
||||
<p class="text-[11px] text-slate-400">首 Token</p>
|
||||
<p class="mt-1 text-base font-bold tabular-nums text-slate-700">{{ formatMs(overviewData.latency.avg_first_token_ms) }}</p>
|
||||
</div>
|
||||
<!-- 分位数对普通成员偏技术,默认收起,排障时再展开 -->
|
||||
<details class="latency-details">
|
||||
<summary>展开延迟分位数</summary>
|
||||
<div class="mt-2.5 grid grid-cols-3 gap-2">
|
||||
<div v-for="item in latencyItems" :key="item.label" class="quality-metric">
|
||||
<p class="text-[11px] text-slate-400">{{ item.label }}</p>
|
||||
<p class="mt-1 text-sm font-bold tabular-nums text-slate-700">{{ item.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 pt-4 md:border-l md:border-t-0 md:pl-5 md:pt-0">
|
||||
@@ -479,11 +466,23 @@ onMounted(refreshAll)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-strip mt-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="bolt" size="sm" class="flex-shrink-0 text-cyan-500" />
|
||||
<div>
|
||||
<span class="text-xs font-medium text-slate-600">缓存命中率 {{ (overviewData.cache.hit_ratio * 100).toFixed(1) }}%</span>
|
||||
<!-- 次级指标带:左半延迟分位数,右半缓存命中卡片,命中量与节省金额收进卡片内部 -->
|
||||
<div class="mt-4 grid gap-2.5 md:grid-cols-2">
|
||||
<div class="grid grid-cols-3 gap-2.5">
|
||||
<div v-for="item in latencyItems" :key="item.label" class="quality-metric">
|
||||
<p class="text-[11px] text-slate-400">{{ item.label }}</p>
|
||||
<p class="mt-1 text-sm font-bold tabular-nums text-slate-700">{{ item.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quality-metric quality-metric-cache flex items-center gap-3">
|
||||
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-cyan-500/10 text-cyan-500">
|
||||
<Icon name="bolt" size="sm" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-base font-bold tabular-nums text-cyan-600">{{ (overviewData.cache.hit_ratio * 100).toFixed(1) }}%</span>
|
||||
<span class="text-[11px] text-slate-400">缓存命中率</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-[11px] text-slate-400">
|
||||
已命中 {{ formatNumber(overviewData.cache.cache_read_tokens) }} Token,约省下 {{ formatCost(overviewData.cache.saved_cost) }}
|
||||
</p>
|
||||
@@ -913,30 +912,6 @@ onMounted(refreshAll)
|
||||
padding: 0.7rem 0.8rem;
|
||||
}
|
||||
|
||||
.latency-details {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.latency-details > summary {
|
||||
color: #0d9488;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 620;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.latency-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.latency-details > summary::after {
|
||||
content: ' ▾';
|
||||
}
|
||||
|
||||
.latency-details[open] > summary::after {
|
||||
content: ' ▴';
|
||||
}
|
||||
|
||||
.mini-progress {
|
||||
height: 0.35rem;
|
||||
overflow: hidden;
|
||||
@@ -951,14 +926,10 @@ onMounted(refreshAll)
|
||||
background: linear-gradient(90deg, #2dd4bf, #0d9488);
|
||||
}
|
||||
|
||||
.cache-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-radius: 0.8rem;
|
||||
background: rgba(6, 182, 212, 0.08);
|
||||
padding: 0.7rem 0.85rem;
|
||||
/* 缓存命中小卡片:在次级指标带里用青色调区分身份 */
|
||||
.quality-metric-cache {
|
||||
border-color: rgba(6, 182, 212, 0.2);
|
||||
background: rgba(6, 182, 212, 0.07);
|
||||
}
|
||||
|
||||
/* ── 失败列表 ── */
|
||||
|
||||
Reference in new issue
Block a user