Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
chengqian committed 2026-08-20 11:45:46 +08:00
commit ccdb314ecd
81 files changed
+5859 -1024

No files matched your search

+3 -1
View File
@@ -263,7 +263,9 @@ cmd(路由注册)
价格查询优先级:租户独立价 > 套餐价 > 模型基础价 > 硬编码默认。
最终价格公式:`最终价格 = 基础价格 × 模型乘数 × 租户乘数`。模型乘数根据模型稀有度/成本动态调整;租户乘数根据租户等级/用量等级设置(VIP 折扣等)。双层乘数替代单纯的"租户独立定价",提供更灵活的定价能力。
最终价格公式:`最终价格 = 基础价格 × 模型乘数 × 租户乘数 × 时段乘数`。模型乘数根据模型稀有度/成本动态调整;租户乘数根据租户等级/用量等级设置(VIP 折扣等)。双层乘数替代单纯的"租户独立定价",提供更灵活的定价能力。
**时段定价**(`mdl_pricing.time_segments` JSONB,挂在 min_tokens=0 锚点行):命名时段有序数组(名称/适用日/每日时间窗/有效期/乘数),**从上到下先命中先生效,未命中=默认价 1.0**;乘数作用于全部价格(各阶梯档/缓存价/按次价),不支持每时段独立阶梯结构。**定价时刻 = 请求(任务)受理时刻**——结算/异步任务路径必须传受理时刻(`GetModelPriceAt`),预扣时刻≈受理时刻可用 `GetModelPrice`;时段按平台时区(`sys_options.pricing_time_timezone`,默认 Asia/Shanghai)解释。时段配置随 `PricingResult` 进 600s 定价缓存,缓存命中后按 billAt 重评估乘数(`reapplyTimeMultiplier`),TimeMultiplier 零值兜底为 1.0。
### 钱包状态:Redis 权威 + DB 物化(强约束)
+6
View File
@@ -64,6 +64,11 @@ type IAdminV1 interface {
ChannelExport(ctx context.Context, req *v1.ChannelExportReq) (res *v1.ChannelExportRes, err error)
ChannelClone(ctx context.Context, req *v1.ChannelCloneReq) (res *v1.ChannelCloneRes, err error)
ChannelResetHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (res *v1.ChannelResetHealthRes, err error)
ChannelDebugLogList(ctx context.Context, req *v1.ChannelDebugLogListReq) (res *v1.ChannelDebugLogListRes, err error)
ChannelDebugLogStats(ctx context.Context, req *v1.ChannelDebugLogStatsReq) (res *v1.ChannelDebugLogStatsRes, err error)
ChannelDebugLogDetail(ctx context.Context, req *v1.ChannelDebugLogDetailReq) (res *v1.ChannelDebugLogDetailRes, err error)
ChannelDebugLogDelete(ctx context.Context, req *v1.ChannelDebugLogDeleteReq) (res *v1.ChannelDebugLogDeleteRes, err error)
ChannelDebugLogClear(ctx context.Context, req *v1.ChannelDebugLogClearReq) (res *v1.ChannelDebugLogClearRes, err error)
ChannelErrorEventList(ctx context.Context, req *v1.ChannelErrorEventListReq) (res *v1.ChannelErrorEventListRes, err error)
ChannelErrorStats(ctx context.Context, req *v1.ChannelErrorStatsReq) (res *v1.ChannelErrorStatsRes, err error)
ChannelErrorTrend(ctx context.Context, req *v1.ChannelErrorTrendReq) (res *v1.ChannelErrorTrendRes, err error)
@@ -254,6 +259,7 @@ type IAdminV1 interface {
TenantExport(ctx context.Context, req *v1.TenantExportReq) (res *v1.TenantExportRes, err error)
AdminMemberExport(ctx context.Context, req *v1.AdminMemberExportReq) (res *v1.AdminMemberExportRes, err error)
TenantSelect(ctx context.Context, req *v1.TenantSelectReq) (res *v1.TenantSelectRes, err error)
TenantApiKeySelect(ctx context.Context, req *v1.TenantApiKeySelectReq) (res *v1.TenantApiKeySelectRes, err error)
TenantLevelConfigList(ctx context.Context, req *v1.TenantLevelConfigListReq) (res *v1.TenantLevelConfigListRes, err error)
TenantLevelConfigCreate(ctx context.Context, req *v1.TenantLevelConfigCreateReq) (res *v1.TenantLevelConfigCreateRes, err error)
TenantLevelConfigUpdate(ctx context.Context, req *v1.TenantLevelConfigUpdateReq) (res *v1.TenantLevelConfigUpdateRes, err error)
+8
View File
@@ -93,6 +93,10 @@ type ChannelUpdateReq struct {
StrictCapacity *bool `json:"strict_capacity" dc:"严格容量(fail-closed)"`
IsVIP *bool `json:"is_vip" dc:"是否VIP专属渠道"`
UseProxy *bool `json:"use_proxy" dc:"启用代理"`
DebugLogEnabled *bool `json:"debug_log_enabled" dc:"启用渠道调试日志(记录四段完整报文,排障用,用完及时关闭)"`
DebugLogTenantID *int64 `json:"debug_log_tenant_id" dc:"调试目标租户ID过滤(0=不限)"`
DebugLogUserID *int64 `json:"debug_log_user_id" dc:"调试目标成员ID过滤(0=不限)"`
DebugLogApiKeyID *int64 `json:"debug_log_api_key_id" dc:"调试目标密钥ID过滤(0=不限)"`
SharingThreshold *float64 `json:"sharing_threshold" dc:"普通租户借用阈值"`
PreemptionThreshold *float64 `json:"preemption_threshold" dc:"VIP抢占阈值"`
BorrowingCooldownSeconds *int `json:"borrowing_cooldown_seconds" dc:"被抢占后冷却时间(秒)"`
@@ -127,6 +131,10 @@ type ChannelDetailRes struct {
Remark string `json:"remark"`
IsVIP bool `json:"is_vip"`
UseProxy bool `json:"use_proxy"`
DebugLogEnabled bool `json:"debug_log_enabled"`
DebugLogTenantID int64 `json:"debug_log_tenant_id"`
DebugLogUserID int64 `json:"debug_log_user_id"`
DebugLogApiKeyID int64 `json:"debug_log_api_key_id"`
SharingThreshold *float64 `json:"sharing_threshold"`
PreemptionThreshold *float64 `json:"preemption_threshold"`
BorrowingCooldownSeconds *int `json:"borrowing_cooldown_seconds"`
+75
View File
@@ -0,0 +1,75 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// 渠道调试日志:调试开关开启的渠道,per-attempt 记录客户端↔系统↔上游四段完整报文。
// 端点全部为 GET/DELETE,路径在 /channels/ 下,自动命中 rbac 前缀规则
//(GET → channel:view,DELETE → channel:delete),无需新增权限点。
// ChannelDebugLogListReq 渠道调试日志列表请求(只返回元数据与四段体积,不返回 body)
type ChannelDebugLogListReq struct {
g.Meta `path:"/channels/{channel_id}/debug-logs" method:"get" mime:"json" tags:"管理后台-渠道" summary:"渠道调试日志列表"`
ChannelID int64 `json:"channel_id" in:"path" v:"required" dc:"渠道ID"`
Page int `json:"page" in:"query" d:"1" v:"min:1" dc:"页码"`
PageSize int `json:"page_size" in:"query" d:"20" v:"min:1|max:100" dc:"每页数量"`
RequestID string `json:"request_id" in:"query" dc:"请求ID精确筛选"`
ModelName string `json:"model_name" in:"query" dc:"模型名模糊筛选"`
UpstreamStatus *int `json:"upstream_status" in:"query" dc:"上游状态码筛选"`
ClientStatus *int `json:"client_status" in:"query" dc:"客户端状态码筛选"`
IsStream *bool `json:"is_stream" in:"query" dc:"是否流式"`
OnlyError *bool `json:"only_error" in:"query" dc:"仅看有错误的记录"`
StartDate string `json:"start_date" in:"query" dc:"开始日期 YYYY-MM-DD"`
EndDate string `json:"end_date" in:"query" dc:"结束日期 YYYY-MM-DD"`
}
// ChannelDebugLogListRes 渠道调试日志列表响应
type ChannelDebugLogListRes struct {
List []map[string]any `json:"list"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// ChannelDebugLogStatsReq 渠道调试日志统计请求
type ChannelDebugLogStatsReq struct {
g.Meta `path:"/channels/{channel_id}/debug-logs/stats" method:"get" mime:"json" tags:"管理后台-渠道" summary:"渠道调试日志统计"`
ChannelID int64 `json:"channel_id" in:"path" v:"required" dc:"渠道ID"`
}
// ChannelDebugLogStatsRes 渠道调试日志统计响应
type ChannelDebugLogStatsRes struct {
Total int64 `json:"total" dc:"记录条数"`
TotalBytes int64 `json:"total_bytes" dc:"四段 body 落库总字节数"`
OldestAt string `json:"oldest_at" dc:"最早记录时间"`
}
// ChannelDebugLogDetailReq 渠道调试日志详情请求(返回四段完整报文)
type ChannelDebugLogDetailReq struct {
g.Meta `path:"/channels/{channel_id}/debug-logs/{id}" method:"get" mime:"json" tags:"管理后台-渠道" summary:"渠道调试日志详情"`
ChannelID int64 `json:"channel_id" in:"path" v:"required" dc:"渠道ID"`
ID int64 `json:"id" in:"path" v:"required" dc:"记录ID"`
}
// ChannelDebugLogDetailRes 渠道调试日志详情响应
type ChannelDebugLogDetailRes struct {
Data map[string]any `json:"data"`
}
// ChannelDebugLogDeleteReq 删除单条调试日志请求(硬删除)
type ChannelDebugLogDeleteReq struct {
g.Meta `path:"/channels/{channel_id}/debug-logs/{id}" method:"delete" mime:"json" tags:"管理后台-渠道" summary:"删除调试日志"`
ChannelID int64 `json:"channel_id" in:"path" v:"required" dc:"渠道ID"`
ID int64 `json:"id" in:"path" v:"required" dc:"记录ID"`
}
// ChannelDebugLogDeleteRes 删除调试日志响应
type ChannelDebugLogDeleteRes struct{}
// ChannelDebugLogClearReq 清空渠道调试日志请求(按渠道硬删除全部)
type ChannelDebugLogClearReq struct {
g.Meta `path:"/channels/{channel_id}/debug-logs" method:"delete" mime:"json" tags:"管理后台-渠道" summary:"清空渠道调试日志"`
ChannelID int64 `json:"channel_id" in:"path" v:"required" dc:"渠道ID"`
}
// ChannelDebugLogClearRes 清空渠道调试日志响应
type ChannelDebugLogClearRes struct{}
+55 -30
View File
@@ -106,6 +106,19 @@ type PricingItem struct {
CacheCreationPrice float64 `json:"cache_creation_price" dc:"缓存创建每 1M token 价格"`
}
// TimeSegmentItem 时段定价项(mdl_pricing.time_segments JSONB 数组元素)。
// 语义:按数组顺序先命中先生效(促销时段放前面覆盖常驻时段),未命中=默认价(乘数 1.0);
// 最终费用 = 各项小计 × 租户乘数 × 时段乘数。
type TimeSegmentItem struct {
Name string `json:"name" v:"required" dc:"时段名(写入计费快照)"`
Days []int `json:"days" dc:"适用星期 1=周一..7=周日;空=每天"`
StartTime string `json:"start_time" dc:"每日开始时刻 HH:MM;与 end_time 均空=全天"`
EndTime string `json:"end_time" dc:"每日结束时刻 HH:MM;end<start 表示跨零点(如 22:00~06:00)"`
ValidFrom string `json:"valid_from" dc:"生效起始日期 YYYY-MM-DD(含端点);空=长期,促销/定时调价用"`
ValidTo string `json:"valid_to" dc:"生效结束日期 YYYY-MM-DD(含端点)"`
Multiplier float64 `json:"multiplier" v:"required|min:0.0001|max:10" dc:"价格乘数,0.5=半价"`
}
// PricingGetReq 获取模型定价
type PricingGetReq struct {
g.Meta `path:"/models/{model_id}/pricing" method:"get" mime:"json" tags:"管理后台-模型" summary:"获取模型定价"`
@@ -113,14 +126,24 @@ type PricingGetReq struct {
}
type PricingGetRes struct {
List []PricingItem `json:"list"`
List []PricingItem `json:"list"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(锚点行 time_segments)"`
// 以下为锚点行展示字段(仅 min_tokens=0 行生效)
PriceNote string `json:"price_note" dc:"价格说明(仅管理后台可见的内部备注)"`
DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示,如 7折起)"`
PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示,提示价格有变动)"`
}
// PricingSetReq 设置模型定价(全量替换)
type PricingSetReq struct {
g.Meta `path:"/models/{model_id}/pricing" method:"put" mime:"json" tags:"管理后台-模型" summary:"设置模型定价"`
ModelID int64 `json:"model_id" in:"path" v:"required" dc:"模型ID"`
Items []PricingItem `json:"items" v:"required" dc:"定价列表"`
g.Meta `path:"/models/{model_id}/pricing" method:"put" mime:"json" tags:"管理后台-模型" summary:"设置模型定价"`
ModelID int64 `json:"model_id" in:"path" v:"required" dc:"模型ID"`
Items []PricingItem `json:"items" v:"required" dc:"定价列表"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价(可选,全量替换;空数组清除时段配置)"`
// 以下为锚点行展示字段(全量替换语义:空=清除;price_note 不透出到租户端)
PriceNote string `json:"price_note" dc:"价格说明(仅内部可见)"`
DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示)"`
PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示)"`
}
type PricingSetRes struct{}
@@ -232,19 +255,20 @@ type ModelImportPreviewRes struct {
// ModelImportPreviewItem 导入预览项(模型信息 + 冲突标记)
type ModelImportPreviewItem struct {
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Status string `json:"status"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
Capabilities map[string]bool `json:"capabilities"`
Description string `json:"description"`
Tags []string `json:"tags"`
SunsetDate string `json:"sunset_date"`
ReplacementModel string `json:"replacement_model"`
Pricing []PricingItem `json:"pricing"`
Conflict string `json:"conflict"` // "" 或 "exists"
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Status string `json:"status"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
Capabilities map[string]bool `json:"capabilities"`
Description string `json:"description"`
Tags []string `json:"tags"`
SunsetDate string `json:"sunset_date"`
ReplacementModel string `json:"replacement_model"`
Pricing []PricingItem `json:"pricing"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表"`
Conflict string `json:"conflict"` // "" 或 "exists"
}
// ModelImportReq 确认导入模型请求
@@ -255,19 +279,20 @@ type ModelImportReq struct {
// ModelImportItem 导入模型项
type ModelImportItem struct {
ModelId string `json:"model_id" v:"required" dc:"模型唯一标识"`
ModelName string `json:"model_name" dc:"模型显示名称"`
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类无效" dc:"模型分类"`
Status string `json:"status" dc:"状态"`
MaxContextTokens int `json:"max_context_tokens" dc:"最大上下文 token 数"`
MaxOutputTokens int `json:"max_output_tokens" dc:"最大输出 token 数"`
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
Description string `json:"description" dc:"模型描述"`
Tags []string `json:"tags" dc:"标签列表"`
SunsetDate string `json:"sunset_date" dc:"下线日期"`
ReplacementModel string `json:"replacement_model" dc:"推荐替代模型名"`
Pricing []PricingItem `json:"pricing" dc:"定价列表"`
ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"`
ModelId string `json:"model_id" v:"required" dc:"模型唯一标识"`
ModelName string `json:"model_name" dc:"模型显示名称"`
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类无效" dc:"模型分类"`
Status string `json:"status" dc:"状态"`
MaxContextTokens int `json:"max_context_tokens" dc:"最大上下文 token 数"`
MaxOutputTokens int `json:"max_output_tokens" dc:"最大输出 token 数"`
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
Description string `json:"description" dc:"模型描述"`
Tags []string `json:"tags" dc:"标签列表"`
SunsetDate string `json:"sunset_date" dc:"下线日期"`
ReplacementModel string `json:"replacement_model" dc:"推荐替代模型名"`
Pricing []PricingItem `json:"pricing" dc:"定价列表"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(可选)"`
ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"`
}
// ModelImportRes 导入结果响应
+24
View File
@@ -174,3 +174,27 @@ type TenantSelectItem struct {
Name string `json:"name"`
Code string `json:"code"`
}
// TenantApiKeySelectReq 租户 API Key 下拉选择列表(轻量,用于调试目标过滤等选择器)
type TenantApiKeySelectReq struct {
g.Meta `path:"/tenants/{tenant_id}/api-keys/select" method:"get" mime:"json" tags:"管理后台-租户管理" summary:"租户API Key下拉选择列表"`
TenantID int64 `json:"tenant_id" in:"path" v:"required" dc:"租户ID"`
UserID int64 `json:"user_id" in:"query" dc:"按创建者成员ID筛选(0=不限)"`
Page int `json:"page" in:"query" d:"1" dc:"页码"`
PageSize int `json:"page_size" in:"query" d:"100" v:"min:1|max:200" dc:"每页数量"`
}
type TenantApiKeySelectRes struct {
List []TenantApiKeySelectItem `json:"list"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type TenantApiKeySelectItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
UserID int64 `json:"user_id"`
Status string `json:"status"`
}
+17 -10
View File
@@ -20,16 +20,23 @@ type MarketplaceListRes struct {
}
type MarketplaceModelItem struct {
ModelId string `json:"model_id"` // 模型标识
ModelName string `json:"model_name"` // 模型显示名称
Category string `json:"category"` // 分类(chat/embedding/image等)
Description string `json:"description"` // 描述
MaxContextTokens int `json:"max_context_tokens"` // 最大上下文 tokens
MaxOutputTokens int `json:"max_output_tokens"` // 最大输出 tokens
InputPrice float64 `json:"input_price"` // 输入价格(每百万 token,USD)
OutputPrice float64 `json:"output_price"` // 输出价格(每百万 token,USD)
Tags []string `json:"tags"` // 标签
Capabilities g.Map `json:"capabilities"` // 能力标签
ModelId string `json:"model_id"` // 模型标识
ModelName string `json:"model_name"` // 模型显示名称
Category string `json:"category"` // 分类(chat/embedding/image等)
Description string `json:"description"` // 描述
MaxContextTokens int `json:"max_context_tokens"` // 最大上下文 tokens
MaxOutputTokens int `json:"max_output_tokens"` // 最大输出 tokens
BillingMode string `json:"billing_mode"` // 计费模式:token/per_request/tiered
InputPrice float64 `json:"input_price"` // 输入价格(每百万 token,USD;tiered=首档)
OutputPrice float64 `json:"output_price"` // 输出价格(每百万 token,USD;tiered=首档)
PerRequestPrice float64 `json:"per_request_price"` // 按次单价(USD/次,仅 per_request)
CacheReadPrice float64 `json:"cache_read_price"` // 缓存读取价格(每百万 token,USD)
CacheCreationPrice float64 `json:"cache_creation_price"` // 缓存创建价格(每百万 token,USD)
DiscountLabel string `json:"discount_label"` // 折扣标签(营销展示,空=不展示)
PriceChangeNote string `json:"price_change_note"` // 价格调整说明(对外提示,空=不展示)
TimePrices []TimePriceItem `json:"time_prices"` // 时段价目(平台基础价 × 时段乘数,无配置=空)
Tags []string `json:"tags"` // 标签
Capabilities g.Map `json:"capabilities"` // 能力标签
}
type MarketplaceDetailReq struct {
+21
View File
@@ -23,6 +23,21 @@ type PricingTierItem struct {
OutputPrice float64 `json:"output_price"`
}
// TimePriceItem 时段价目项(后端按「租户有效价 × 时段乘数」换算好的展示价,前端直接渲染)。
// tiered 模型 InputPrice/OutputPrice 为首档换算价(起价);per_request 模型填 PerRequestPrice。
type TimePriceItem struct {
Name string `json:"name"` // 时段名
Days []int `json:"days,omitempty"` // 适用星期 1=周一..7=周日;空=每天
StartTime string `json:"start_time,omitempty"` // 空=全天
EndTime string `json:"end_time,omitempty"`
ValidFrom string `json:"valid_from,omitempty"` // 促销/定时调价窗口(空=长期)
ValidTo string `json:"valid_to,omitempty"`
Multiplier float64 `json:"multiplier"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
}
// TenantAvailableModelItem 租户可用模型信息
type TenantAvailableModelItem struct {
ID int64 `json:"id"`
@@ -43,6 +58,12 @@ type TenantAvailableModelItem struct {
CacheReadPrice *float64 `json:"cache_read_price"`
CacheCreationPrice *float64 `json:"cache_creation_price"`
PricingTiers []PricingTierItem `json:"pricing_tiers"`
// TimePrices 时段价目(平台配置了时段定价的模型才有;价格为换算后的展示价)
TimePrices []TimePriceItem `json:"time_prices"`
// DiscountLabel 折扣标签(平台定价锚点行配置的营销展示文案,如"7折起";NULL=不展示)
DiscountLabel *string `json:"discount_label"`
// PriceChangeNote 价格调整说明(对外提示价格有变动;NULL=不展示)
PriceChangeNote *string `json:"price_change_note"`
// AsyncImage 图片模型异步端点是否可用(提交 /v1/images/generations/async 后轮询取图)。
// 仅图片分类模型有意义;真异步厂商,或同步厂商且「同步图片异步化」开关开启时为 true。
AsyncImage bool `json:"async_image"`
+28
View File
@@ -751,3 +751,31 @@ err := g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error {
- **前端编辑表单慎用 `x || 默认值` 兜底**:对数值字段,0 会被 falsy 规则吞掉,既掩盖真实数据,又可能在保存时把合法的 0 改写为默认值。
排查信号:某字段在库里莫名变成 0/空串,而创建时明明写过合法值——查该表所有 UPDATE 路径中是否存在非指针字段的无条件赋值。
### 2026-08-20(三):Fields 挂在共用模型上导致 COUNT(col1, col2, ...) 报错
**问题**:`TenantApiKeySelect` 中把 `.Fields("id, name, ...")` 挂在模型链上后调用 `m.Count()`,PostgreSQL 报 `function count(bigint, character varying, ...) does not exist`——gdb 生成了 `SELECT COUNT("id","name",...)` 而不是 `COUNT(*)`。
**原因**:gdb 的 `Count()` 会复用模型上已设置的 `Fields` 作为 COUNT 的参数列表(多字段时逐个传入 count 聚合函数),而 PostgreSQL 的 `count` 只接受单个参数(MySQL 语义下多参 count 也不等于总行数)。这是"链式复用同一 model 做计数 + 查询"模式下的隐蔽陷阱:查列表正常、计数 SQL 被悄悄改写。
**修复**:`.Fields(...)` 只挂在分页查询链上(`m.Fields(...).Page(...).Scan(...)`),`Count()` 用不带 Fields 的基础模型(生成 `COUNT(*)`)。
**正确做法**:凡"先 Count 再分页 Scan"的写法,构建条件模型(Where/Order)后**先 Count**,再把 `Fields/Page/Scan` 链在同一个基础模型上;不要在 Count 之前设置 Fields。若必须提前设置 Fields,用 `m.Clone()` 或为 Count 单独构建条件链。
排查信号:日志出现 `function count(... N 个参数 ...) does not exist` 或 `COUNT("a","b")` 形态的 SQL,且调用栈落在 `*.Count()`。
### 2026-08-20(三):Model.Scan(&string) 静默吞错,局部更新把未提交的 JSONB 字段重置为默认值
**问题**:渠道调试日志「保存捕捉条件后开关自动关闭」。PUT 只带 `debug_log_tenant_id` 等过滤字段,settings JSONB 里的 `debug_log_enabled:true` 被静默丢弃。查库发现过滤字段写入成功、唯独开关标志消失。
**原因**:读旧 settings 用的是 `_ = dao.ChnChannels...Fields("settings").Scan(&currentSettings)`——gdb 的 `Model.Scan` **只接受 struct/*struct/[]struct/[]*struct**,传入 `*string` 直接返回错误 `element of parameter "pointer" for function Scan should type of struct/...`;`_ =` 把错误吞掉后 `currentSettings` 永远是空串,`ParseChannelSettings("")` 返回默认值,套上本次请求的字段 marshal 回写——**所有未在本次请求中显式提交的 settings 字段全部被重置为默认**。开关单独提交时显式带上了 enabled 所以正常;过滤条件单独提交时没带 enabled,于是被重置为 false。这是个从原 UseProxy 更新块继承来的潜伏 bug:此前每次切换 use_proxy 同样会把 timeout_seconds 覆盖、header_override 等自定义配置静默洗掉,只是显式字段每次都带、无人察觉。同模式的 `GetTenantAuditLevel`(audit.go,只读路径)也中招——租户级审计设置因此**从未生效过**。
**修复**:读单列字符串一律用 `Model.Value()`(返回 `*gvar.Var`,`v.String()` 取值),两处(channel.go 更新块、audit.go)均已改为 Value()。
**正确做法(通用规则)**:
- **读单个标量列用 `.Value()` 或 `.Array()`/`.All()` 后取字段,禁止 `Scan(&string)`/`Scan(&int)`**——Scan 只认 struct 系列,对基础类型指针直接报错。
- **禁止 `_ =` 吞掉 DB 读取错误**,尤其是「读旧值 → 改字段 → 写回」的合并更新模式:读失败意味着回写会把整个 JSONB 洗成默认值,属于数据破坏而非可忽略的小错。
- 「读-改-写」JSONB 配置的合并更新块,读失败时应中止更新并返回错误,而不是带着默认值继续。
排查信号:JSONB 配置列中「本次没提交的字段莫名回到默认值」;`grep 'Scan(&' `命中基础类型指针。
+4
View File
@@ -119,6 +119,10 @@ var (
// Initialize async audit log writer
common.InitAuditLogWriter()
// Initialize async channel debug log writer + 注入 relay 层提交钩子
common.InitChannelDebugLogWriter()
relayLogic.InitDebugLogRecorder()
// Initialize async error log writer
response.InitErrorLogWriter()
@@ -0,0 +1,24 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) ChannelDebugLogList(ctx context.Context, req *v1.ChannelDebugLogListReq) (res *v1.ChannelDebugLogListRes, err error) {
return service.Admin().ChannelDebugLogList(ctx, req)
}
func (c *ControllerV1) ChannelDebugLogStats(ctx context.Context, req *v1.ChannelDebugLogStatsReq) (res *v1.ChannelDebugLogStatsRes, err error) {
return service.Admin().ChannelDebugLogStats(ctx, req)
}
func (c *ControllerV1) ChannelDebugLogDetail(ctx context.Context, req *v1.ChannelDebugLogDetailReq) (res *v1.ChannelDebugLogDetailRes, err error) {
return service.Admin().ChannelDebugLogDetail(ctx, req)
}
func (c *ControllerV1) ChannelDebugLogDelete(ctx context.Context, req *v1.ChannelDebugLogDeleteReq) (res *v1.ChannelDebugLogDeleteRes, err error) {
return service.Admin().ChannelDebugLogDelete(ctx, req)
}
func (c *ControllerV1) ChannelDebugLogClear(ctx context.Context, req *v1.ChannelDebugLogClearReq) (res *v1.ChannelDebugLogClearRes, err error) {
return service.Admin().ChannelDebugLogClear(ctx, req)
}
@@ -37,3 +37,6 @@ func (c *ControllerV1) AdminMemberExport(ctx context.Context, req *v1.AdminMembe
func (c *ControllerV1) TenantSelect(ctx context.Context, req *v1.TenantSelectReq) (res *v1.TenantSelectRes, err error) {
return service.Admin().TenantSelect(ctx, req)
}
func (c *ControllerV1) TenantApiKeySelect(ctx context.Context, req *v1.TenantApiKeySelectReq) (res *v1.TenantApiKeySelectRes, err error) {
return service.Admin().TenantApiKeySelect(ctx, req)
}
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"github.com/qianfree/team-api/internal/dao/internal"
)
// chnDebugLogsDao is the data access object for the table chn_debug_logs.
// You can define custom methods on it to extend its functionality as needed.
type chnDebugLogsDao struct {
*internal.ChnDebugLogsDao
}
var (
// ChnDebugLogs is a globally accessible object for table chn_debug_logs operations.
ChnDebugLogs = chnDebugLogsDao{internal.NewChnDebugLogsDao()}
)
// Add your custom methods and functionality below.
+2 -2
View File
@@ -29,7 +29,7 @@ type BilUsageDailyColumns struct {
ChannelId string // 渠道ID(0=无渠道)
Status string // 请求状态:success/error/timeout/cancelled
RequestCount string // 请求数(COUNT(*))
InputTokens string // 输入Token合计
InputTokens string // 输入Token合计(含缓存总输入,源自 bil_usage_logs.input_tokens)
OutputTokens string // 输出Token合计
TotalCost string // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost)
AccountCost string // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)
@@ -37,7 +37,7 @@ type BilUsageDailyColumns struct {
SumFirstTokenMs string // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)
CreatedAt string //
UpdatedAt string //
CacheCreationTokens string // 缓存创建 token 数(Claude cache_creation,SUM 聚合)
CacheCreationTokens string // 写入缓存的 token 数(Claude cache_creation / OpenAI cache_write,SUM 聚合)
CacheReadTokens string // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)
CacheHitRequestCount string // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)
}
+2 -2
View File
@@ -29,7 +29,7 @@ type BilUsageLogsColumns struct {
ModelName string // 调用的模型名
RequestId string // 请求唯一ID
RelayMode string // 代理模式:chat_completions / embeddings / images_generations 等
InputTokens string // 输入 token 数
InputTokens string // 输入 token 数(含缓存总输入:base + cache_read + cache_creation,跨渠道统一口径)
OutputTokens string // 输出 token 数
TotalCost string // 本次调用费用
Currency string // 货币(USD)
@@ -39,7 +39,7 @@ type BilUsageLogsColumns struct {
ClientIp string // 客户端 IP
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
CacheCreationTokens string // 缓存创建 token 数 (Claude)
CacheCreationTokens string // 写入缓存的 token 数(Claude cache_creation_input_tokens / OpenAI Responses cache_write_tokens)
CacheReadTokens string // 缓存读取 token 数 (Claude/OpenAI)
InputCost string // 输入 token 费用
OutputCost string // 输出 token 费用
+157
View File
@@ -0,0 +1,157 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// ChnDebugLogsDao is the data access object for the table chn_debug_logs.
type ChnDebugLogsDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns ChnDebugLogsColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// ChnDebugLogsColumns defines and stores column names for the table chn_debug_logs.
type ChnDebugLogsColumns struct {
Id string // 主键ID
ChannelId string // 渠道ID(逻辑关联 chn_channels.id,无外键)
ChannelName string // 渠道名称(冗余存储,渠道删除后仍可辨识)
ChannelType string // 渠道类型(ProviderType 枚举值)
RequestId string // 请求唯一ID(同一请求多次重试共享)
TenantId string // 租户ID
UserId string // 用户ID
ApiKeyId string // API Key ID
ModelName string // 用户请求的模型名
UpstreamModel string // 上游实际使用的模型名(模型映射后)
RelayMode string // 转发模式(chat_completions/claude_messages/embeddings 等)
InboundPath string // 入站端点路径(如 /v1/chat/completions)
UpstreamUrl string // 上游请求 URL(query 中的凭证参数已脱敏)
IsStream string // 是否流式请求
RetryIndex string // 重试轮次(0=首次尝试)
IsFinal string // 是否为产生客户端响应的最终尝试(成功/终止/流中断)
UpstreamStatusCode string // 上游 HTTP 状态码(未发起请求或连接失败为 NULL)
ClientStatusCode string // 返回客户端的状态码
Error string // 本尝试的错误信息(成功为空)
ClientReqHeaders string // 段1 客户端请求头(凭证类头脱敏:前6后4)JSON
ClientReqBody string // 段1 客户端请求体(完整不截断;二进制为 base64,见 encoding 列)
ClientReqEncoding string // 段1 编码:plain / base64
UpstreamReqHeaders string // 段2 发往上游的最终请求头(协议转换+override 后,凭证类脱敏)JSON
UpstreamReqBody string // 段2 发往上游的请求体(实际发送字节,含协议转换;二进制为 base64)
UpstreamReqEncoding string // 段2 编码:plain / base64
UpstreamRespHeaders string // 段3 上游响应头 JSON(Content-Encoding 可能已被 net/http 透明解压移除)
UpstreamRespBody string // 段3 上游响应体(Go 透明解压后的字节;流式为 SSE 原文;二进制为 base64)
UpstreamRespEncoding string // 段3 编码:plain / base64
ClientRespHeaders string // 段4 返回客户端的响应头 JSON
ClientRespBody string // 段4 返回客户端的响应体(协议转换后,完整不截断;二进制为 base64)
ClientRespEncoding string // 段4 编码:plain / base64
UpstreamLatencyMs string // 上游往返耗时(RoundTrip 到响应头)毫秒
TotalLatencyMs string // 请求总耗时毫秒
FirstTokenMs string // 首字节延迟毫秒
ClientReqBytes string // 段1 请求体原始字节数(base64 落库膨胀前的真实大小)
UpstreamReqBytes string // 段2 请求体原始字节数
UpstreamRespBytes string // 段3 响应体原始字节数
ClientRespBytes string // 段4 响应体原始字节数
CreatedAt string // 创建时间
Conversion string // 协议转换信息 JSON:client_format 客户端协议 / upstream_format 上游协议 / chain 请求侧转换链 / bridge 桥接方式(responses_api|responses_direct|pass_through,空=常规转换)
}
// chnDebugLogsColumns holds the columns for the table chn_debug_logs.
var chnDebugLogsColumns = ChnDebugLogsColumns{
Id: "id",
ChannelId: "channel_id",
ChannelName: "channel_name",
ChannelType: "channel_type",
RequestId: "request_id",
TenantId: "tenant_id",
UserId: "user_id",
ApiKeyId: "api_key_id",
ModelName: "model_name",
UpstreamModel: "upstream_model",
RelayMode: "relay_mode",
InboundPath: "inbound_path",
UpstreamUrl: "upstream_url",
IsStream: "is_stream",
RetryIndex: "retry_index",
IsFinal: "is_final",
UpstreamStatusCode: "upstream_status_code",
ClientStatusCode: "client_status_code",
Error: "error",
ClientReqHeaders: "client_req_headers",
ClientReqBody: "client_req_body",
ClientReqEncoding: "client_req_encoding",
UpstreamReqHeaders: "upstream_req_headers",
UpstreamReqBody: "upstream_req_body",
UpstreamReqEncoding: "upstream_req_encoding",
UpstreamRespHeaders: "upstream_resp_headers",
UpstreamRespBody: "upstream_resp_body",
UpstreamRespEncoding: "upstream_resp_encoding",
ClientRespHeaders: "client_resp_headers",
ClientRespBody: "client_resp_body",
ClientRespEncoding: "client_resp_encoding",
UpstreamLatencyMs: "upstream_latency_ms",
TotalLatencyMs: "total_latency_ms",
FirstTokenMs: "first_token_ms",
ClientReqBytes: "client_req_bytes",
UpstreamReqBytes: "upstream_req_bytes",
UpstreamRespBytes: "upstream_resp_bytes",
ClientRespBytes: "client_resp_bytes",
CreatedAt: "created_at",
Conversion: "conversion",
}
// NewChnDebugLogsDao creates and returns a new DAO object for table data access.
func NewChnDebugLogsDao(handlers ...gdb.ModelHandler) *ChnDebugLogsDao {
return &ChnDebugLogsDao{
group: "default",
table: "chn_debug_logs",
columns: chnDebugLogsColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *ChnDebugLogsDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *ChnDebugLogsDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *ChnDebugLogsDao) Columns() ChnDebugLogsColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *ChnDebugLogsDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *ChnDebugLogsDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *ChnDebugLogsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+8
View File
@@ -33,6 +33,10 @@ type MdlPricingColumns struct {
UpdatedAt string //
CacheReadPrice string // 缓存读取每 1M token 价格(直接定价)
CacheCreationPrice string // 缓存创建每 1M token 价格(直接定价)
TimeSegments string // 时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{"name":"闲时","days":[1,2,3,4,5],"start_time":"00:00","end_time":"08:00","valid_from":"","valid_to":"","multiplier":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点
PriceNote string // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
DiscountLabel string // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
PriceChangeNote string // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
}
// mdlPricingColumns holds the columns for the table mdl_pricing.
@@ -49,6 +53,10 @@ var mdlPricingColumns = MdlPricingColumns{
UpdatedAt: "updated_at",
CacheReadPrice: "cache_read_price",
CacheCreationPrice: "cache_creation_price",
TimeSegments: "time_segments",
PriceNote: "price_note",
DiscountLabel: "discount_label",
PriceChangeNote: "price_change_note",
}
// NewMdlPricingDao creates and returns a new DAO object for table data access.
+17 -13
View File
@@ -56,7 +56,7 @@ func HandleChatCompletions(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteRelayError(capture, err)
handler.WriteRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/chat/completions", latencyMs, firstTokenMs(billingResult))
return
}
@@ -90,7 +90,7 @@ func HandleEmbeddings(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteRelayError(capture, err)
handler.WriteRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/embeddings", latencyMs, firstTokenMs(billingResult))
return
}
@@ -124,7 +124,7 @@ func HandleImagesGenerations(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteRelayError(capture, err)
handler.WriteRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/images/generations", latencyMs, firstTokenMs(billingResult))
return
}
@@ -158,7 +158,7 @@ func HandleCompletions(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteRelayError(capture, err)
handler.WriteRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/completions", latencyMs, firstTokenMs(billingResult))
return
}
@@ -196,7 +196,7 @@ func HandleResponses(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteRelayError(capture, err)
handler.WriteRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/responses", latencyMs, firstTokenMs(billingResult))
return
}
@@ -228,7 +228,7 @@ func HandleMessages(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteClaudeRelayError(capture, err)
handler.WriteClaudeRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/messages", latencyMs, firstTokenMs(billingResult))
return
}
@@ -314,7 +314,7 @@ func HandleGeminiGenerateContent(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteGeminiRelayError(capture, err)
handler.WriteGeminiRelayError(rc.Writer, err)
recordAudit(r, rc, capture, modifiedBody, path, latencyMs, firstTokenMs(billingResult))
return
}
@@ -568,6 +568,10 @@ func recordAudit(r *ghttp.Request, rc *handler.RelayContext, capture *ResponseCa
ResponseHeaders: capture.ResponseHeaders(),
ForwardingTrace: rc.ForwardingTrace,
})
// 渠道调试日志:补段4(客户端响应,此时错误分支的 WriteRelayError 已写入 rc.Writer)
// 并提交最终尝试记录;无调试会话(开关未开启)时 no-op
rc.Debug.FinalizeAndSubmit(int64(latencyMs), int64(ttft))
}
// isBinaryContentType 判断请求是否为 multipart 或其它二进制类型,
@@ -641,7 +645,7 @@ func HandleAudioSpeech(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteRelayError(capture, err)
handler.WriteRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/audio/speech", latencyMs, firstTokenMs(billingResult))
return
}
@@ -672,7 +676,7 @@ func HandleAudioTranscription(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteRelayError(capture, err)
handler.WriteRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/audio/transcriptions", latencyMs, firstTokenMs(billingResult))
return
}
@@ -703,7 +707,7 @@ func HandleAudioTranslation(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteRelayError(capture, err)
handler.WriteRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/audio/translations", latencyMs, firstTokenMs(billingResult))
return
}
@@ -734,7 +738,7 @@ func HandleRerank(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteRelayError(capture, err)
handler.WriteRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/rerank", latencyMs, firstTokenMs(billingResult))
return
}
@@ -819,7 +823,7 @@ func HandleModerations(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteRelayError(capture, err)
handler.WriteRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/moderations", latencyMs, firstTokenMs(billingResult))
return
}
@@ -850,7 +854,7 @@ func HandleImagesEdits(r *ghttp.Request) {
setDeprecationHeaders(r, billingResult)
if err != nil {
handler.WriteRelayError(capture, err)
handler.WriteRelayError(rc.Writer, err)
recordAudit(r, rc, capture, body, "/images/edits", latencyMs, firstTokenMs(billingResult))
return
}
+30 -4
View File
@@ -405,12 +405,34 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (*
data.BorrowingCooldownSeconds = *req.BorrowingCooldownSeconds
}
// Update use_proxy in settings JSONB
if req.UseProxy != nil {
// Update use_proxy / debug_log_* in settings JSONB。
// 必须单块合并处理(读一次旧值 → 同时应用全部字段 → 回写一次):
// 若写成多个同构块,各自从 DB 读旧值再各自 marshal,同请求多字段时会互相覆盖。
if req.UseProxy != nil || req.DebugLogEnabled != nil || req.DebugLogTenantID != nil ||
req.DebugLogUserID != nil || req.DebugLogApiKeyID != nil {
// 注意:读单列字符串必须用 Value()——gdb 的 Model.Scan 只接受 struct 系列,
// 传入 *string 会直接报错;此前用 _ = Scan(&string) 吞错导致永远读到空串,
// 每次局部更新都会把未提交的 settings 字段静默重置为默认值
var currentSettings string
_ = dao.ChnChannels.Ctx(ctx).Where("id", req.ID).Fields("settings").Scan(&currentSettings)
if v, err := dao.ChnChannels.Ctx(ctx).Where("id", req.ID).Fields("settings").Value(); err == nil {
currentSettings = v.String()
}
settings := relay.ParseChannelSettings(currentSettings)
settings.UseProxy = *req.UseProxy
if req.UseProxy != nil {
settings.UseProxy = *req.UseProxy
}
if req.DebugLogEnabled != nil {
settings.DebugLogEnabled = *req.DebugLogEnabled
}
if req.DebugLogTenantID != nil {
settings.DebugLogTenantID = *req.DebugLogTenantID
}
if req.DebugLogUserID != nil {
settings.DebugLogUserID = *req.DebugLogUserID
}
if req.DebugLogApiKeyID != nil {
settings.DebugLogApiKeyID = *req.DebugLogApiKeyID
}
if settingsJSON, err := json.Marshal(settings); err == nil {
data.Settings = string(settingsJSON)
}
@@ -554,6 +576,10 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq)
Remark: ch.Remark,
IsVIP: ch.IsVIP,
UseProxy: settings.UseProxy,
DebugLogEnabled: settings.DebugLogEnabled,
DebugLogTenantID: settings.DebugLogTenantID,
DebugLogUserID: settings.DebugLogUserID,
DebugLogApiKeyID: settings.DebugLogApiKeyID,
SharingThreshold: ch.SharingThreshold,
PreemptionThreshold: ch.PreemptionThreshold,
BorrowingCooldownSeconds: ch.BorrowingCooldownSeconds,
+181
View File
@@ -0,0 +1,181 @@
package admin
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/common"
)
// debugLogListFields 列表查询字段:元数据 + 四段体积(octet_length),绝不 SELECT body 列
const debugLogListFields = "id, channel_id, channel_name, channel_type, request_id, tenant_id, user_id, api_key_id, " +
"model_name, upstream_model, relay_mode, inbound_path, upstream_url, is_stream, retry_index, is_final, " +
"upstream_status_code, client_status_code, error, upstream_latency_ms, total_latency_ms, first_token_ms, conversion, " +
"octet_length(client_req_body) AS client_req_body_size, " +
"octet_length(upstream_req_body) AS upstream_req_body_size, " +
"octet_length(upstream_resp_body) AS upstream_resp_body_size, " +
"octet_length(client_resp_body) AS client_resp_body_size, " +
"created_at"
// ChannelDebugLogList 渠道调试日志列表(原生 SQL:octet_length 表达式经 gdb Fields 有引号化风险)
func (s *sAdmin) ChannelDebugLogList(ctx context.Context, req *v1.ChannelDebugLogListReq) (*v1.ChannelDebugLogListRes, error) {
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
return nil, err
}
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
return nil, err
}
conditions := []string{"channel_id = ?"}
args := []any{req.ChannelID}
if req.RequestID != "" {
conditions = append(conditions, "request_id = ?")
args = append(args, req.RequestID)
}
if req.ModelName != "" {
conditions = append(conditions, "model_name ILIKE ?")
args = append(args, "%"+req.ModelName+"%")
}
if req.UpstreamStatus != nil {
conditions = append(conditions, "upstream_status_code = ?")
args = append(args, *req.UpstreamStatus)
}
if req.ClientStatus != nil {
conditions = append(conditions, "client_status_code = ?")
args = append(args, *req.ClientStatus)
}
if req.IsStream != nil {
conditions = append(conditions, "is_stream = ?")
args = append(args, *req.IsStream)
}
if req.OnlyError != nil && *req.OnlyError {
conditions = append(conditions, "COALESCE(error, '') <> ''")
}
if req.StartDate != "" {
conditions = append(conditions, "created_at >= ?")
args = append(args, req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
conditions = append(conditions, "created_at <= ?")
args = append(args, req.EndDate+" 23:59:59")
}
where := strings.Join(conditions, " AND ")
countRows, err := g.DB().Ctx(ctx).Query(ctx,
"SELECT COUNT(*) AS total FROM chn_debug_logs WHERE "+where, args...)
if err != nil {
return nil, err
}
total := 0
if len(countRows) > 0 {
total = countRows[0]["total"].Int()
}
if total == 0 {
return &v1.ChannelDebugLogListRes{List: []map[string]any{}, Total: 0, Page: req.Page, PageSize: req.PageSize}, nil
}
rows, err := g.DB().Ctx(ctx).Query(ctx,
"SELECT "+debugLogListFields+" FROM chn_debug_logs WHERE "+where+
" ORDER BY id DESC LIMIT ? OFFSET ?",
append(args, req.PageSize, (req.Page-1)*req.PageSize)...)
if err != nil {
return nil, err
}
return &v1.ChannelDebugLogListRes{
List: toMapList(rows),
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}, nil
}
// ChannelDebugLogStats 渠道调试日志统计(条数/落库体积/最早时间,供清理提示)
func (s *sAdmin) ChannelDebugLogStats(ctx context.Context, req *v1.ChannelDebugLogStatsReq) (*v1.ChannelDebugLogStatsRes, error) {
rows, err := g.DB().Ctx(ctx).Query(ctx,
"SELECT COUNT(*) AS total, "+
"COALESCE(SUM(octet_length(client_req_body) + octet_length(upstream_req_body) + "+
"octet_length(upstream_resp_body) + octet_length(client_resp_body)), 0) AS total_bytes, "+
"MIN(created_at) AS oldest_at "+
"FROM chn_debug_logs WHERE channel_id = ?", req.ChannelID)
if err != nil {
return nil, err
}
res := &v1.ChannelDebugLogStatsRes{}
if len(rows) > 0 {
res.Total = rows[0]["total"].Int64()
res.TotalBytes = rows[0]["total_bytes"].Int64()
if v := rows[0]["oldest_at"]; v != nil {
if t, ok := v.Interface().(time.Time); ok && !t.IsZero() {
res.OldestAt = gtime.New(t).String()
}
}
}
return res, nil
}
// ChannelDebugLogDetail 渠道调试日志详情(四段完整报文;headers JSONB 解析为对象便于前端渲染)
func (s *sAdmin) ChannelDebugLogDetail(ctx context.Context, req *v1.ChannelDebugLogDetailReq) (*v1.ChannelDebugLogDetailRes, error) {
record, err := dao.ChnDebugLogs.Ctx(ctx).
Where("channel_id", req.ChannelID).
Where("id", req.ID).
One()
if err != nil {
return nil, err
}
if record.IsEmpty() {
return nil, common.NewNotFoundError("调试日志")
}
data := record.Map()
// headers JSONB 列可能以字符串返回,解析为对象;无效/空值保持原样
for _, field := range []string{
"client_req_headers", "upstream_req_headers", "upstream_resp_headers", "client_resp_headers",
} {
data[field] = parseJSONField(data[field])
}
return &v1.ChannelDebugLogDetailRes{Data: data}, nil
}
// ChannelDebugLogDelete 删除单条调试日志(硬删除)
func (s *sAdmin) ChannelDebugLogDelete(ctx context.Context, req *v1.ChannelDebugLogDeleteReq) (*v1.ChannelDebugLogDeleteRes, error) {
_, err := dao.ChnDebugLogs.Ctx(ctx).
Where("channel_id", req.ChannelID).
Where("id", req.ID).
Delete()
if err != nil {
return nil, err
}
return nil, nil
}
// ChannelDebugLogClear 清空渠道调试日志(按渠道硬删除全部)
func (s *sAdmin) ChannelDebugLogClear(ctx context.Context, req *v1.ChannelDebugLogClearReq) (*v1.ChannelDebugLogClearRes, error) {
_, err := dao.ChnDebugLogs.Ctx(ctx).
Where("channel_id", req.ChannelID).
Delete()
if err != nil {
return nil, err
}
return nil, nil
}
// parseJSONField 尝试将字符串形式的 JSON 解析为对象;解析失败或非字符串时原样返回
func parseJSONField(v any) any {
s, ok := v.(string)
if !ok || s == "" || s == "null" {
return v
}
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
return v
}
return parsed
}
+9 -6
View File
@@ -15,7 +15,9 @@ import (
"github.com/qianfree/team-api/internal/logic/relay"
)
// TestChannel 测试渠道可用性(发送最小请求验证)
// TestChannel 测试渠道可用性(发送最小请求验证)。
// 不限制渠道状态:禁用中的渠道也允许手动测试,便于管理员在启用放流量前确认已恢复;
// 自动探测 cron 只查 active 渠道,禁用渠道不会被自动探测。
func (s *sAdmin) TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.ChannelTestRes, error) {
channelID := req.ID
@@ -41,9 +43,6 @@ func (s *sAdmin) TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.C
if ch == nil {
return nil, common.NewNotFoundError("渠道")
}
if ch.Status == "disabled" {
return nil, common.NewBadRequestError("渠道已禁用")
}
testModel := req.ModelName
if testModel == "" {
@@ -113,8 +112,12 @@ func (s *sAdmin) TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.C
result := sendTestRequest(ctx, ch.Type, ch.BaseURL, apiKey, upstreamModel, useProxy)
latencyMs := time.Since(startTime).Milliseconds()
// 更新健康度(喂给调度引擎的健康体系;探测失败按瞬时错误轻罚)
dispatchadapter.ReportProbeOutcome(ctx, channelID, upstreamModel, result.Success, float64(latencyMs))
// 更新健康度(喂给调度引擎的健康体系;探测失败按瞬时错误轻罚)。
// 仅 active 渠道上报:禁用/测试中渠道不在调度目录,喂健康分只会无意义拉低展示值;
// 渠道启用时 MarkChannelRecovered 会复位熔断,无需在禁用期间预热。
if ch.Status == "active" {
dispatchadapter.ReportProbeOutcome(ctx, channelID, upstreamModel, result.Success, float64(latencyMs))
}
// 记录测试结果日志
if result.Success {
+159 -3
View File
@@ -451,6 +451,10 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v
PerRequestPrice *float64 `json:"per_request_price"`
CacheReadPrice float64 `json:"cache_read_price"`
CacheCreationPrice float64 `json:"cache_creation_price"`
TimeSegments string `json:"time_segments"`
PriceNote *string `json:"price_note"`
DiscountLabel *string `json:"discount_label"`
PriceChangeNote *string `json:"price_change_note"`
}
err := dao.MdlPricing.Ctx(ctx).
@@ -462,7 +466,10 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v
}
result := make([]v1.PricingItem, 0, len(rows))
for _, r := range rows {
var timeSegments []billing.TimeSegment
var priceNote, discountLabel, priceChangeNote string
for i := range rows {
r := rows[i]
result = append(result, v1.PricingItem{
BillingMode: r.BillingMode,
MinTokens: r.MinTokens,
@@ -473,12 +480,151 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v
CacheReadPrice: r.CacheReadPrice,
CacheCreationPrice: r.CacheCreationPrice,
})
// 时段定价与展示字段只认锚点行(min_tokens=0),阶梯行上的残留忽略
if r.MinTokens == 0 {
if r.TimeSegments != "" && r.TimeSegments != "null" {
if err := json.Unmarshal([]byte(r.TimeSegments), &timeSegments); err != nil {
g.Log().Warningf(ctx, "admin: 模型 %d 时段定价解析失败: %v", req.ModelID, err)
timeSegments = nil
}
}
priceNote = derefStr(r.PriceNote)
discountLabel = derefStr(r.DiscountLabel)
priceChangeNote = derefStr(r.PriceChangeNote)
}
}
return &v1.PricingGetRes{List: result}, nil
return &v1.PricingGetRes{
List: result,
TimeSegments: timeSegmentsToAPI(timeSegments),
PriceNote: priceNote,
DiscountLabel: discountLabel,
PriceChangeNote: priceChangeNote,
}, nil
}
// derefStr 字符串指针安全解引用(NULL → 空串)
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// timeSegmentsToAPI billing.TimeSegment → API 时段项
func timeSegmentsToAPI(segments []billing.TimeSegment) []v1.TimeSegmentItem {
if len(segments) == 0 {
return nil
}
result := make([]v1.TimeSegmentItem, 0, len(segments))
for _, seg := range segments {
result = append(result, v1.TimeSegmentItem{
Name: seg.Name,
Days: seg.Days,
StartTime: seg.StartTime,
EndTime: seg.EndTime,
ValidFrom: seg.ValidFrom,
ValidTo: seg.ValidTo,
Multiplier: seg.Multiplier,
})
}
return result
}
// timeSegmentsFromAPI API 时段项 → billing.TimeSegment(含校验)
func timeSegmentsFromAPI(items []v1.TimeSegmentItem) ([]billing.TimeSegment, error) {
if len(items) == 0 {
return nil, nil
}
result := make([]billing.TimeSegment, 0, len(items))
for _, item := range items {
result = append(result, billing.TimeSegment{
Name: item.Name,
Days: item.Days,
StartTime: item.StartTime,
EndTime: item.EndTime,
ValidFrom: item.ValidFrom,
ValidTo: item.ValidTo,
Multiplier: item.Multiplier,
})
}
if err := billing.ValidateTimeSegments(result); err != nil {
return nil, err
}
return result, nil
}
// writeTimeSegmentsForModel 把时段定价写到模型定价锚点行(min_tokens=0),空列表=清除(全量替换语义)。
// 定价保存与模型导入共用。清除时写 "[]" 而非 SQL NULL:避免依赖 ORM 对 nil 值 UPDATE 的默认行为
// (被省略会让「关闭时段」静默失效),所有读取方(计费/租户/管理端)均已把空数组视为未配置。
// 用列名直写而非 do 字段:do.MdlPricing 的 TimeSegments 字段需迁移落库后 gf gen dao 重新生成才存在,
// 直写列名与生成物解耦(wire 格式与 custom_pricing_tiers JSONB 先例一致)。调用方需保证事务 ctx 传播。
func writeTimeSegmentsForModel(ctx context.Context, modelDBID int64, items []v1.TimeSegmentItem) error {
segs, err := timeSegmentsFromAPI(items)
if err != nil {
return err
}
// 空配置显式写 "[]"(nil 切片会序列化成 "null",语义不明确)
segmentsJSON := "[]"
if len(segs) > 0 {
data, mErr := json.Marshal(segs)
if mErr != nil {
return mErr
}
segmentsJSON = string(data)
}
_, err = dao.MdlPricing.Ctx(ctx).
Where("model_id", modelDBID).
Where("min_tokens", 0).
Data(g.Map{"time_segments": segmentsJSON}).
Update()
return err
}
// writePricingDisplayFieldsForModel 把展示字段(价格说明/折扣标签/价格调整说明)写到定价锚点行(min_tokens=0)。
// 全量替换语义:空串=清除(写 NULL)。与 writeTimeSegmentsForModel 同理用列名直写而非 do 字段,
// 与 gf gen dao 生成物解耦。调用方需保证事务 ctx 传播。
func writePricingDisplayFieldsForModel(ctx context.Context, modelDBID int64, priceNote, discountLabel, priceChangeNote string) error {
trimOrNull := func(s string) any {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
return s
}
_, err := dao.MdlPricing.Ctx(ctx).
Where("model_id", modelDBID).
Where("min_tokens", 0).
Data(g.Map{
"price_note": trimOrNull(priceNote),
"discount_label": trimOrNull(discountLabel),
"price_change_note": trimOrNull(priceChangeNote),
}).
Update()
return err
}
// SetModelPricing 设置模型定价(全量替换)
func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v1.PricingSetRes, error) {
// 时段定价校验(乘数范围/时间格式/星期/日期边界),fail-fast 返回用户可读错误
segments, err := timeSegmentsFromAPI(req.TimeSegments)
if err != nil {
return nil, err
}
// 时段配置挂在锚点行(min_tokens=0)上,列表中必须存在锚点行
if len(segments) > 0 {
hasAnchor := false
for _, item := range req.Items {
if item.MinTokens == 0 {
hasAnchor = true
break
}
}
if !hasAnchor {
return nil, gerror.New("时段定价要求定价列表包含 min_tokens=0 的基础行")
}
}
// 先查模型编码,供事务提交后按模型清除所有租户的价格缓存
var model struct {
ModelId string `json:"model_id"`
@@ -491,7 +637,7 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v
// 全量替换:先删旧定价再插新定价,放入同一事务。否则若删除后插入中途失败,
// 该模型会残留「无定价」状态,导致计费失败或回退到默认价。
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ModelID).Delete(); err != nil {
return err
}
@@ -519,6 +665,16 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v
}
}
// 时段定价写锚点行(空=清除,全量替换语义;需在锚点行插入之后执行)
if err := writeTimeSegmentsForModel(ctx, req.ModelID, req.TimeSegments); err != nil {
return err
}
// 展示字段写锚点行(空=清除,全量替换语义;需在锚点行插入之后执行)
if err := writePricingDisplayFieldsForModel(ctx, req.ModelID, req.PriceNote, req.DiscountLabel, req.PriceChangeNote); err != nil {
return err
}
return nil
})
if err != nil {
+33 -13
View File
@@ -61,18 +61,19 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
}
type exportModel struct {
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Status string `json:"status"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
Description string `json:"description"`
Tags []string `json:"tags"`
Capabilities map[string]bool `json:"capabilities"`
SunsetDate string `json:"sunset_date,omitempty"`
ReplacementModel string `json:"replacement_model,omitempty"`
Pricing []exportPricing `json:"pricing"`
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Status string `json:"status"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
Description string `json:"description"`
Tags []string `json:"tags"`
Capabilities map[string]bool `json:"capabilities"`
SunsetDate string `json:"sunset_date,omitempty"`
ReplacementModel string `json:"replacement_model,omitempty"`
Pricing []exportPricing `json:"pricing"`
TimeSegments []v1.TimeSegmentItem `json:"time_segments,omitempty"`
}
// 批量查询所有模型的定价行(避免循环内逐模型查询导致 N+1),与 ListModels 的批量模式对齐。
@@ -90,6 +91,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
PerRequestPrice *float64 `orm:"per_request_price" json:"per_request_price"`
CacheReadPrice float64 `orm:"cache_read_price" json:"cache_read_price"`
CacheCreationPrice float64 `orm:"cache_creation_price" json:"cache_creation_price"`
TimeSegments string `orm:"time_segments" json:"time_segments"`
}
var allPricingRows []pricingRow
if len(modelIDs) > 0 {
@@ -97,8 +99,9 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
return nil, err
}
}
// 按 model_id 分组,供后续组装
// 按 model_id 分组,供后续组装;时段定价只认锚点行(min_tokens=0)
pricingByModel := make(map[int64][]exportPricing, len(allPricingRows))
segmentsByModel := make(map[int64][]v1.TimeSegmentItem, len(allPricingRows))
for _, p := range allPricingRows {
pricingByModel[p.ModelId] = append(pricingByModel[p.ModelId], exportPricing{
BillingMode: p.BillingMode,
@@ -110,6 +113,12 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
CacheReadPrice: p.CacheReadPrice,
CacheCreationPrice: p.CacheCreationPrice,
})
if p.MinTokens == 0 && p.TimeSegments != "" && p.TimeSegments != "null" {
var segs []billing.TimeSegment
if err := json.Unmarshal([]byte(p.TimeSegments), &segs); err == nil {
segmentsByModel[p.ModelId] = timeSegmentsToAPI(segs)
}
}
}
result := make([]exportModel, 0, len(models))
@@ -125,6 +134,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
Capabilities: parseCapabilities(m.Capabilities),
ReplacementModel: m.ReplacementModel,
Pricing: pricingByModel[m.ID],
TimeSegments: segmentsByModel[m.ID],
}
if em.Pricing == nil {
em.Pricing = []exportPricing{}
@@ -298,6 +308,11 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
}
}
// 时段定价透传(挂锚点行,全量替换语义:导入文件无该字段=清除)
if err := writeTimeSegmentsForModel(ctx, existing.ID, item.TimeSegments); err != nil {
return gerror.Wrapf(err, "模型 %s 时段定价", item.ModelId)
}
relay.NewDataProvider().InvalidateModelCache(item.ModelId)
billing.ClearModelPriceCache(ctx, item.ModelId)
res.Imported++
@@ -351,6 +366,11 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
}
}
// 时段定价透传(挂锚点行)
if err := writeTimeSegmentsForModel(ctx, id, item.TimeSegments); err != nil {
return gerror.Wrapf(err, "模型 %s 时段定价", item.ModelId)
}
res.Imported++
}
}
+41
View File
@@ -654,3 +654,44 @@ func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v
}
})
}
// TenantApiKeySelect 租户 API Key 下拉选择列表(轻量,不返回密钥原值)。
// 供渠道调试目标过滤等管理端选择器使用;user_id > 0 时按创建者过滤。
// 注意:Fields 不能挂在 Count 之前的模型上——gdb 会生成 COUNT(col1, col2, ...),
// PostgreSQL 的 count 只接受单参数,会直接报错。
func (s *sAdmin) TenantApiKeySelect(ctx context.Context, req *v1.TenantApiKeySelectReq) (*v1.TenantApiKeySelectRes, error) {
m := dao.ApiKeys.Ctx(ctx).
Where("tenant_id", req.TenantID).
OrderDesc("id")
if req.UserID > 0 {
m = m.Where("user_id", req.UserID)
}
total, err := m.Count()
if err != nil {
return nil, err
}
var keys []struct {
ID int64 `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
UserID int64 `json:"user_id"`
Status string `json:"status"`
}
if err := m.Fields("id, name, key_prefix, user_id, status").Page(req.Page, req.PageSize).Scan(&keys); err != nil {
return nil, err
}
list := make([]v1.TenantApiKeySelectItem, len(keys))
for i, k := range keys {
list[i] = v1.TenantApiKeySelectItem{
ID: k.ID,
Name: k.Name,
KeyPrefix: k.KeyPrefix,
UserID: k.UserID,
Status: k.Status,
}
}
return &v1.TenantApiKeySelectRes{List: list, Total: total, Page: req.Page, PageSize: req.PageSize}, nil
}
@@ -0,0 +1,112 @@
//go:build e2e
// 手动 E2E(需真实 DB/Redis;默认 go test 不编译本文件)。
// 跑法:go test -tags e2e -run TestE2ETimeSegmentPricing ./internal/logic/billing/
// 使用独立的临时模型行 + 不存在的租户 ID,走纯基础定价路径,结束即清理,不影响现有数据。
package billing
import (
"fmt"
"testing"
"time"
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
"github.com/gogf/gf/v2/os/gcfg"
"github.com/gogf/gf/v2/os/gctx"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
)
func TestE2ETimeSegmentPricing(t *testing.T) {
// 测试 CWD 是包目录,把配置搜索路径指回仓库根的 manifest/config
adapterFile, err := gcfg.NewAdapterFile()
if err != nil {
t.Fatalf("new config adapter: %v", err)
}
if err := adapterFile.AddPath("../../../manifest/config"); err != nil {
t.Fatalf("add config path: %v", err)
}
gcfg.Instance().SetAdapter(adapterFile)
ctx := gctx.New()
const tmpModel = "tmp-e2e-time-segment"
const tmpTenant = int64(999999999) // 不存在的租户:走纯基础定价路径
// 清理(含上次失败运行的残留)
cleanup := func() {
var id int64
v, _ := dao.MdlModels.Ctx(ctx).Where("model_id", tmpModel).Fields("id").Value()
fmt.Sscanf(v.String(), "%d", &id)
if id > 0 {
_, _ = dao.MdlPricing.Ctx(ctx).Where("model_id", id).Delete()
_, _ = dao.MdlModels.Ctx(ctx).Where("id", id).Delete()
}
ClearModelPriceCache(ctx, tmpModel)
}
cleanup()
defer cleanup()
// 临时模型 + 定价(时段窗口覆盖 now±1h,乘数 0.5)
modelID, err := dao.MdlModels.Ctx(ctx).InsertAndGetId(do.MdlModels{
ModelId: tmpModel,
ModelName: "E2E时段定价临时模型",
Category: "chat",
Status: "active",
})
if err != nil {
t.Fatalf("insert tmp model: %v", err)
}
now := time.Now()
start := now.Add(-time.Hour).Format("15:04")
end := now.Add(time.Hour).Format("15:04")
segJSON := fmt.Sprintf(`[{"name":"闲时","start_time":%q,"end_time":%q,"multiplier":0.5}]`, start, end)
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: modelID,
BillingMode: "token",
MinTokens: 0,
InputPrice: NewFromFloat(3),
OutputPrice: NewFromFloat(15),
TimeSegments: segJSON,
}); err != nil {
t.Fatalf("insert tmp pricing: %v", err)
}
// 1. 首次解析(DB 路径):命中时段
p1, err := GetModelPrice(ctx, tmpTenant, tmpModel)
if err != nil {
t.Fatalf("GetModelPrice: %v", err)
}
assertFloat(t, p1.TimeMultiplier, 0.5, "e2e TimeMultiplier (db path)")
if p1.TimeRuleName != "闲时" {
t.Fatalf("TimeRuleName = %q, want 闲时", p1.TimeRuleName)
}
assertFloat(t, p1.InputPrice, 3.0, "e2e InputPrice")
// 2. 费用计算:1M 输入 + 1M 输出 = 18 × 0.5 = 9
bd, err := CalculateCost(ctx, tmpTenant, tmpModel, 1_000_000, 1_000_000)
if err != nil {
t.Fatalf("CalculateCost: %v", err)
}
assertFloat(t, bd.TotalCost, 9.0, "e2e TotalCost (18 × 0.5)")
// 3. 缓存命中路径 + 窗口外 billAt:重评估为 1.0(受理时刻语义)
p2, err := GetModelPriceAt(ctx, tmpTenant, tmpModel, now.Add(-3*time.Hour))
if err != nil {
t.Fatalf("GetModelPriceAt(out of window): %v", err)
}
assertFloat(t, p2.TimeMultiplier, 1.0, "e2e TimeMultiplier (cache hit, out-of-window billAt)")
if p2.TimeRuleName != "" {
t.Fatalf("out-of-window TimeRuleName = %q, want empty", p2.TimeRuleName)
}
// 4. 缓存命中路径 + 窗口内 billAt:重评估回 0.5
p3, err := GetModelPriceAt(ctx, tmpTenant, tmpModel, now.Add(10*time.Minute))
if err != nil {
t.Fatalf("GetModelPriceAt(in window): %v", err)
}
assertFloat(t, p3.TimeMultiplier, 0.5, "e2e TimeMultiplier (cache hit, in-window billAt)")
t.Logf("E2E 通过:DB 解析/费用计算/缓存重评估均符合预期(窗口 %s~%s)", start, end)
}
+121 -48
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/shopspring/decimal"
"github.com/qianfree/team-api/internal/dao"
@@ -56,7 +57,15 @@ type PricingResult struct {
MaxOutputTokens int
// 租户自定义阶梯定价(JSONB 解析后的原始数据,供 CalculateCost 使用)
CustomTiers []pricingTierRow
// 租户未自定义时回落为平台阶梯(mdl_pricing 的 min_tokens 档位行)
CustomTiers []pricingTierRow `json:"CustomTiers"`
// 时段定价配置(mdl_pricing 锚点行 time_segments,随定价缓存存储)
TimeSegments []TimeSegment `json:"time_segments,omitempty"`
// 时段乘数(读取时按定价时刻评估;缓存条目中的值是写入时刻的结果,仅作参考)
TimeMultiplier float64 `json:"time_multiplier"`
// 命中的时段名(写入计费快照,供账单解释;未命中为空)
TimeRuleName string `json:"time_rule_name"`
}
// ClearTenantPriceCache 清除租户的所有模型价格缓存
@@ -85,13 +94,27 @@ func ClearModelPriceCache(ctx context.Context, modelName string) {
modelPriceCache.DeleteByPattern(ctx, fmt.Sprintf("*:%s", modelName))
}
// GetModelPrice 获取模型价格
// 优先级:租户独立价 > 套餐价 > 模型基础价 > 硬编码默认
// GetModelPrice 获取模型价格(时段乘数按当前时刻评估)。
// 预扣等「调用时刻≈受理时刻」的路径使用本函数。
func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*PricingResult, error) {
return GetModelPriceAt(ctx, tenantID, modelName, time.Time{})
}
// GetModelPriceAt 获取模型价格,时段乘数按 billAt(定价时刻)评估。
// 优先级:租户独立价 > 套餐价 > 模型基础价 > 硬编码默认。
// 定价时刻语义:结算/异步任务路径必须传「请求(任务)受理时刻」,保证 07:59 发出的请求
// 即使 08:05 结算也按 07:59 的时段价计费,预扣与结算口径一致;billAt 零值按当前时刻。
func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, billAt time.Time) (*PricingResult, error) {
if billAt.IsZero() {
billAt = time.Now()
}
cacheKey := fmt.Sprintf("%d:%s", tenantID, modelName)
var cached PricingResult
if modelPriceCache.GetJSON(ctx, cacheKey, &cached) {
return &cached, nil
// 缓存条目中的时段乘数是写入时刻的评估结果,必须按 billAt 重评估
// (GetJSON 的 target 为调用方独有副本,L1 回填存的也是反序列化副本,
// 就地改写不会波及缓存内对象、无数据竞争)
return reapplyTimeMultiplier(ctx, &cached, billAt), nil
}
// 1. 查模型基础信息(max_output_tokens 一并取出,随定价缓存供预扣估算复用)
@@ -115,25 +138,45 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
return nil, gerror.Newf("model not found: %s", modelName)
}
// 2. 从 mdl_pricing 获取基础定价
// 2. 从 mdl_pricing 获取定价(全行读取:锚点行 min_tokens=0 是默认价与时段配置载体,
// 其余行是平台阶梯档位。此前只读锚点行导致平台阶梯不参与计费,退化为第一档平价)
type pricingRow struct {
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
BillingMode string `json:"billing_mode"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
CacheReadPrice float64 `json:"cache_read_price"`
CacheCreationPrice float64 `json:"cache_creation_price"`
TimeSegments string `json:"time_segments"`
}
var pricing *pricingRow
var pricingRows []pricingRow
err = dao.MdlPricing.Ctx(ctx).
Where("model_id", model.ID).
Where("min_tokens", 0).
Scan(&pricing)
OrderAsc("min_tokens").
Scan(&pricingRows)
if err != nil {
return nil, gerror.Wrapf(err, "query model pricing")
}
// 锚点行(默认价)与平台阶梯(含锚点行作为第一档,与租户端 buildTiers 口径一致)
var pricing *pricingRow
var platformTiers []pricingTierRow
for i := range pricingRows {
row := &pricingRows[i]
if row.MinTokens == 0 {
pricing = row
}
platformTiers = append(platformTiers, pricingTierRow{
MinTokens: row.MinTokens,
MaxTokens: row.MaxTokens,
InputPrice: row.InputPrice,
OutputPrice: row.OutputPrice,
})
}
billingMode := "token"
inputPrice := 0.0
outputPrice := 0.0
@@ -229,6 +272,21 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
}
}
// 2.5 平台阶梯兜底:租户未自定义阶梯时使用平台阶梯(mdl_pricing 档位行)。
// 计费模式非 tiered 时 computeCost 不会读取 CustomTiers,无副作用
if len(customTiers) == 0 {
customTiers = platformTiers
}
// 2.6 时段定价(锚点行 JSONB;解析失败按默认价处理并告警,不阻断计费)
var timeSegments []TimeSegment
if pricing != nil && pricing.TimeSegments != "" && pricing.TimeSegments != "null" {
if err := json.Unmarshal([]byte(pricing.TimeSegments), &timeSegments); err != nil {
g.Log().Warningf(ctx, "billing: 模型 %s 时段定价配置解析失败,按默认价处理: %v", modelName, err)
timeSegments = nil
}
}
// 3.5 级别折扣 fallback:当租户×模型维度未设置倍率时,使用租户级别的 price_multiplier
if tenantMultiplier == 1.0 {
levelMultiplier := GetLevelPriceMultiplier(ctx, tenantID)
@@ -271,12 +329,36 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
CacheCreation1hPrice: cacheCreationPrice * 1.6, // 1h TTL = 1.6× 基础价(因为 2.0÷1.25=1.6,对应官方 2× 输入价)
MaxOutputTokens: model.MaxOutputTokens,
CustomTiers: customTiers,
TimeSegments: timeSegments,
}
// 时段乘数按定价时刻评估后随缓存存储(缓存命中路径会按 billAt 重评估,存储值仅参考)
if len(timeSegments) > 0 {
loc := pricingTimeLocation(ctx)
result.TimeMultiplier, result.TimeRuleName = resolveTimeMultiplier(timeSegments, billAt, loc)
} else {
result.TimeMultiplier = 1.0
}
modelPriceCache.Set(ctx, cacheKey, result)
return result, nil
}
// reapplyTimeMultiplier 缓存命中路径:按 billAt 重评估时段乘数后就地改写并返回。
// 入参必须是本调用新建/反序列化的独有副本,不得传入共享指针:
// GetJSON 保证 target 归调用方所有(L1 回填存反序列化副本),
// DB 加载路径的 result 在 Set 之后不再改写。
func reapplyTimeMultiplier(ctx context.Context, p *PricingResult, billAt time.Time) *PricingResult {
if len(p.TimeSegments) == 0 {
p.TimeMultiplier = 1.0
p.TimeRuleName = ""
return p
}
loc := pricingTimeLocation(ctx)
p.TimeMultiplier, p.TimeRuleName = resolveTimeMultiplier(p.TimeSegments, billAt, loc)
return p
}
// CalculateCost 计算实际费用(含阶梯定价)
// inputTokens / outputTokens 为实际使用的 token 数
func CalculateCost(ctx context.Context, tenantID int64, modelName string, inputTokens, outputTokens int) (*CostBreakdown, error) {
@@ -293,11 +375,13 @@ func CalculateCost(ctx context.Context, tenantID int64, modelName string, inputT
func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *rcommon.Usage) *CostBreakdown {
baseInputTokens, outputTokens, cacheReadTokens, cacheCreation5mTokens, cacheCreation1hTokens := resolveTokenCounts(pricing, inputTokens, outputTokens, usage)
// 按次计费:直接返回单价
// 按次计费:单价 × 租户乘数 × 时段乘数。
// 此前未乘租户乘数(按次模型对租户折扣免疫,疑似遗漏),本次与 token/tiered 口径对齐
if pricing.BillingMode == "per_request" {
mulD := NewFromFloat(pricing.TenantMultiplier).Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
return &CostBreakdown{
BaseCost: pricing.PerRequestPrice,
TotalCost: pricing.PerRequestPrice,
TotalCost: InexactFloat64(RoundMoney(NewFromFloat(pricing.PerRequestPrice).Mul(mulD))),
InputTokens: baseInputTokens,
OutputTokens: outputTokens,
BillingMode: pricing.BillingMode,
@@ -316,10 +400,10 @@ func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *r
// 输出费用(已改为 decimal)
outputCostD := computeOutputCost(pricing, outputTokens)
// A8:token 成本链式计算(÷1e6 × 单价 × 租户倍率 + 各项求和)改用 decimal 精确运算,
// A8:token 成本链式计算(÷1e6 × 单价 × 租户倍率 × 时段乘数 + 各项求和)改用 decimal 精确运算,
// 最终四舍五入到 10 位(NUMERIC(20,10))再返回 float64,消除 float64 累计误差。
million := decimal.NewFromInt(1_000_000)
mul := NewFromFloat(pricing.TenantMultiplier)
mul := NewFromFloat(pricing.TenantMultiplier).Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
cacheReadCostD := decimal.NewFromInt(int64(cacheReadTokens)).Div(million).Mul(NewFromFloat(pricing.CacheReadPrice))
@@ -457,9 +541,10 @@ func EstimatePreDeductAmount(ctx context.Context, tenantID int64, modelName stri
return 0, err
}
// 按次计费:直接用单价
// 按次计费:单价 × 租户乘数 × 时段乘数(与 computeCost 结算口径一致)
if pricing.BillingMode == "per_request" {
return pricing.PerRequestPrice, nil
mult := NewFromFloat(pricing.TenantMultiplier).Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
return InexactFloat64(NewFromFloat(pricing.PerRequestPrice).Mul(mult)), nil
}
// Token 计费:估算输出上限。
@@ -496,7 +581,7 @@ func EstimatePreDeductAmount(ctx context.Context, tenantID int64, modelName stri
// validatePricingConfigured 校验模型定价有效性(fail-closed)。
// 未配置定价的模型不得放行:零价会让预扣/结算全部为 0,等同免费使用。
// 允许只配 OutputPrice(输入免费)或只配租户自定义阶梯(CustomTiers)的合法场景。
// 允许只配 OutputPrice(输入免费)或只配含正价的自定义/平台阶梯(CustomTiers)的合法场景。
func validatePricingConfigured(pricing *PricingResult, modelName string) error {
switch pricing.BillingMode {
case "per_request":
@@ -504,13 +589,24 @@ func validatePricingConfigured(pricing *PricingResult, modelName string) error {
return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s (per_request price not set)", modelName)
}
default: // token / tiered
if pricing.InputPrice <= 0 && pricing.OutputPrice <= 0 && len(pricing.CustomTiers) == 0 {
if pricing.InputPrice <= 0 && pricing.OutputPrice <= 0 && !tiersHavePrice(pricing.CustomTiers) {
return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s", modelName)
}
}
return nil
}
// tiersHavePrice 阶梯数组中是否存在任一档正价(输入或输出)。
// 平台阶梯兜底后 CustomTiers 可能恒非空,全 0 价阶梯不得视为「已配置定价」放行
func tiersHavePrice(tiers []pricingTierRow) bool {
for _, tier := range tiers {
if tier.InputPrice > 0 || tier.OutputPrice > 0 {
return true
}
}
return false
}
// pricingTierRow 定价阶梯行(绝对价格)
type pricingTierRow struct {
MinTokens int64 `json:"min_tokens"`
@@ -519,41 +615,18 @@ type pricingTierRow struct {
OutputPrice float64 `json:"output_price"`
}
// calculateTieredCostFromPricing 从 mdl_pricing 读取阶梯绝对价格计算费用
func calculateTieredCostFromPricing(ctx context.Context, modelName string, tokens int, isInput bool) (float64, float64) {
if tokens <= 0 {
return 0, 1.0
// effectiveTimeMultiplier 时段乘数归一化:0/负值视为 1.0。
// 兜底两类场景:升级部署后 Redis L2 缓存中的旧定价条目(无该字段)、手工构造的零值 PricingResult。
// 计费系统 fail-safe 原则:时段字段缺失不得把费用清零。
func effectiveTimeMultiplier(p *PricingResult) float64 {
if p.TimeMultiplier <= 0 {
return 1.0
}
// 查模型ID
type modelIDRow struct {
ID int64 `json:"id"`
}
var mid modelIDRow
err := dao.MdlModels.Ctx(ctx).
Where("model_id", modelName).
Fields("id").
Scan(&mid)
if err != nil || mid.ID == 0 {
return 0, 1.0
}
// 查阶梯定价(绝对价格)
var tiers []pricingTierRow
err = dao.MdlPricing.Ctx(ctx).
Where("model_id", mid.ID).
Where("billing_mode", "tiered").
OrderAsc("min_tokens").
Fields("min_tokens, max_tokens, input_price, output_price").
Scan(&tiers)
if err != nil || len(tiers) == 0 {
return 0, 1.0
}
return calculateTieredCostFromTiers(tiers, tokens, isInput), 1.0
return p.TimeMultiplier
}
// calculateTieredCostFromTiers 从给定的阶梯数组计算费用(租户自定义阶梯或基础阶梯共用)
// calculateTieredCostFromTiers 从给定的阶梯数组计算费用(租户自定义阶梯或平台阶梯共用;
// 平台阶梯随 GetModelPrice 加载进 PricingResult.CustomTiers,走 600s 定价缓存)
func calculateTieredCostFromTiers(tiers []pricingTierRow, tokens int, isInput bool) float64 {
if tokens <= 0 || len(tiers) == 0 {
return 0
+71 -2
View File
@@ -315,6 +315,17 @@ func TestValidatePricingConfigured(t *testing.T) {
pricing: &PricingResult{BillingMode: "tiered", CustomTiers: []pricingTierRow{{MinTokens: 0, InputPrice: 1.0}}},
wantErr: false,
},
{
// 平台阶梯兜底使 CustomTiers 恒非空,全 0 价阶梯不得视为「已配置」放行(免费使用)
name: "tiered 双零且阶梯全 0 价拒绝",
pricing: &PricingResult{BillingMode: "tiered", CustomTiers: []pricingTierRow{{MinTokens: 0, InputPrice: 0, OutputPrice: 0}, {MinTokens: 100, InputPrice: 0, OutputPrice: 0}}},
wantErr: true,
},
{
name: "tiered 双零但阶梯任一档有正价放行",
pricing: &PricingResult{BillingMode: "tiered", CustomTiers: []pricingTierRow{{MinTokens: 0, InputPrice: 0, OutputPrice: 0}, {MinTokens: 100, OutputPrice: 10.0}}},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -516,8 +527,66 @@ func TestComputeCost_PerRequestMode_WithMultiplier(t *testing.T) {
cb := computeCost(pricing, 100, 50, nil)
// Per-request ignores multiplier in BaseCost but applies it to TotalCost
assertFloat(t, cb.TotalCost, 0.10, "per_request TotalCost")
// BaseCost 保持折扣前单价,TotalCost = 单价 × 租户乘数 × 时段乘数
// (此前按次计费不乘租户乘数,租户折扣对按次模型不生效,已对齐 token/tiered 口径)
assertFloat(t, cb.TotalCost, 0.09, "per_request TotalCost")
assertFloat(t, cb.BaseCost, 0.10, "per_request BaseCost")
}
func TestComputeCost_PerRequestMode_WithTimeMultiplier(t *testing.T) {
pricing := &PricingResult{
BillingMode: "per_request",
PerRequestPrice: 0.10,
TenantMultiplier: 0.9,
TimeMultiplier: 0.5,
Currency: "USD",
}
cb := computeCost(pricing, 100, 50, nil)
assertFloat(t, cb.TotalCost, 0.045, "per_request TotalCost (tenant×time)")
// 时段乘数零值兜底:视为 1.0,不得把费用清零
pricing.TimeMultiplier = 0
cb = computeCost(pricing, 100, 50, nil)
assertFloat(t, cb.TotalCost, 0.09, "per_request TotalCost (zero time multiplier fallback)")
}
func TestComputeCost_WithTimeMultiplier(t *testing.T) {
pricing := &PricingResult{
BillingMode: "token",
InputPrice: 3.0,
OutputPrice: 15.0,
TenantMultiplier: 1.0,
TimeMultiplier: 0.5,
Currency: "USD",
}
// 1M input + 1M output:默认价 18,闲时半价 9
cb := computeCost(pricing, 1_000_000, 1_000_000, nil)
assertFloat(t, cb.InputCost, 1.5, "InputCost (time 0.5)")
assertFloat(t, cb.OutputCost, 7.5, "OutputCost (time 0.5)")
assertFloat(t, cb.TotalCost, 9.0, "TotalCost (time 0.5)")
// 与租户乘数叠加:0.8 × 0.5 = 0.4
pricing.TenantMultiplier = 0.8
cb = computeCost(pricing, 1_000_000, 1_000_000, nil)
assertFloat(t, cb.TotalCost, 7.2, "TotalCost (tenant 0.8 × time 0.5)")
// tiered 模式:阶梯小计 × 时段乘数
tiered := &PricingResult{
BillingMode: "tiered",
TenantMultiplier: 1.0,
TimeMultiplier: 0.5,
Currency: "USD",
CustomTiers: []pricingTierRow{
{MinTokens: 0, MaxTokens: ptrInt64(100_000), InputPrice: 5.0, OutputPrice: 15.0},
{MinTokens: 100_000, MaxTokens: nil, InputPrice: 3.0, OutputPrice: 10.0},
},
}
// 输入 200K:第一档 100K×$5/1M=0.5 + 第二档 100K×$3/1M=0.3 = 0.8,半价 0.4
cb = computeCost(tiered, 200_000, 0, nil)
assertFloat(t, cb.InputCost, 0.4, "tiered InputCost (time 0.5)")
assertFloat(t, cb.TotalCost, 0.4, "tiered TotalCost (time 0.5)")
}
func TestComputeCost_TieredMode_WithCustomTiers(t *testing.T) {
+5 -4
View File
@@ -2,6 +2,7 @@ package billing
import (
"context"
"time"
"github.com/gogf/gf/v2/frame/g"
@@ -30,13 +31,13 @@ func (b *BillingProviderImpl) PreDeduct(ctx context.Context, tenantID int64, mod
return amount, nil
}
func (b *BillingProviderImpl) Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64) (*common.SettlementResult, error) {
func (b *BillingProviderImpl) Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64, billAt time.Time) (*common.SettlementResult, error) {
inputTokens, outputTokens := 0, 0
if usage != nil {
inputTokens = usage.PromptTokens
outputTokens = usage.CompletionTokens
}
result, err := Settle(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, inputTokens, outputTokens, preDeductAmount, projectID)
result, err := Settle(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, inputTokens, outputTokens, preDeductAmount, projectID, billAt)
return toCommonSettlementResult(result), err
}
@@ -60,9 +61,9 @@ func (b *BillingProviderImpl) SettleFailed(ctx context.Context, tenantID int64,
return SettleFailed(ctx, tenantID, requestID, preDeductAmount)
}
func (b *BillingProviderImpl) SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64) (*common.SettlementResult, error) {
func (b *BillingProviderImpl) SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64, billAt time.Time) (*common.SettlementResult, error) {
// 直接透传完整 usage:流中断结算需计入 cache token(拆成 input/output 两数会丢 cache 明细)
result, err := SettleStreamInterrupted(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, usage, preDeductAmount, projectID)
result, err := SettleStreamInterrupted(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, usage, preDeductAmount, projectID, billAt)
return toCommonSettlementResult(result), err
}
+34 -16
View File
@@ -145,20 +145,22 @@ func deleteBillingRecord(ctx context.Context, billingID int64) error {
// Settle 结算请求费用
// 预扣→调用→结算→退差额/补扣
// billAt 为请求受理时刻(时段定价按该时刻评估;零值按当前时刻兜底)
func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
modelName, requestID, relayMode string,
inputTokens, outputTokens int,
preDeductAmount float64, projectID int64) (*SettlementResult, error) {
preDeductAmount float64, projectID int64, billAt time.Time) (*SettlementResult, error) {
// 1. 计算实际费用
breakdown, err := CalculateCost(ctx, tenantID, modelName, inputTokens, outputTokens)
if err != nil {
// 1. 定价与实际费用:按受理时刻一次性取价(时段定价预扣/结算口径一致)
pricingResult, pricingErr := GetModelPriceAt(ctx, tenantID, modelName, billAt)
var breakdown *CostBreakdown
if pricingErr != nil {
// A4 修复:计价失败【不得】按零费用结算——那会把定价异常/模型未配价/短暂 DB 故障
// 都变成免费请求。改为 fail-closed 兜底:按已冻结的预扣额计费(预扣是请求受理时的估价,
// 当前可得的最佳估值),与 task 结算路径(async_polling / sync_image_worker 默认
// actualCost = PreDeductAmount)保持一致。保留 token 数便于账单核对。
g.Log().Errorf(ctx, "settle: calculate cost failed for %s (model=%s), fallback to pre-deduct estimate %.6f: %v",
requestID, modelName, preDeductAmount, err)
requestID, modelName, preDeductAmount, pricingErr)
breakdown = &CostBreakdown{
TotalCost: preDeductAmount,
BaseCost: preDeductAmount,
@@ -166,6 +168,8 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
OutputTokens: outputTokens,
Currency: "USD",
}
} else {
breakdown = computeCost(pricingResult, inputTokens, outputTokens, nil)
}
actualCost := breakdown.TotalCost
@@ -178,8 +182,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
return nil, gerror.Wrapf(err, "settle: get wallet")
}
// 4. 获取定价信息(事务外只读)
pricingResult, _ := GetModelPrice(ctx, tenantID, modelName)
// 4. 定价信息已在第 1 步随费用计算一并获取(事务外只读)
// 5. 执行结算(幂等闸门 → Redis 认领扣款 → 流水)
billingID, err := executeSettlement(ctx, settlementTxParams{
@@ -255,17 +258,29 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
}, nil
}
// SettleWithUsage 完整 Usage 结算(含 cache token 计费 + 计费快照)
// SettleWithUsage 完整 Usage 结算(含 cache token 计费 + 计费快照)。
// 时段定价按 relayInfo.StartTime(请求受理时刻)评估,跨时段边界的请求不因结算延迟变价。
func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
modelName, requestID, relayMode string,
usage *rcommon.Usage, preDeductAmount float64, relayInfo *rcommon.RelayInfo) (*SettlementResult, error) {
// 1. 使用完整 Usage 计算实际费用(含 cache token)
breakdown, err := CalculateCostWithUsage(ctx, tenantID, modelName, usage)
if err != nil {
// 1. 定价与实际费用:按受理时刻一次性取价后用完整 Usage 计算(含 cache token)。
// 此前 CalculateCostWithUsage 与第 4 步各取一次定价,两次结果可能不一致(缓存刷新窗口),已合并。
var billAt time.Time
if relayInfo != nil {
billAt = relayInfo.StartTime
}
pricingResult, pricingErr := GetModelPriceAt(ctx, tenantID, modelName, billAt)
var breakdown *CostBreakdown
if pricingErr != nil || usage == nil {
// A4 修复:计价失败 fail-closed 兜底按预扣额计费,而非零费用(免费请求)。见 Settle 同段说明。
g.Log().Errorf(ctx, "settle_with_usage: calculate cost failed for %s (model=%s), fallback to pre-deduct estimate %.6f: %v",
requestID, modelName, preDeductAmount, err)
if pricingErr != nil {
g.Log().Errorf(ctx, "settle_with_usage: calculate cost failed for %s (model=%s), fallback to pre-deduct estimate %.6f: %v",
requestID, modelName, preDeductAmount, pricingErr)
} else {
g.Log().Errorf(ctx, "settle_with_usage: usage is nil for %s (model=%s), fallback to pre-deduct estimate %.6f",
requestID, modelName, preDeductAmount)
}
fb := &CostBreakdown{
TotalCost: preDeductAmount,
BaseCost: preDeductAmount,
@@ -276,6 +291,8 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
fb.OutputTokens = usage.CompletionTokens
}
breakdown = fb
} else {
breakdown = computeCost(pricingResult, usage.PromptTokens, usage.CompletionTokens, usage)
}
actualCost := breakdown.TotalCost
@@ -288,8 +305,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
return nil, gerror.Wrapf(err, "settle_with_usage: get wallet")
}
// 4. 获取定价信息(事务外只读)
pricingResult, _ := GetModelPrice(ctx, tenantID, modelName)
// 4. 定价信息已在第 1 步随费用计算一并获取(事务外只读)
// 5. 执行结算(幂等闸门 → Redis 认领扣款 → 流水)
billingID, err := executeSettlement(ctx, settlementTxParams{
@@ -401,9 +417,10 @@ func SettleFailed(ctx context.Context, tenantID int64, requestID string, preDedu
// SettleStreamInterrupted 流式中断结算:按已确认 usage 走完整 Usage 结算(含 cache token 计费 + 快照)。
// 此前按 (input, output) 两数走 Settle,会丢弃 usage 中的 cache_read / cache_creation token——
// Claude 场景 prompt_tokens 不含 cache token,大缓存请求中断时缓存部分完全漏计费。
// billAt 为请求受理时刻(写入合成 RelayInfo 供时段定价评估;零值按当前时刻兜底)。
func SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
modelName, requestID, relayMode string,
usage *rcommon.Usage, preDeductAmount float64, projectID int64) (*SettlementResult, error) {
usage *rcommon.Usage, preDeductAmount float64, projectID int64, billAt time.Time) (*SettlementResult, error) {
// usage 为 nil 必须兜换空 Usage:SettleWithUsage 对 nil usage 会 fail-closed 按预扣全额计费,
// 而中断且无任何 usage 的正确语义是 0 token 结算(成本 0,全额退差)
@@ -414,6 +431,7 @@ func SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, ch
ProjectID: projectID,
OriginModelName: modelName,
IsStream: true,
StartTime: billAt,
}
return SettleWithUsage(ctx, tenantID, userID, apiKeyID, channelID,
modelName, requestID, relayMode, usage, preDeductAmount, relayInfo)
+23 -5
View File
@@ -33,6 +33,8 @@ type BillingSnapshotMultipliers struct {
TenantMultiplier float64 `json:"tenant_multiplier"`
DiscountRatio float64 `json:"discount_ratio"`
RateMultiplier float64 `json:"rate_multiplier"`
TimeMultiplier float64 `json:"time_multiplier"` // 时段乘数(未启用时段定价时为 1)
TimeRule string `json:"time_rule"` // 命中的时段名(供账单解释;未命中为空)
}
// BillingSnapshotCachePrices 缓存价格信息
@@ -91,6 +93,8 @@ func GenerateBillingSnapshot(
TenantMultiplier: pricing.TenantMultiplier,
DiscountRatio: pricing.DiscountRatio,
RateMultiplier: pricing.TenantMultiplier,
TimeMultiplier: effectiveTimeMultiplier(pricing),
TimeRule: pricing.TimeRuleName,
},
TokenCosts: buildTokenCosts(pricing, breakdown),
}
@@ -228,13 +232,27 @@ func GenerateBillingSummary(snapshot *BillingSnapshot) string {
formatInt(tc.Tokens), formatPrice(tc.UnitPrice), formatPrice(tc.Cost)))
}
// 小计 × 倍率
// 小计 × 倍率(乘法链 = 租户倍率 × 时段乘数,两者均为 1 时省略该行)
subtotal := snapshot.Settlement.ActualCost
if snapshot.Multipliers.TenantMultiplier != 1.0 && snapshot.Multipliers.TenantMultiplier != 0 {
preMultiplier := subtotal / snapshot.Multipliers.TenantMultiplier
lines = append(lines, fmt.Sprintf("小计: $%s × 租户倍率(%.2f) = $%s",
formatPrice(preMultiplier), snapshot.Multipliers.TenantMultiplier, formatPrice(subtotal)))
effTenant := snapshot.Multipliers.TenantMultiplier
effTime := snapshot.Multipliers.TimeMultiplier
if effTime <= 0 {
effTime = 1.0
}
if effTenant > 0 && (effTenant != 1.0 || effTime != 1.0) {
preMultiplier := subtotal / (effTenant * effTime)
multDesc := fmt.Sprintf("%.2f", effTenant)
if effTime != 1.0 {
multDesc += fmt.Sprintf(" × 时段(%.2f)", effTime)
}
lines = append(lines, fmt.Sprintf("小计: $%s × 租户倍率(%s) = $%s",
formatPrice(preMultiplier), multDesc, formatPrice(subtotal)))
}
}
// 时段定价行:放在 token/per_request 分支之后统一展示,保证账单可解释(按次计费同样适用时段乘数)
if snapshot.Multipliers.TimeRule != "" && snapshot.Multipliers.TimeMultiplier > 0 && snapshot.Multipliers.TimeMultiplier != 1.0 {
lines = append(lines, fmt.Sprintf("时段: %s ×%.2f", snapshot.Multipliers.TimeRule, snapshot.Multipliers.TimeMultiplier))
}
// 结算信息
+51
View File
@@ -443,3 +443,54 @@ func TestFormatPrice(t *testing.T) {
}
}
}
func TestGenerateBillingSnapshot_TimeMultipliers(t *testing.T) {
pricing := &PricingResult{
InputPrice: 1.0,
OutputPrice: 2.0,
BillingMode: "token",
TenantMultiplier: 0.8,
TimeMultiplier: 0.5,
TimeRuleName: "闲时",
}
breakdown := &CostBreakdown{InputTokens: 100, OutputTokens: 50}
snapshot := GenerateBillingSnapshot(pricing, breakdown, nil, nil, nil)
assertFloat(t, snapshot.Multipliers.TimeMultiplier, 0.5, "TimeMultiplier")
if snapshot.Multipliers.TimeRule != "闲时" {
t.Errorf("TimeRule = %q, want 闲时", snapshot.Multipliers.TimeRule)
}
// 时段乘数零值兜底:快照记录 1 而非 0
pricing.TimeMultiplier = 0
snapshot = GenerateBillingSnapshot(pricing, breakdown, nil, nil, nil)
assertFloat(t, snapshot.Multipliers.TimeMultiplier, 1.0, "TimeMultiplier (zero fallback)")
}
func TestGenerateBillingSummary_TimeRule(t *testing.T) {
snapshot := &BillingSnapshot{
Pricing: BillingSnapshotPricing{
BillingMode: "token",
EffectiveInputPrice: 1.0,
BillingSource: "base",
},
Multipliers: BillingSnapshotMultipliers{
TenantMultiplier: 0.8,
TimeMultiplier: 0.5,
TimeRule: "闲时",
},
Settlement: BillingSnapshotSettlement{
PreDeductAmount: 0.1,
ActualCost: 0.04,
},
RequestMeta: BillingSnapshotRequestMeta{RequestedModel: "gpt-4o"},
}
text := GenerateBillingSummary(snapshot)
if !strings.Contains(text, "闲时") {
t.Errorf("summary should contain time rule name, got:\n%s", text)
}
if !strings.Contains(text, "时段(0.50)") {
t.Errorf("summary should contain time multiplier in subtotal line, got:\n%s", text)
}
}
+24 -16
View File
@@ -153,7 +153,8 @@ func (b *TaskBillingProviderImpl) CheckApiKeyQuota(ctx context.Context, apiKeyID
// SettleTaskSuccess 任务成功结算(含计费快照)
// totalTokens/completionTokens: 上游返回的 token 用量
// ratios: 提交时保存的计费比率(如 video_input 折扣)
func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]float64, taskID string) (*common.SettlementResult, error) {
// billAt: 任务受理时刻(时段定价按该时刻评估;零值按当前时刻兜底)
func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]float64, taskID string, billAt time.Time) (*common.SettlementResult, error) {
diff := SubtractMoney(actualCost, preDeductAmount)
// 1. 获取钱包
@@ -162,8 +163,8 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
return nil, fmt.Errorf("settle task: get wallet: %w", err)
}
// 2. 获取定价(事务外只读)
pricing, _ := GetModelPrice(ctx, tenantID, modelName)
// 2. 获取定价(事务外只读,按任务受理时刻评估时段乘数)
pricing, _ := GetModelPriceAt(ctx, tenantID, modelName, billAt)
breakdown := buildTaskCostBreakdown(pricing, InexactFloat64(actualCost), totalTokens, completionTokens)
var billingMode string
@@ -337,7 +338,7 @@ func buildTaskCostBreakdown(pricing *PricingResult, actualCost float64, totalTok
// 不摊进 OutputCost。否则快照会生成「0 token 却有 output 费用」的自相矛盾行
// (如 0 tokens × $30/1M = $3.375)。真实费用仍由结算的 actual_cost 体现。
if totalTokens <= 0 {
bd.BaseCost = actualCost
bd.BaseCost = preMultiplierCost(actualCost, pricing)
bd.TotalCost = actualCost
return bd
}
@@ -346,17 +347,22 @@ func buildTaskCostBreakdown(pricing *PricingResult, actualCost float64, totalTok
bd.OutputTokens = totalTokens
bd.OutputCost = actualCost
bd.TotalCost = actualCost
tenantMul := pricing.TenantMultiplier
if tenantMul > 0 {
bd.BaseCost = actualCost / tenantMul
} else {
bd.BaseCost = actualCost
}
bd.BaseCost = preMultiplierCost(actualCost, pricing)
return bd
}
// preMultiplierCost 把已含「租户乘数 × 时段乘数」的实际费用还原为折扣前成本(BaseCost 语义)。
// actualCost 来自 RecalculateByTokens(已乘全部乘数)或预扣额(EstimatePreDeductAmount 同样含乘数),
// 还原时两个乘数都要除,与 computeCost 的 BaseCost = 折扣前小计语义对齐
func preMultiplierCost(actualCost float64, pricing *PricingResult) float64 {
mul := pricing.TenantMultiplier * effectiveTimeMultiplier(pricing)
if mul <= 0 {
return actualCost
}
return actualCost / mul
}
// SettleTaskFailed 任务失败退还预扣
func (b *TaskBillingProviderImpl) SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount decimal.Decimal) error {
preDeductFloat := InexactFloat64(preDeductAmount)
@@ -395,14 +401,15 @@ func (b *TaskBillingProviderImpl) AdjustTaskBilling(ctx context.Context, tenantI
}
// RecalculateByTokens 根据上游返回的 total_tokens 重算费用。
// 公式:totalTokens / 1M × output_price × tenant_multiplier × 附加比率
// 如果模型没有配置 token 单价(纯按次计费),返回 0 表示不做 token 重算。
func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64) (decimal.Decimal, error) {
// 公式:totalTokens / 1M × output_price × tenant_multiplier × 时段乘数 × 附加比率
// billAt 为任务受理时刻(时段定价按该时刻评估);如果模型没有配置 token 单价(纯按次计费),
// 返回 0 表示不做 token 重算。
func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64, billAt time.Time) (decimal.Decimal, error) {
if totalTokens <= 0 {
return Zero, nil
}
pricing, err := GetModelPrice(ctx, tenantID, modelName)
pricing, err := GetModelPriceAt(ctx, tenantID, modelName, billAt)
if err != nil {
return Zero, nil
}
@@ -416,7 +423,8 @@ func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenan
million := decimal.NewFromInt(1_000_000)
costD := decimal.NewFromInt(int64(totalTokens)).Div(million).
Mul(NewFromFloat(pricing.OutputPrice)).
Mul(NewFromFloat(pricing.TenantMultiplier))
Mul(NewFromFloat(pricing.TenantMultiplier)).
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
// 应用附加比率(视频输入折扣等)
// 注意:跳过 duration/resolution,它们已体现在上游返回的 token 数中,不应再乘
@@ -118,6 +118,44 @@ func TestBuildTaskCostBreakdown_TokenMode_ZeroTokens(t *testing.T) {
assertFloat(t, bd.TotalCost, 3.375, "TotalCost")
}
// TestBuildTaskCostBreakdown_TokenMode_TimeMultiplier BaseCost 还原必须同时除以
// 租户乘数与时段乘数(actualCost 已含全部乘数),与 computeCost 的折扣前小计语义对齐。
func TestBuildTaskCostBreakdown_TokenMode_TimeMultiplier(t *testing.T) {
pricing := &PricingResult{
BillingMode: "token",
OutputPrice: 3.0,
TenantMultiplier: 0.8,
TimeMultiplier: 0.5,
Currency: "USD",
}
// 0.4 / (0.8 × 0.5) = 1.0
bd := buildTaskCostBreakdown(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)
assertFloat(t, bd.BaseCost, 0.2, "BaseCost zero-token (tenant × time)")
assertFloat(t, bd.TotalCost, 0.08, "TotalCost zero-token")
}
// TestBuildTaskCostBreakdown_ZeroTimeMultiplierFallback 时段乘数零值(升级部署后
// Redis L2 旧定价条目缺字段)兜底为 1.0,BaseCost 还原不得失真。
func TestBuildTaskCostBreakdown_ZeroTimeMultiplierFallback(t *testing.T) {
pricing := &PricingResult{
BillingMode: "token",
OutputPrice: 3.0,
TenantMultiplier: 0.8,
TimeMultiplier: 0, // 旧缓存条目缺字段
Currency: "USD",
}
// 0.024 / (0.8 × 1.0) = 0.03
bd := buildTaskCostBreakdown(pricing, 0.024, 1000, 500)
assertFloat(t, bd.BaseCost, 0.03, "BaseCost (time fallback 1.0)")
}
// TestEstimateTaskCost_PerRequest 按次计费直接取按次单价。
func TestEstimateTaskCost_PerRequest(t *testing.T) {
pricing := &PricingResult{
+202
View File
@@ -0,0 +1,202 @@
package billing
import (
"context"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
lcommon "github.com/qianfree/team-api/internal/logic/common"
)
// TimeSegment 时段定价规则(mdl_pricing.time_segments JSONB 数组元素)。
// 语义:按数组顺序先命中先生效,可用来表达「促销时段压在常驻时段之上」;
// 未命中任何时段 → 乘数 1.0(默认价)。最终费用 = 各项小计 × 租户乘数 × 时段乘数。
type TimeSegment struct {
Name string `json:"name"` // 时段名(写入计费快照,供账单解释)
Days []int `json:"days,omitempty"` // 适用星期 1=周一..7=周日;空=每天
StartTime string `json:"start_time,omitempty"` // 每日开始时刻 "HH:MM";与 EndTime 均空=全天
EndTime string `json:"end_time,omitempty"` // 每日结束时刻;End<Start 表示跨零点(如 22:00~06:00)
ValidFrom string `json:"valid_from,omitempty"` // 生效起始日期 "2006-01-02"(含端点);促销/定时调价用
ValidTo string `json:"valid_to,omitempty"` // 生效结束日期(含端点);空=长期有效
Multiplier float64 `json:"multiplier"` // 价格乘数,0.5=半价;取值 (0,10]
}
const (
// maxTimeSegments 单模型时段数量上限,防止配置爆炸与评估开销失控
maxTimeSegments = 20
// defaultPricingTimezone 时段定价默认时区(平台面向中国区运营)
defaultPricingTimezone = "Asia/Shanghai"
)
// tzLocationCache 时区名 → *time.Location 缓存,避免每请求 LoadLocation
var tzLocationCache sync.Map
// resolveTimeMultiplier 按定价时刻评估时段乘数(纯函数,不触 DB/缓存)。
// 返回命中的乘数与时段名;未命中返回 (1.0, "")。
// 格式非法的时段直接跳过——数据在写入时已过 ValidateTimeSegments,此处仅防御性兜底。
func resolveTimeMultiplier(segments []TimeSegment, billAt time.Time, loc *time.Location) (float64, string) {
for _, seg := range segments {
if segmentMatches(seg, billAt, loc) {
return seg.Multiplier, seg.Name
}
}
return 1.0, ""
}
// segmentMatches 判断 billAt(换算到 loc 时区后)是否命中单个时段。
// 三个维度正交组合:日期边界 × 星期 × 每日时间窗。
func segmentMatches(seg TimeSegment, t time.Time, loc *time.Location) bool {
local := t.In(loc)
// 日期边界(含端点):YYYY-MM-DD 按可比较整数
if seg.ValidFrom != "" {
from, ok := parseSegDate(seg.ValidFrom)
if !ok || segDateNum(local) < from {
return false
}
}
if seg.ValidTo != "" {
to, ok := parseSegDate(seg.ValidTo)
if !ok || segDateNum(local) > to {
return false
}
}
// 星期过滤:Go 的 Weekday 周日=0,映射为 ISO 习惯的 1=周一..7=周日
if len(seg.Days) > 0 {
wd := int(local.Weekday())
if wd == 0 {
wd = 7
}
if !slices.Contains(seg.Days, wd) {
return false
}
}
// 每日时间窗:两项均空=全天;只填一项视为非法,跳过
if seg.StartTime == "" && seg.EndTime == "" {
return true
}
start, okS := parseSegClock(seg.StartTime)
end, okE := parseSegClock(seg.EndTime)
if !okS || !okE {
return false
}
cur := local.Hour()*60 + local.Minute()
if start <= end {
// 常规窗口 [start, end),结束时刻那一分钟起不再命中(08:00~ 表示到 07:59:59)
return cur >= start && cur < end
}
// 跨零点窗口(如 22:00~06:00):当日 >= start 或 次日凌晨 < end
return cur >= start || cur < end
}
// parseSegClock 解析 "HH:MM" 为当日分钟数(严格两位数字,"9:00"/"24:00" 等视为非法)
func parseSegClock(s string) (int, bool) {
parts := strings.Split(s, ":")
if len(parts) != 2 || len(parts[0]) != 2 || len(parts[1]) != 2 {
return 0, false
}
h, errH := strconv.Atoi(parts[0])
m, errM := strconv.Atoi(parts[1])
if errH != nil || errM != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return 0, false
}
return h*60 + m, true
}
// parseSegDate 解析 "2006-01-02" 为可比较整数 YYYYMMDD
func parseSegDate(s string) (int, bool) {
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
if err != nil {
return 0, false
}
return segDateNum(t), true
}
// segDateNum time.Time → YYYYMMDD 整数,用于日期边界比较
func segDateNum(t time.Time) int {
return t.Year()*10000 + int(t.Month())*100 + t.Day()
}
// ValidateTimeSegments 校验时段定价配置(管理后台保存时调用,fail-fast 返回用户可读中文错误)
func ValidateTimeSegments(segments []TimeSegment) error {
if len(segments) > maxTimeSegments {
return gerror.Newf("时段数量不能超过 %d 条", maxTimeSegments)
}
for i, seg := range segments {
label := seg.Name
if label == "" {
label = strconv.Itoa(i + 1)
}
if strings.TrimSpace(seg.Name) == "" {
return gerror.Newf("第 %s 条时段名称不能为空", label)
}
if seg.Multiplier <= 0 || seg.Multiplier > 10 {
return gerror.Newf("时段「%s」乘数必须在 (0, 10] 之间", seg.Name)
}
// 时间窗:两项要么都空(全天)要么都填
if (seg.StartTime == "") != (seg.EndTime == "") {
return gerror.Newf("时段「%s」开始与结束时间必须同时填写或同时留空", seg.Name)
}
if seg.StartTime != "" {
if _, ok := parseSegClock(seg.StartTime); !ok {
return gerror.Newf("时段「%s」开始时间格式必须为 HH:MM", seg.Name)
}
if _, ok := parseSegClock(seg.EndTime); !ok {
return gerror.Newf("时段「%s」结束时间格式必须为 HH:MM", seg.Name)
}
}
// 星期:1~7 且不重复
seen := make(map[int]bool, len(seg.Days))
for _, d := range seg.Days {
if d < 1 || d > 7 {
return gerror.Newf("时段「%s」适用日必须在 1(周一)~ 7(周日)之间", seg.Name)
}
if seen[d] {
return gerror.Newf("时段「%s」适用日存在重复", seg.Name)
}
seen[d] = true
}
// 日期边界格式与先后
if seg.ValidFrom != "" {
if _, ok := parseSegDate(seg.ValidFrom); !ok {
return gerror.Newf("时段「%s」生效起始日期格式必须为 YYYY-MM-DD", seg.Name)
}
}
if seg.ValidTo != "" {
if _, ok := parseSegDate(seg.ValidTo); !ok {
return gerror.Newf("时段「%s」生效结束日期格式必须为 YYYY-MM-DD", seg.Name)
}
}
if seg.ValidFrom != "" && seg.ValidTo != "" && seg.ValidFrom > seg.ValidTo {
return gerror.Newf("时段「%s」生效起始日期不能晚于结束日期", seg.Name)
}
}
return nil
}
// pricingTimeLocation 返回时段定价使用的时区(sys_options: pricing_time_timezone,默认 Asia/Shanghai)。
// Location 按 tz 字符串进程级缓存;解析失败兜底 time.Local(容器/主机 TZ 为 Asia/Shanghai)。
func pricingTimeLocation(ctx context.Context) *time.Location {
tz := lcommon.Config().GetOption(ctx, "pricing_time_timezone")
if tz == "" {
tz = defaultPricingTimezone
}
if loc, ok := tzLocationCache.Load(tz); ok {
return loc.(*time.Location)
}
loc, err := time.LoadLocation(tz)
if err != nil {
g.Log().Warningf(ctx, "billing: 非法计费时区配置 %q,回退本地时区", tz)
loc = time.Local
}
tzLocationCache.Store(tz, loc)
return loc
}
+211
View File
@@ -0,0 +1,211 @@
package billing
import (
"testing"
"time"
)
// cst 测试用固定时区 UTC+8(与生产默认 Asia/Shanghai 偏移一致),避免依赖宿主机时区
var cst = time.FixedZone("CST", 8*3600)
// monday/wednesday/saturday/sunday 2026-08-17 为周一(下方有防御性断言),18 周二、19 周三、22 周六、23 周日
var (
monday = time.Date(2026, 8, 17, 0, 0, 0, 0, cst)
wednesday = time.Date(2026, 8, 19, 0, 0, 0, 0, cst)
saturday = time.Date(2026, 8, 22, 0, 0, 0, 0, cst)
)
func TestTimePricing_FixtureWeekdaySanity(t *testing.T) {
// 日期夹具防御性校验:若有人改动夹具日期导致星期错位,在此直接失败
if monday.Weekday() != time.Monday || wednesday.Weekday() != time.Wednesday ||
saturday.Weekday() != time.Saturday {
t.Fatalf("测试日期夹具星期不正确: %v %v %v", monday.Weekday(), wednesday.Weekday(), saturday.Weekday())
}
}
func TestResolveTimeMultiplier_FirstMatchWins(t *testing.T) {
segments := []TimeSegment{
{Name: "促销", Multiplier: 0.8, StartTime: "00:00", EndTime: "24:00"}, // "24:00" 非法,跳过
{Name: "促销2", Multiplier: 0.8},
{Name: "闲时", Multiplier: 0.5},
}
// 命中第二条(第一条格式非法被跳过),证明顺序先命中 + 坏格式防御
mult, name := resolveTimeMultiplier(segments, wednesday.Add(10*time.Hour), cst)
assertFloat(t, mult, 0.8, "mult")
if name != "促销2" {
t.Fatalf("expected 促销2, got %s", name)
}
}
func TestResolveTimeMultiplier_NormalWindow(t *testing.T) {
segments := []TimeSegment{{Name: "忙时", Days: []int{1, 2, 3, 4, 5}, StartTime: "09:00", EndTime: "18:00", Multiplier: 1.2}}
cases := []struct {
desc string
at time.Time
expected float64
}{
{"工作日窗口内 10:00", monday.Add(10 * time.Hour), 1.2},
{"工作日开始时刻 09:00(含)", monday.Add(9 * time.Hour), 1.2},
{"工作日结束时刻 18:00(不含)", monday.Add(18 * time.Hour), 1.0},
{"工作日窗口前 08:59", monday.Add(8*time.Hour + 59*time.Minute), 1.0},
{"周六不命中(days 过滤)", saturday.Add(10 * time.Hour), 1.0},
}
for _, c := range cases {
mult, _ := resolveTimeMultiplier(segments, c.at, cst)
assertFloat(t, mult, c.expected, c.desc)
}
}
func TestResolveTimeMultiplier_CrossMidnight(t *testing.T) {
segments := []TimeSegment{{Name: "夜段", StartTime: "22:00", EndTime: "06:00", Multiplier: 0.5}}
cases := []struct {
desc string
at time.Time
expected float64
}{
{"22:00 起始(含)", wednesday.Add(22 * time.Hour), 0.5},
{"23:30 窗口内", wednesday.Add(23*time.Hour + 30*time.Minute), 0.5},
{"次日 03:00 仍命中", wednesday.Add(24*time.Hour + 3*time.Hour), 0.5},
{"06:00 结束(不含)", wednesday.Add(6 * time.Hour), 1.0},
{"12:00 窗口外", wednesday.Add(12 * time.Hour), 1.0},
}
for _, c := range cases {
mult, _ := resolveTimeMultiplier(segments, c.at, cst)
assertFloat(t, mult, c.expected, c.desc)
}
}
func TestResolveTimeMultiplier_AllDayAndEmpty(t *testing.T) {
// 全天段(时间两项均空)
segments := []TimeSegment{{Name: "周末", Days: []int{6, 7}, Multiplier: 0.8}}
mult, name := resolveTimeMultiplier(segments, saturday.Add(15*time.Hour), cst)
assertFloat(t, mult, 0.8, "周末全天")
if name != "周末" {
t.Fatalf("expected 周末, got %s", name)
}
// 无时段 → 默认 1.0
mult, name = resolveTimeMultiplier(nil, wednesday, cst)
assertFloat(t, mult, 1.0, "无时段")
if name != "" {
t.Fatalf("expected empty name, got %s", name)
}
}
func TestResolveTimeMultiplier_DateBounds(t *testing.T) {
segments := []TimeSegment{{Name: "促销", ValidFrom: "2026-08-17", ValidTo: "2026-08-19", Multiplier: 0.8}}
cases := []struct {
desc string
at time.Time
expected float64
}{
{"生效首日 00:05(含端点)", monday.Add(5 * time.Minute), 0.8},
{"生效末日 23:59(含端点)", wednesday.Add(23*time.Hour + 59*time.Minute), 0.8},
{"结束后次日", wednesday.Add(24 * time.Hour), 1.0},
{"开始前一日", monday.Add(-time.Hour), 1.0},
}
for _, c := range cases {
mult, _ := resolveTimeMultiplier(segments, c.at, cst)
assertFloat(t, mult, c.expected, c.desc)
}
}
func TestResolveTimeMultiplier_CombinedDimensions(t *testing.T) {
// 日期边界 × 工作日 × 时间窗三维度正交组合
segments := []TimeSegment{
{Name: "九月促销忙时", Days: []int{1, 2, 3, 4, 5}, StartTime: "09:00", EndTime: "18:00",
ValidFrom: "2026-09-01", ValidTo: "2026-09-30", Multiplier: 0.8},
}
septMonday := time.Date(2026, 9, 7, 10, 0, 0, 0, cst) // 2026-09-07 周一
if septMonday.Weekday() != time.Monday {
t.Fatalf("夹具 2026-09-07 应为周一, got %v", septMonday.Weekday())
}
mult, _ := resolveTimeMultiplier(segments, septMonday, cst)
assertFloat(t, mult, 0.8, "促销期内工作日忙时")
// 周六即使时间命中也不生效
septSaturday := time.Date(2026, 9, 12, 10, 0, 0, 0, cst)
mult, _ = resolveTimeMultiplier(segments, septSaturday, cst)
assertFloat(t, mult, 1.0, "促销期内周六")
// 工作日但窗口外
mult, _ = resolveTimeMultiplier(segments, septMonday.Add(20*time.Hour), cst)
assertFloat(t, mult, 1.0, "促销期内工作日夜里")
// 促销期外的工作日忙时
octMonday := time.Date(2026, 10, 5, 10, 0, 0, 0, cst)
mult, _ = resolveTimeMultiplier(segments, octMonday, cst)
assertFloat(t, mult, 1.0, "促销期外")
}
func TestResolveTimeMultiplier_BadFormatSkipped(t *testing.T) {
segments := []TimeSegment{
{Name: "坏时间", StartTime: "9:00", EndTime: "18:00", Multiplier: 0.1}, // "9:00" 缺前导零视为非法
{Name: "坏小时", StartTime: "25:00", EndTime: "26:00", Multiplier: 0.2},
{Name: "只填开始", StartTime: "09:00", Multiplier: 0.3},
{Name: "兜底段", Multiplier: 0.9},
}
mult, name := resolveTimeMultiplier(segments, monday.Add(10*time.Hour), cst)
assertFloat(t, mult, 0.9, "坏格式全部跳过后命中兜底段")
if name != "兜底段" {
t.Fatalf("expected 兜底段, got %s", name)
}
}
func TestResolveTimeMultiplier_TimezoneConversion(t *testing.T) {
// 时段按评估时区解释:UTC 16:00 = CST 00:00(次日),应命中 CST 00:00~08:00 的闲时段
segments := []TimeSegment{{Name: "闲时", StartTime: "00:00", EndTime: "08:00", Multiplier: 0.5}}
utc := time.FixedZone("UTC", 0)
at := time.Date(2026, 8, 19, 16, 30, 0, 0, utc) // UTC 16:30 = CST 00:30
mult, _ := resolveTimeMultiplier(segments, at, cst)
assertFloat(t, mult, 0.5, "跨时区换算后命中")
}
func TestValidateTimeSegments(t *testing.T) {
ok := []TimeSegment{
{Name: "闲时", StartTime: "00:00", EndTime: "08:00", Multiplier: 0.5},
{Name: "周末", Days: []int{6, 7}, Multiplier: 10},
{Name: "促销", ValidFrom: "2026-09-01", ValidTo: "2026-09-30", Multiplier: 0.8},
}
if err := ValidateTimeSegments(ok); err != nil {
t.Fatalf("合法配置不应报错: %v", err)
}
if err := ValidateTimeSegments(nil); err != nil {
t.Fatalf("空配置不应报错: %v", err)
}
badCases := []struct {
desc string
seg TimeSegment
errIs bool // 期望报错
}{
{"乘数为 0", TimeSegment{Name: "a", Multiplier: 0}, true},
{"乘数超上限", TimeSegment{Name: "a", Multiplier: 10.1}, true},
{"名称为空", TimeSegment{Name: "", Multiplier: 1}, true},
{"只填开始时间", TimeSegment{Name: "a", StartTime: "09:00", Multiplier: 1}, true},
{"时间格式非法", TimeSegment{Name: "a", StartTime: "9:00", EndTime: "18:00", Multiplier: 1}, true},
{"小时越界", TimeSegment{Name: "a", StartTime: "24:00", EndTime: "25:00", Multiplier: 1}, true},
{"星期越界", TimeSegment{Name: "a", Days: []int{0}, Multiplier: 1}, true},
{"星期重复", TimeSegment{Name: "a", Days: []int{1, 1}, Multiplier: 1}, true},
{"日期格式非法", TimeSegment{Name: "a", ValidFrom: "2026/09/01", Multiplier: 1}, true},
{"起始晚于结束", TimeSegment{Name: "a", ValidFrom: "2026-09-30", ValidTo: "2026-09-01", Multiplier: 1}, true},
}
for _, c := range badCases {
err := ValidateTimeSegments([]TimeSegment{c.seg})
if c.errIs && err == nil {
t.Fatalf("%s: 期望报错但通过", c.desc)
}
}
// 数量上限
var many []TimeSegment
for range maxTimeSegments + 1 {
many = append(many, TimeSegment{Name: "s", Multiplier: 1})
}
if err := ValidateTimeSegments(many); err == nil {
t.Fatalf("超过 %d 条应报错", maxTimeSegments)
}
}
+9 -4
View File
@@ -50,12 +50,17 @@ func GetAuditLevels(ctx context.Context, tenantID int64) (globalLevel, tenantLev
// GetTenantAuditLevel 从 tnt_tenants.settings JSONB 中读取租户自身设置的审计级别。
// 未设置时返回空字符串,不回退到全局级别。
func GetTenantAuditLevel(ctx context.Context, tenantID int64) string {
var settingsJSON string
err := dao.TntTenants.Ctx(ctx).
// 读单列字符串必须用 Value()——gdb 的 Model.Scan 只接受 struct 系列,
// 传入 *string 直接报错,会导致租户审计级别永远读不到
v, err := dao.TntTenants.Ctx(ctx).
Where("id", tenantID).
Fields("settings").
Scan(&settingsJSON)
if err != nil || settingsJSON == "" {
Value()
settingsJSON := ""
if err == nil {
settingsJSON = v.String()
}
if settingsJSON == "" {
return ""
}
var settings struct {
+26 -2
View File
@@ -5,9 +5,11 @@ import (
"encoding/json"
"math"
"math/rand/v2"
"reflect"
"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/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
@@ -135,8 +137,15 @@ func (c *Cache) GetJSON(ctx context.Context, key string, target any) bool {
g.Log().Warningf(ctx, "[Cache] JSON unmarshal failed key=%s: %v", fullKey, unmarshalErr)
return false
}
// Backfill L1
gcache.Set(ctx, fullKey, target, jitterTTL(c.ttl))
// Backfill L1:存反序列化副本而非 target 本身。target 指针归调用方所有,
// 若调用方在 GetJSON 返回后就地改写(如定价缓存按时段重评乘数),
// 存储别名会与并发 L1 读者的 json.Marshal 构成数据竞争。
// 副本失败时退回存 target(此时调用方须自律不改写,与历史行为一致)
if cp, cpErr := unmarshalCopy(jsonStr, target); cpErr == nil {
gcache.Set(ctx, fullKey, cp, jitterTTL(c.ttl))
} else {
gcache.Set(ctx, fullKey, target, jitterTTL(c.ttl))
}
return true
}
}
@@ -144,6 +153,21 @@ func (c *Cache) GetJSON(ctx context.Context, key string, target any) bool {
return false
}
// unmarshalCopy 按 target 的具体类型从 JSON 生成一个独立副本(L1 回填用,避免
// L1 持有调用方 target 指针的别名)。target 必须是非 nil 指针(调用前已成功
// unmarshal 到 target,天然满足)。
func unmarshalCopy(jsonStr string, target any) (any, error) {
t := reflect.TypeOf(target)
if t == nil || t.Kind() != reflect.Pointer {
return nil, gerror.New("cache: unmarshalCopy target must be a non-nil pointer")
}
cp := reflect.New(t.Elem()).Interface()
if err := json.Unmarshal([]byte(jsonStr), cp); err != nil {
return nil, err
}
return cp, nil
}
// GetOrSet retrieves a value or calls fn to set it if missing.
func (c *Cache) GetOrSet(ctx context.Context, key string, fn func(ctx context.Context) (any, error)) (any, error) {
if val, ok := c.Get(ctx, key); ok {
+55
View File
@@ -1,6 +1,7 @@
package common
import (
"encoding/json"
"testing"
"time"
)
@@ -19,3 +20,57 @@ func TestJitterTTLRange(t *testing.T) {
t.Fatalf("jitterTTL(0) = %v, want 0", got)
}
}
// TestUnmarshalCopyIndependent 副本必须与 target 完全独立:改写副本不得波及 target,
// 反之亦然(GetJSON L2 回填后调用方改写 target 不得产生缓存别名竞争)。
func TestUnmarshalCopyIndependent(t *testing.T) {
type seg struct {
Name string `json:"name"`
Mult float64 `json:"mult"`
}
type payload struct {
TimeMultiplier float64 `json:"time_multiplier"`
TimeRule string `json:"time_rule"`
Segs []seg `json:"segs"`
}
target := payload{}
jsonStr := `{"time_multiplier":1.5,"time_rule":"忙时","segs":[{"name":"a","mult":2}]}`
if err := json.Unmarshal([]byte(jsonStr), &target); err != nil {
t.Fatalf("unmarshal target: %v", err)
}
cpAny, err := unmarshalCopy(jsonStr, &target)
if err != nil {
t.Fatalf("unmarshalCopy: %v", err)
}
cp, ok := cpAny.(*payload)
if !ok {
t.Fatalf("unmarshalCopy 返回类型 %T, want *payload", cpAny)
}
// 初始值一致
if cp.TimeRule != target.TimeRule || cp.TimeMultiplier != target.TimeMultiplier || len(cp.Segs) != len(target.Segs) {
t.Fatalf("副本初始值与 target 不一致: %+v vs %+v", cp, &target)
}
// 改写副本不波及 target
cp.TimeMultiplier = 0.5
cp.TimeRule = "闲时"
if target.TimeMultiplier != 1.5 || target.TimeRule != "忙时" {
t.Fatalf("改写副本波及了 target: %+v", &target)
}
// 改写 target 不波及副本
target.TimeMultiplier = 9.9
if cp.TimeMultiplier != 0.5 {
t.Fatalf("改写 target 波及了副本: %+v", cp)
}
}
// TestUnmarshalCopyRejectsNonPointer 非 pointer target 必须报错而非 panic。
func TestUnmarshalCopyRejectsNonPointer(t *testing.T) {
if _, err := unmarshalCopy(`{}`, map[string]any{}); err == nil {
t.Fatal("non-pointer target 应返回错误")
}
}
@@ -0,0 +1,30 @@
package common
import (
"time"
)
// DefaultChannelDebugLogWriter 渠道调试日志全局写入器
var DefaultChannelDebugLogWriter *UsageLogWriter
// InitChannelDebugLogWriter 初始化渠道调试日志写入器。
// 单条记录可能达数 MB(完整四段报文):小批量 + 短间隔切批,队列满时丢弃并计数
// (调试日志允许丢,不反压请求;管理员可重发请求补录)。
func InitChannelDebugLogWriter() {
DefaultChannelDebugLogWriter = NewUsageLogWriter(UsageLogWriterConfig{
Table: "chn_debug_logs",
QueueSize: 256,
BatchSize: 4,
FlushInterval: 2 * time.Second,
Workers: 2,
Overflow: OverflowDrop,
})
DefaultChannelDebugLogWriter.Start()
}
// CloseChannelDebugLogWriter 关闭渠道调试日志写入器
func CloseChannelDebugLogWriter() {
if DefaultChannelDebugLogWriter != nil {
DefaultChannelDebugLogWriter.Close()
}
}
+3 -3
View File
@@ -75,6 +75,8 @@ var Registry = []SettingDef{
Label: "全局 API 维护", Description: "开启后 API 代理返回 503,叠加维护模式使用", IsPublic: true},
{Key: "tenant_console_url", Type: SettingTypeString, Default: "", Category: "general",
Label: "租户控制台地址", Description: "租户控制台的完整 URL,如 https://console.example.com,用于生成邀请链接等"},
{Key: "pricing_time_timezone", Type: SettingTypeString, Default: "Asia/Shanghai", Category: "general",
Label: "计费时区", Description: "模型时段定价(峰谷/促销)按此时区解释,IANA 时区名,如 Asia/Shanghai"},
// ── OAuth (第三方登录) ──
{Key: "oauth_auto_register", Type: SettingTypeBool, Default: "false", Category: "oauth",
@@ -210,9 +212,7 @@ var Registry = []SettingDef{
// ── Channel ──
{Key: "channel_auto_test_enabled", Type: SettingTypeBool, Default: "true", Category: "channel",
Label: "渠道自动探测", Description: "定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)"},
{Key: "channel_auto_test_recovery_enabled", Type: SettingTypeBool, Default: "true", Category: "channel",
Label: "自动恢复探测", Description: "定期测试已自动禁用的渠道,测试通过则恢复启用(依赖自动探测开启)"},
Label: "渠道自动探测", Description: "定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)。禁用渠道不自动探测,由管理员手动测试确认后再启用"},
{Key: "channel_auto_disable_enabled", Type: SettingTypeBool, Default: "false", Category: "channel",
Label: "渠道自动禁用", Description: "渠道熔断持续超过路由策略 breaker.autoDisableAfterSeconds(默认 10 分钟)未恢复时自动禁用"},
{Key: "channel_routing_policy", Type: SettingTypeJSON, Default: "", Category: "channel",
+39 -18
View File
@@ -2,19 +2,45 @@ package monitor
import (
"math"
"reflect"
"strings"
"testing"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/logic/common"
)
// 断言 list 中某模型的字段(浮点用容差比较,派生指标均四舍五入到 2 位小数)。
func assertPerfField(t *testing.T, list []map[string]any, model, field string, wantFloat float64, wantInt int64) {
t.Helper()
for _, item := range list {
if item["model_name"] != model {
// perfFieldValue 按 JSON 字段名(snake_case)从结构体取值,整数统一归一为 int64、
// 浮点统一为 float64,与旧的 []map[string]any 断言口径保持一致。
func perfFieldValue(item v1.ModelPerformanceSummary, field string) any {
rv := reflect.ValueOf(item)
rt := rv.Type()
for i := 0; i < rt.NumField(); i++ {
name, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
if name != field {
continue
}
switch v := item[field].(type) {
fv := rv.Field(i)
switch fv.Kind() {
case reflect.Int, reflect.Int64:
return fv.Int()
case reflect.Float64:
return fv.Float()
default:
return fv.Interface()
}
}
return nil
}
// 断言 list 中某模型的字段(浮点用容差比较,派生指标均四舍五入到 2 位小数)。
func assertPerfField(t *testing.T, list []v1.ModelPerformanceSummary, model, field string, wantFloat float64, wantInt int64) {
t.Helper()
for _, item := range list {
if item.ModelName != model {
continue
}
switch v := perfFieldValue(item, field).(type) {
case int64:
if v != wantInt {
t.Errorf("%s.%s = %d, want %d", model, field, v, wantInt)
@@ -25,11 +51,6 @@ func assertPerfField(t *testing.T, list []map[string]any, model, field string, w
t.Errorf("%s.%s = %v, want %v", model, field, v, wantFloat)
}
return
case string:
if v != model {
t.Errorf("%s.%s = %q", model, field, v)
}
return
}
}
t.Errorf("model %s not found in list or field %s type unexpected", model, field)
@@ -199,7 +220,7 @@ func TestBuildModelPerformanceList_FilterUnknownAndEmpty(t *testing.T) {
"skip-zero": {Req: 0, Lat: 999}, // 当日请求数为 0 不并入
}
list := buildModelPerformanceList(rows, today)
if len(list) != 1 || list[0]["model_name"] != "gpt-4o" {
if len(list) != 1 || list[0].ModelName != "gpt-4o" {
t.Fatalf("expected only gpt-4o, got %v", list)
}
assertPerfField(t, list, "gpt-4o", "request_count", 0, 10)
@@ -212,11 +233,11 @@ func TestBuildModelPerformanceList_Empty(t *testing.T) {
}
}
func assertOrder(t *testing.T, list []map[string]any, first, second string) bool {
func assertOrder(t *testing.T, list []v1.ModelPerformanceSummary, first, second string) bool {
t.Helper()
pos := map[string]int{}
for i, item := range list {
pos[item["model_name"].(string)] = i
pos[item.ModelName] = i
}
if pos[first] > pos[second] {
t.Errorf("expected %s before %s, got positions %v", first, second, pos)
@@ -225,12 +246,12 @@ func assertOrder(t *testing.T, list []map[string]any, first, second string) bool
return true
}
func assertGrade(t *testing.T, list []map[string]any, model, want string) {
func assertGrade(t *testing.T, list []v1.ModelPerformanceSummary, model, want string) {
t.Helper()
for _, item := range list {
if item["model_name"] == model {
if g, ok := item["grade"].(string); !ok || g != want {
t.Errorf("%s.grade = %v, want %s", model, item["grade"], want)
if item.ModelName == model {
if item.Grade != want {
t.Errorf("%s.grade = %v, want %s", model, item.Grade, want)
}
return
}
+93
View File
@@ -0,0 +1,93 @@
package relay
import (
"context"
"encoding/json"
relaycommon "github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/model/do"
)
// InitDebugLogRecorder 注入渠道调试日志提交钩子(进程启动时调用一次)。
// relay 层通过包级钩子解耦对 internal/logic 的依赖(仿 billing webhook 钩子模式)。
func InitDebugLogRecorder() {
relaycommon.SubmitDebugLog = submitDebugLog
}
// submitDebugLog 将四段报文记录映射为 chn_debug_logs 行并投递批量写入器。
// body 经 EncodeBody 处理二进制(base64),headers 序列化为 JSON 串写入 JSONB 列。
func submitDebugLog(_ context.Context, record *relaycommon.DebugLogRecord) {
if record == nil || common.DefaultChannelDebugLogWriter == nil {
return
}
clientReqBody, clientReqEnc := relaycommon.EncodeBody(record.ClientReqBody)
upstreamReqBody, upstreamReqEnc := relaycommon.EncodeBody(record.UpstreamReqBody)
upstreamRespBody, upstreamRespEnc := relaycommon.EncodeBody(record.UpstreamRespBody)
clientRespBody, clientRespEnc := relaycommon.EncodeBody(record.ClientRespBody)
insert := do.ChnDebugLogs{
ChannelId: record.ChannelID,
ChannelName: record.ChannelName,
ChannelType: record.ChannelType,
RequestId: record.RequestID,
TenantId: record.TenantID,
UserId: record.UserID,
ApiKeyId: record.ApiKeyID,
ModelName: record.ModelName,
UpstreamModel: record.UpstreamModel,
RelayMode: record.RelayMode,
InboundPath: record.InboundPath,
UpstreamUrl: record.UpstreamURL,
IsStream: record.IsStream,
RetryIndex: record.RetryIndex,
IsFinal: record.IsFinal,
ClientStatusCode: record.ClientStatusCode,
Error: record.Error,
ClientReqHeaders: headersJSON(record.ClientReqHeaders),
ClientReqBody: clientReqBody,
ClientReqEncoding: clientReqEnc,
UpstreamReqHeaders: headersJSON(record.UpstreamReqHeaders),
UpstreamReqBody: upstreamReqBody,
UpstreamReqEncoding: upstreamReqEnc,
UpstreamRespHeaders: headersJSON(record.UpstreamRespHeaders),
UpstreamRespBody: upstreamRespBody,
UpstreamRespEncoding: upstreamRespEnc,
ClientRespHeaders: headersJSON(record.ClientRespHeaders),
ClientRespBody: clientRespBody,
ClientRespEncoding: clientRespEnc,
UpstreamLatencyMs: record.UpstreamLatencyMs,
TotalLatencyMs: record.TotalLatencyMs,
FirstTokenMs: record.FirstTokenMs,
ClientReqBytes: int64(len(record.ClientReqBody)),
UpstreamReqBytes: int64(len(record.UpstreamReqBody)),
UpstreamRespBytes: int64(len(record.UpstreamRespBody)),
ClientRespBytes: int64(len(record.ClientRespBody)),
}
// 0 表示未发起请求/连接失败,落库为 NULL 以区分真实状态码 0 之外的语义
if record.UpstreamStatusCode > 0 {
insert.UpstreamStatusCode = record.UpstreamStatusCode
}
// 协议转换信息 → JSONB
if record.Conversion != nil {
if b, err := json.Marshal(record.Conversion); err == nil {
insert.Conversion = string(b)
}
}
common.DefaultChannelDebugLogWriter.Submit(insert)
}
// headersJSON headers map 序列化为 JSON 串(JSONB 列);nil 时写 "null"(与审计日志同口径)
func headersJSON(h map[string]string) any {
if h == nil {
return "null"
}
b, err := json.Marshal(h)
if err != nil {
return "null"
}
return string(b)
}
+4 -4
View File
@@ -219,7 +219,7 @@ func handleUnsettledTasks(ctx context.Context) {
taskBilling := billing.NewTaskBillingProvider()
settleResult, err := taskBilling.SettleTaskSuccess(ctx, t.TenantID, t.UserID, t.ApiKeyID, t.ChannelID,
t.ModelName, t.RequestID, actualCost, t.PreDeductAmount,
0, 0, pd.BillingContext.Ratios, t.PublicTaskID)
0, 0, pd.BillingContext.Ratios, t.PublicTaskID, t.CreatedAt)
if err != nil {
g.Log().Warningf(ctx, "poll: retry settle task %s: %v", t.PublicTaskID, err)
} else {
@@ -263,7 +263,7 @@ func handleUnsettledSyncImage(ctx context.Context, t *common.AsyncTask) {
actualCost = t.PreDeductAmount
}
settleResult, err := taskBilling.SettleTaskSuccess(ctx, t.TenantID, t.UserID, t.ApiKeyID, t.ChannelID,
t.ModelName, t.RequestID, actualCost, t.PreDeductAmount, 0, 0, pd.BillingContext.Ratios, t.PublicTaskID)
t.ModelName, t.RequestID, actualCost, t.PreDeductAmount, 0, 0, pd.BillingContext.Ratios, t.PublicTaskID, t.CreatedAt)
if err != nil {
g.Log().Warningf(ctx, "poll: retry settle sync_image task %s: %v", t.PublicTaskID, err)
return
@@ -422,13 +422,13 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
actualCost = billing.NewFromFloat(taskInfo.ActualCost)
} else if taskInfo.TotalTokens > 0 && pd.BillingContext.Ratios != nil {
// 用上游 total_tokens + 保存的 ratios 重算
if tokenCost, err := taskBilling.RecalculateByTokens(ctx, task.TenantID, task.ModelName, taskInfo.TotalTokens, pd.BillingContext.Ratios); err == nil && tokenCost.GreaterThan(billing.Zero) {
if tokenCost, err := taskBilling.RecalculateByTokens(ctx, task.TenantID, task.ModelName, taskInfo.TotalTokens, pd.BillingContext.Ratios, task.CreatedAt); err == nil && tokenCost.GreaterThan(billing.Zero) {
actualCost = tokenCost
}
}
task.ActualCost = actualCost
settleResult, err = taskBilling.SettleTaskSuccess(ctx, task.TenantID, task.UserID, task.ApiKeyID, task.ChannelID, task.ModelName, task.RequestID, actualCost, task.PreDeductAmount, taskInfo.TotalTokens, taskInfo.CompletionTokens, pd.BillingContext.Ratios, task.PublicTaskID)
settleResult, err = taskBilling.SettleTaskSuccess(ctx, task.TenantID, task.UserID, task.ApiKeyID, task.ChannelID, task.ModelName, task.RequestID, actualCost, task.PreDeductAmount, taskInfo.TotalTokens, taskInfo.CompletionTokens, pd.BillingContext.Ratios, task.PublicTaskID, task.CreatedAt)
if err != nil {
g.Log().Warningf(ctx, "poll: settle task %s: %v", task.PublicTaskID, err)
} else {
+2 -59
View File
@@ -8,13 +8,11 @@ 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/admin"
"github.com/qianfree/team-api/internal/logic/common"
do "github.com/qianfree/team-api/internal/model/do"
)
// AutoTestChannels 自动测试所有活跃渠道,并尝试恢复自动禁用的渠道
// AutoTestChannels 自动测试所有活跃渠道(禁用渠道不自动探测,
// 由管理员手动测试确认恢复后再启用,避免未经确认的流量进入)
func AutoTestChannels(ctx context.Context) {
type channelInfo struct {
ID int64 `json:"id"`
@@ -59,59 +57,4 @@ func AutoTestChannels(ctx context.Context) {
}
}
}
// 2. 尝试恢复自动禁用的渠道(受独立开关控制)
if common.Config().GetBool(ctx, "channel_auto_test_recovery_enabled") {
testAndRecoverDisabledChannels(ctx)
}
}
// testAndRecoverDisabledChannels 测试自动禁用的渠道,测试通过则恢复
func testAndRecoverDisabledChannels(ctx context.Context) int {
var disabledChannels []struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
err := dao.ChnChannels.Ctx(ctx).
Where("status", "disabled").
Where("auto_disabled", 1).
Where("test_model != ?", "").
Fields("id, name").
Scan(&disabledChannels)
if err != nil || len(disabledChannels) == 0 {
return 0
}
recovered := 0
for _, ch := range disabledChannels {
testCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
result, err := admin.New().TestChannel(testCtx, &v1.ChannelTestReq{ID: ch.ID})
cancel()
if err != nil || !result.Success {
g.Log().Debugf(ctx, "[Cron] auto-disabled channel %s (%d) recovery test failed", ch.Name, ch.ID)
continue
}
// 恢复渠道
_, err = dao.ChnChannels.Ctx(ctx).
Where("id", ch.ID).
Data(do.ChnChannels{
Status: "active",
AutoDisabled: 0,
}).Update()
if err != nil {
g.Log().Errorf(ctx, "[Cron] recover channel %s (%d) failed: %v", ch.Name, ch.ID, err)
continue
}
// 复位调度熔断并开启爬坡窗口 + 跨实例目录失效
dispatchadapter.MarkChannelRecovered(ctx, ch.ID)
dispatchadapter.PublishInvalidate(ctx)
recovered++
g.Log().Infof(ctx, "[Cron] auto-disabled channel %s (%d) recovered", ch.Name, ch.ID)
}
return recovered
}
+4 -3
View File
@@ -593,7 +593,7 @@ func settleSyncImageSuccess(ctx context.Context, job *SyncImageJob, sel *common.
actualCost := job.PreDeductAmount
// 上游返回了 token 用量 → 按真实 token 重算;仅 token 计费模型返回 >0,按次模型返回 0 保持预扣。
if totalTokens > 0 {
if tokenCost, rerr := syncImageBilling.RecalculateByTokens(ctx, job.TenantID, job.Model, totalTokens, job.Ratios); rerr == nil && tokenCost.GreaterThan(billing.Zero) {
if tokenCost, rerr := syncImageBilling.RecalculateByTokens(ctx, job.TenantID, job.Model, totalTokens, job.Ratios, job.SubmitTime); rerr == nil && tokenCost.GreaterThan(billing.Zero) {
actualCost = tokenCost
}
}
@@ -612,9 +612,10 @@ func settleSyncImageSuccess(ctx context.Context, job *SyncImageJob, sel *common.
return
}
// 2. 结算钱包(传真实 token 用量:驱动 bil_records.output_tokens 与计费快照的 token 明细)
// 2. 结算钱包(传真实 token 用量:驱动 bil_records.output_tokens 与计费快照的 token 明细;
// billAt=提交时刻,时段定价按受理时刻计价)
settleResult, serr := syncImageBilling.SettleTaskSuccess(ctx, job.TenantID, job.UserID, job.ApiKeyID, sel.ChannelID,
job.Model, job.RequestID, actualCost, job.PreDeductAmount, totalTokens, completionTokens, job.Ratios, job.PublicTaskID)
job.Model, job.RequestID, actualCost, job.PreDeductAmount, totalTokens, completionTokens, job.Ratios, job.PublicTaskID, job.SubmitTime)
if serr != nil {
// 保留 billing_settled=false,由未结算兜底网重放结算
g.Log().Warningf(ctx, "sync_image: task %s settle success failed (unsettled net will retry): %v", job.PublicTaskID, serr)
+61 -15
View File
@@ -2,6 +2,7 @@ package tenant
import (
"context"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/frame/g"
@@ -13,6 +14,22 @@ import (
"github.com/qianfree/team-api/internal/model/entity"
)
// marketplacePricingRow 定价锚点行(min_tokens=0):全部计费模式的基础价载体
// (token 默认价 / tiered 首档价 / per_request 单价),另带时段定价与对外展示字段。
// 显式列名扫描进独立结构体,与 do/entity 生成物解耦(新展示列需迁移落库 + gf gen dao 后才进生成物)。
type marketplacePricingRow struct {
ModelID int64 `json:"model_id"`
BillingMode string `json:"billing_mode"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
CacheReadPrice float64 `json:"cache_read_price"`
CacheCreationPrice float64 `json:"cache_creation_price"`
TimeSegments string `json:"time_segments"`
DiscountLabel *string `json:"discount_label"`
PriceChangeNote *string `json:"price_change_note"`
}
// GetModelList 获取模型广场列表(从默认模型分组加载)
func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq) (*v1.MarketplaceListRes, error) {
// 1. 查找默认模型分组
@@ -84,19 +101,19 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq)
modelIds[i] = model.Id
}
var pricings []*entity.MdlPricing
var pricings []marketplacePricingRow
err = dao.MdlPricing.Ctx(ctx).
WhereIn("model_id", modelIds).
Where("billing_mode", "token"). // 只取 token 计费模式的基础价格
Where("min_tokens", 0). // 锚点行承载全部计费模式的基础价与展示字段
Scan(&pricings)
if err != nil {
return nil, err
}
// 构建价格映射(model_id -> pricing)
priceMap := make(map[int64]*entity.MdlPricing)
for _, pricing := range pricings {
priceMap[pricing.ModelId] = pricing
priceMap := make(map[int64]*marketplacePricingRow, len(pricings))
for i := range pricings {
priceMap[pricings[i].ModelID] = &pricings[i]
}
// 6. 转换为响应格式
@@ -145,11 +162,11 @@ func (s *sTenant) GetModelDetail(ctx context.Context, req *v1.MarketplaceDetailR
return nil, fmt.Errorf("模型不存在或未公开")
}
// 3. 查询价格
var pricing *entity.MdlPricing
// 3. 查询价格(锚点行:全部计费模式的基础价 + 时段/展示字段)
var pricing *marketplacePricingRow
err = dao.MdlPricing.Ctx(ctx).
Where("model_id", model.Id).
Where("billing_mode", "token").
Where("min_tokens", 0).
Scan(&pricing)
if err != nil {
return nil, err
@@ -164,7 +181,7 @@ func (s *sTenant) GetModelDetail(ctx context.Context, req *v1.MarketplaceDetailR
}
// convertToMarketplaceItem 转换模型实体为响应格式
func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.MdlModels, pricing *entity.MdlPricing) v1.MarketplaceModelItem {
func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.MdlModels, pricing *marketplacePricingRow) v1.MarketplaceModelItem {
item := v1.MarketplaceModelItem{
ModelId: model.ModelId,
ModelName: model.ModelName,
@@ -189,12 +206,41 @@ func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.Md
}
}
// 价格(数据库已存储每百万 token 的美元价格,直接返回)
if pricing != nil {
// input_price/output_price 数据库字段存储的就是每 1M token 价格(USD)
// 无需转换,直接使用
item.InputPrice = billing.InexactFloat64(pricing.InputPrice)
item.OutputPrice = billing.InexactFloat64(pricing.OutputPrice)
if pricing == nil {
return item
}
billingMode := pricing.BillingMode
if billingMode == "" {
billingMode = "token"
}
item.BillingMode = billingMode
// 锚点行价即对外展示价(数据库存的就是每 1M token 美元价,直接返回):
// token=默认价,tiered=首档价(前端标「起」),per_request=按次单价
item.InputPrice = pricing.InputPrice
item.OutputPrice = pricing.OutputPrice
item.CacheReadPrice = pricing.CacheReadPrice
item.CacheCreationPrice = pricing.CacheCreationPrice
if pricing.PerRequestPrice != nil {
item.PerRequestPrice = *pricing.PerRequestPrice
}
if pricing.DiscountLabel != nil {
item.DiscountLabel = *pricing.DiscountLabel
}
if pricing.PriceChangeNote != nil {
item.PriceChangeNote = *pricing.PriceChangeNote
}
// 时段价目:平台基础价 × 时段乘数换算(tiered 用锚点首档价换算,与 buildTiers 首档口径一致)
if pricing.TimeSegments != "" && pricing.TimeSegments != "null" {
var segments []billing.TimeSegment
if err := json.Unmarshal([]byte(pricing.TimeSegments), &segments); err == nil && len(segments) > 0 {
var tiers []v1.PricingTierItem
if billingMode == "tiered" {
tiers = append(tiers, v1.PricingTierItem{InputPrice: pricing.InputPrice, OutputPrice: pricing.OutputPrice})
}
item.TimePrices = buildTimePrices(segments, billingMode, &pricing.InputPrice, &pricing.OutputPrice, pricing.PerRequestPrice, tiers)
}
}
return item
+117 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
v1 "github.com/qianfree/team-api/api/tenant/v1"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/billing"
lcommon "github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/middleware"
)
@@ -36,6 +37,8 @@ type tenantModelPriceRow struct {
BaseCacheCreationPrice float64 `json:"base_cache_creation_price"`
BaseBillingMode string `json:"base_billing_mode"`
BasePerRequestPrice *float64 `json:"base_per_request_price"`
BaseDiscountLabel *string `json:"base_discount_label"`
BasePriceChangeNote *string `json:"base_price_change_note"`
CustomInputPrice *float64 `json:"custom_input_price"`
CustomOutputPrice *float64 `json:"custom_output_price"`
CustomCacheReadPrice *float64 `json:"custom_cache_read_price"`
@@ -52,6 +55,8 @@ type groupPriceRow struct {
BaseCacheReadPrice float64 `json:"base_cache_read_price"`
BaseCacheCreationPrice float64 `json:"base_cache_creation_price"`
BasePerRequestPrice *float64 `json:"base_per_request_price"`
BaseDiscountLabel *string `json:"base_discount_label"`
BasePriceChangeNote *string `json:"base_price_change_note"`
}
// baseTierRow 阶梯定价查询结果
@@ -82,6 +87,8 @@ type priceInfo struct {
BaseCacheCreationPrice float64
BaseBillingMode string
BasePerRequestPrice *float64
BaseDiscountLabel *string
BasePriceChangeNote *string
CustomInputPrice *float64
CustomOutputPrice *float64
CustomCacheReadPrice *float64
@@ -97,6 +104,8 @@ type groupPriceInfo struct {
BaseCacheReadPrice float64
BaseCacheCreationPrice float64
BasePerRequestPrice *float64
BaseDiscountLabel *string
BasePriceChangeNote *string
}
// ListAvailableModels 获取租户可用的模型列表
@@ -127,7 +136,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
LeftJoin("mdl_pricing p ON p.model_id = mdl_tenant_models.model_id AND p.min_tokens = 0").
Where("mdl_tenant_models.tenant_id", tenantID).
WhereIn("mdl_tenant_models.model_id", explicitDBIDs).
Fields("mdl_tenant_models.model_id AS model_db_id, mdl_tenant_models.id, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.input_price AS base_input_price, p.output_price AS base_output_price, p.cache_read_price AS base_cache_read_price, p.cache_creation_price AS base_cache_creation_price, p.billing_mode AS base_billing_mode, p.per_request_price AS base_per_request_price, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers").
Fields("mdl_tenant_models.model_id AS model_db_id, mdl_tenant_models.id, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.input_price AS base_input_price, p.output_price AS base_output_price, p.cache_read_price AS base_cache_read_price, p.cache_creation_price AS base_cache_creation_price, p.billing_mode AS base_billing_mode, p.per_request_price AS base_per_request_price, p.discount_label AS base_discount_label, p.price_change_note AS base_price_change_note, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers").
Scan(&priceResults)
if err != nil {
return nil, err
@@ -149,6 +158,8 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
BaseCacheCreationPrice: r.BaseCacheCreationPrice,
BaseBillingMode: r.BaseBillingMode,
BasePerRequestPrice: r.BasePerRequestPrice,
BaseDiscountLabel: r.BaseDiscountLabel,
BasePriceChangeNote: r.BasePriceChangeNote,
CustomInputPrice: r.CustomInputPrice,
CustomOutputPrice: r.CustomOutputPrice,
CustomCacheReadPrice: r.CustomCacheReadPrice,
@@ -171,7 +182,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
err = dao.MdlPricing.Ctx(ctx).
WhereIn("model_id", groupDBIDs).
Where("min_tokens", 0).
Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price, cache_read_price AS base_cache_read_price, cache_creation_price AS base_cache_creation_price, per_request_price AS base_per_request_price").
Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price, cache_read_price AS base_cache_read_price, cache_creation_price AS base_cache_creation_price, per_request_price AS base_per_request_price, discount_label AS base_discount_label, price_change_note AS base_price_change_note").
Scan(&groupPrices)
if err != nil {
return nil, err
@@ -185,6 +196,8 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
BaseCacheReadPrice: gp.BaseCacheReadPrice,
BaseCacheCreationPrice: gp.BaseCacheCreationPrice,
BasePerRequestPrice: gp.BasePerRequestPrice,
BaseDiscountLabel: gp.BaseDiscountLabel,
BasePriceChangeNote: gp.BasePriceChangeNote,
}
}
}
@@ -232,6 +245,36 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
}
}
// 时段定价批量查询(锚点行 JSONB;显式与分组来源模型统一处理,展示换算价目用)
allDBIDs := make([]int64, 0, len(models))
for _, m := range models {
allDBIDs = append(allDBIDs, m.ModelDBID)
}
timeSegmentsMap := make(map[int64][]billing.TimeSegment)
if len(allDBIDs) > 0 {
var segRows []struct {
ModelId int64 `json:"model_id"`
TimeSegments string `json:"time_segments"`
}
if err := dao.MdlPricing.Ctx(ctx).
WhereIn("model_id", allDBIDs).
Where("min_tokens", 0).
WhereNotNull("time_segments").
Fields("model_id, time_segments").
Scan(&segRows); err != nil {
return nil, err
}
for _, r := range segRows {
if r.TimeSegments == "" || r.TimeSegments == "null" {
continue
}
var segs []billing.TimeSegment
if err := json.Unmarshal([]byte(r.TimeSegments), &segs); err == nil && len(segs) > 0 {
timeSegmentsMap[r.ModelId] = segs
}
}
}
// 组装最终列表
list := make([]v1.TenantAvailableModelItem, 0, len(models))
for _, m := range models {
@@ -270,18 +313,24 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
OutputPrice: outputPrice,
CacheReadPrice: cacheReadPrice,
CacheCreationPrice: cacheCreationPrice,
DiscountLabel: pi.BaseDiscountLabel,
PriceChangeNote: pi.BasePriceChangeNote,
}
if effectiveBillingMode == "tiered" {
item.PricingTiers = buildTiers(pi.CustomPricingTiers, pi.BaseInputPrice, pi.BaseOutputPrice, baseTiersMap[m.ModelDBID])
}
item.TimePrices = buildTimePrices(timeSegmentsMap[m.ModelDBID], effectiveBillingMode,
inputPrice, outputPrice, perRequestPrice, item.PricingTiers)
list = append(list, item)
} else {
// group 来源的模型:使用 base 定价,与显式模型展示一致(含按次单价 / 缓存价 / 阶梯明细)
billingMode := "token"
var inputPrice, outputPrice, cacheReadPrice, cacheCreationPrice, perRequestPrice *float64
var baseInputPrice, baseOutputPrice float64
var discountLabel, priceChangeNote *string
gp, ok := groupPriceMap[m.ModelDBID]
if ok {
billingMode = gp.BaseBillingMode
@@ -295,6 +344,8 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
cacheReadPrice = effectivePrice(nil, gp.BaseCacheReadPrice)
cacheCreationPrice = effectivePrice(nil, gp.BaseCacheCreationPrice)
perRequestPrice = gp.BasePerRequestPrice
discountLabel = gp.BaseDiscountLabel
priceChangeNote = gp.BasePriceChangeNote
}
item := v1.TenantAvailableModelItem{
@@ -313,10 +364,14 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
OutputPrice: outputPrice,
CacheReadPrice: cacheReadPrice,
CacheCreationPrice: cacheCreationPrice,
DiscountLabel: discountLabel,
PriceChangeNote: priceChangeNote,
}
if billingMode == "tiered" && ok {
item.PricingTiers = buildTiers("", baseInputPrice, baseOutputPrice, baseTiersMap[m.ModelDBID])
}
item.TimePrices = buildTimePrices(timeSegmentsMap[m.ModelDBID], billingMode,
inputPrice, outputPrice, perRequestPrice, item.PricingTiers)
list = append(list, item)
}
}
@@ -488,3 +543,63 @@ func buildTiers(customTiersJSON string, baseInputPrice, baseOutputPrice float64,
}
return tiers
}
// buildTimePrices 构建时段展示价目:每个时段 = 模型当前有效价 × 时段乘数(后端换算,前端直接渲染)。
// token 模式换算输入/输出价;per_request 换算按次价;tiered 用首档价换算(起价,前端标注「起」)。
func buildTimePrices(segments []billing.TimeSegment, billingMode string,
inputPrice, outputPrice, perRequestPrice *float64, tiers []v1.PricingTierItem) []v1.TimePriceItem {
if len(segments) == 0 {
return nil
}
// tiered 模式无阶梯数据时无法换算,不展示时段价目(防御)
var tierInput, tierOutput *float64
if billingMode == "tiered" {
if len(tiers) == 0 {
return nil
}
tierInput = &tiers[0].InputPrice
tierOutput = &tiers[0].OutputPrice
}
result := make([]v1.TimePriceItem, 0, len(segments))
for _, seg := range segments {
tp := v1.TimePriceItem{
Name: seg.Name,
Days: seg.Days,
StartTime: seg.StartTime,
EndTime: seg.EndTime,
ValidFrom: seg.ValidFrom,
ValidTo: seg.ValidTo,
Multiplier: seg.Multiplier,
}
switch billingMode {
case "per_request":
if perRequestPrice != nil {
tp.PerRequestPrice = mulDisplayPrice(*perRequestPrice, seg.Multiplier)
}
case "tiered":
if tierInput != nil {
tp.InputPrice = mulDisplayPrice(*tierInput, seg.Multiplier)
}
if tierOutput != nil {
tp.OutputPrice = mulDisplayPrice(*tierOutput, seg.Multiplier)
}
default: // token
if inputPrice != nil {
tp.InputPrice = mulDisplayPrice(*inputPrice, seg.Multiplier)
}
if outputPrice != nil {
tp.OutputPrice = mulDisplayPrice(*outputPrice, seg.Multiplier)
}
}
result = append(result, tp)
}
return result
}
// mulDisplayPrice 展示价换算:单价 × 时段乘数(decimal 单步乘法,出口转 float64 供前端渲染)
func mulDisplayPrice(price, multiplier float64) *float64 {
v := billing.InexactFloat64(billing.NewFromFloat(price).Mul(billing.NewFromFloat(multiplier)))
return &v
}
@@ -119,6 +119,13 @@ func startMockReleaseServer(t *testing.T, version string) (*httptest.Server, str
// (CheckForUpdate → stageUpdate:下载→校验→备份→替换→写标记),
// 验证整个更新链路的磁盘产物,无需 GitHub / PostgreSQL / Redis。
func TestUpdatePipeline_Local(t *testing.T) {
// 自更新流水线面向 Linux 部署:getPlatformAssetName 在 Windows 期望 .zip 资产,
// 而 extractBinary 仅支持 tar.gz,且运行中的 exe 被锁定无法 rename 替换。
// Windows 开发机跳过,流水线在 Linux CI 上完整验证。
if runtime.GOOS == "windows" {
t.Skip("self-update pipeline targets Linux deployments (tar.gz asset + rename-based replace)")
}
ctx := gctx.New()
version := "0.9.0" // 模拟的"新版本",必须高于当前版本才判定有更新
+2 -2
View File
@@ -20,7 +20,7 @@ type BilUsageDaily struct {
ChannelId any // 渠道ID(0=无渠道)
Status any // 请求状态:success/error/timeout/cancelled
RequestCount any // 请求数(COUNT(*))
InputTokens any // 输入Token合计
InputTokens any // 输入Token合计(含缓存总输入,源自 bil_usage_logs.input_tokens)
OutputTokens any // 输出Token合计
TotalCost any // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost)
AccountCost any // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)
@@ -28,7 +28,7 @@ type BilUsageDaily struct {
SumFirstTokenMs any // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
CacheCreationTokens any // 缓存创建 token 数(Claude cache_creation,SUM 聚合)
CacheCreationTokens any // 写入缓存的 token 数(Claude cache_creation / OpenAI cache_write,SUM 聚合)
CacheReadTokens any // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)
CacheHitRequestCount any // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)
}
+2 -2
View File
@@ -20,7 +20,7 @@ type BilUsageLogs struct {
ModelName any // 调用的模型名
RequestId any // 请求唯一ID
RelayMode any // 代理模式:chat_completions / embeddings / images_generations 等
InputTokens any // 输入 token 数
InputTokens any // 输入 token 数(含缓存总输入:base + cache_read + cache_creation,跨渠道统一口径)
OutputTokens any // 输出 token 数
TotalCost any // 本次调用费用
Currency any // 货币(USD)
@@ -30,7 +30,7 @@ type BilUsageLogs struct {
ClientIp any // 客户端 IP
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
CacheCreationTokens any // 缓存创建 token 数 (Claude)
CacheCreationTokens any // 写入缓存的 token 数(Claude cache_creation_input_tokens / OpenAI Responses cache_write_tokens)
CacheReadTokens any // 缓存读取 token 数 (Claude/OpenAI)
InputCost any // 输入 token 费用
OutputCost any // 输出 token 费用
+55
View File
@@ -0,0 +1,55 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// ChnDebugLogs is the golang structure of table chn_debug_logs for DAO operations like Where/Data.
type ChnDebugLogs struct {
g.Meta `orm:"table:chn_debug_logs, do:true"`
Id any // 主键ID
ChannelId any // 渠道ID(逻辑关联 chn_channels.id,无外键)
ChannelName any // 渠道名称(冗余存储,渠道删除后仍可辨识)
ChannelType any // 渠道类型(ProviderType 枚举值)
RequestId any // 请求唯一ID(同一请求多次重试共享)
TenantId any // 租户ID
UserId any // 用户ID
ApiKeyId any // API Key ID
ModelName any // 用户请求的模型名
UpstreamModel any // 上游实际使用的模型名(模型映射后)
RelayMode any // 转发模式(chat_completions/claude_messages/embeddings 等)
InboundPath any // 入站端点路径(如 /v1/chat/completions)
UpstreamUrl any // 上游请求 URL(query 中的凭证参数已脱敏)
IsStream any // 是否流式请求
RetryIndex any // 重试轮次(0=首次尝试)
IsFinal any // 是否为产生客户端响应的最终尝试(成功/终止/流中断)
UpstreamStatusCode any // 上游 HTTP 状态码(未发起请求或连接失败为 NULL)
ClientStatusCode any // 返回客户端的状态码
Error any // 本尝试的错误信息(成功为空)
ClientReqHeaders any // 段1 客户端请求头(凭证类头脱敏:前6后4)JSON
ClientReqBody any // 段1 客户端请求体(完整不截断;二进制为 base64,见 encoding 列)
ClientReqEncoding any // 段1 编码:plain / base64
UpstreamReqHeaders any // 段2 发往上游的最终请求头(协议转换+override 后,凭证类脱敏)JSON
UpstreamReqBody any // 段2 发往上游的请求体(实际发送字节,含协议转换;二进制为 base64)
UpstreamReqEncoding any // 段2 编码:plain / base64
UpstreamRespHeaders any // 段3 上游响应头 JSON(Content-Encoding 可能已被 net/http 透明解压移除)
UpstreamRespBody any // 段3 上游响应体(Go 透明解压后的字节;流式为 SSE 原文;二进制为 base64)
UpstreamRespEncoding any // 段3 编码:plain / base64
ClientRespHeaders any // 段4 返回客户端的响应头 JSON
ClientRespBody any // 段4 返回客户端的响应体(协议转换后,完整不截断;二进制为 base64)
ClientRespEncoding any // 段4 编码:plain / base64
UpstreamLatencyMs any // 上游往返耗时(RoundTrip 到响应头)毫秒
TotalLatencyMs any // 请求总耗时毫秒
FirstTokenMs any // 首字节延迟毫秒
ClientReqBytes any // 段1 请求体原始字节数(base64 落库膨胀前的真实大小)
UpstreamReqBytes any // 段2 请求体原始字节数
UpstreamRespBytes any // 段3 响应体原始字节数
ClientRespBytes any // 段4 响应体原始字节数
CreatedAt *gtime.Time // 创建时间
Conversion any // 协议转换信息 JSON:client_format 客户端协议 / upstream_format 上游协议 / chain 请求侧转换链 / bridge 桥接方式(responses_api|responses_direct|pass_through,空=常规转换)
}
+4
View File
@@ -25,4 +25,8 @@ type MdlPricing struct {
UpdatedAt *gtime.Time //
CacheReadPrice any // 缓存读取每 1M token 价格(直接定价)
CacheCreationPrice any // 缓存创建每 1M token 价格(直接定价)
TimeSegments any // 时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{"name":"闲时","days":[1,2,3,4,5],"start_time":"00:00","end_time":"08:00","valid_from":"","valid_to":"","multiplier":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点
PriceNote any // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
DiscountLabel any // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
PriceChangeNote any // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
}
+2 -2
View File
@@ -19,7 +19,7 @@ type BilUsageDaily struct {
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"渠道ID(0=无渠道)"` // 渠道ID(0=无渠道)
Status string `json:"status" orm:"status" description:"请求状态:success/error/timeout/cancelled"` // 请求状态:success/error/timeout/cancelled
RequestCount int `json:"request_count" orm:"request_count" description:"请求数(COUNT(*))"` // 请求数(COUNT(*))
InputTokens int64 `json:"input_tokens" orm:"input_tokens" description:"输入Token合计"` // 输入Token合计
InputTokens int64 `json:"input_tokens" orm:"input_tokens" description:"输入Token合计(含缓存总输入,源自 bil_usage_logs.input_tokens)"` // 输入Token合计(含缓存总输入,源自 bil_usage_logs.input_tokens)
OutputTokens int64 `json:"output_tokens" orm:"output_tokens" description:"输出Token合计"` // 输出Token合计
TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"客户侧成本合计(USD,源自 bil_usage_logs.total_cost)"` // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost)
AccountCost decimal.Decimal `json:"account_cost" orm:"account_cost" description:"上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)"` // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)
@@ -27,7 +27,7 @@ type BilUsageDaily struct {
SumFirstTokenMs int64 `json:"sum_first_token_ms" orm:"sum_first_token_ms" description:"首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)"` // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
CacheCreationTokens int64 `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数(Claude cache_creation,SUM 聚合)"` // 缓存创建 token 数(Claude cache_creation,SUM 聚合)
CacheCreationTokens int64 `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"写入缓存的 token 数(Claude cache_creation / OpenAI cache_write,SUM 聚合)"` // 写入缓存的 token 数(Claude cache_creation / OpenAI cache_write,SUM 聚合)
CacheReadTokens int64 `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)"` // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)
CacheHitRequestCount int64 `json:"cache_hit_request_count" orm:"cache_hit_request_count" description:"命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)"` // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)
}
+57 -57
View File
@@ -11,61 +11,61 @@ import (
// BilUsageLogs is the golang structure for table bil_usage_logs.
type BilUsageLogs struct {
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"使用的渠道ID"` // 使用的渠道ID
ModelName string `json:"model_name" orm:"model_name" description:"调用的模型名"` // 调用的模型名
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID"` // 请求唯一ID
RelayMode string `json:"relay_mode" orm:"relay_mode" description:"代理模式:chat_completions / embeddings / images_generations 等"` // 代理模式:chat_completions / embeddings / images_generations 等
InputTokens int `json:"input_tokens" orm:"input_tokens" description:"输入 token 数"` // 输入 token 数
OutputTokens int `json:"output_tokens" orm:"output_tokens" description:"输出 token 数"` // 输出 token 数
TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"本次调用费用"` // 本次调用费用
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
LatencyMs int `json:"latency_ms" orm:"latency_ms" description:"请求延迟(毫秒)"` // 请求延迟(毫秒)
Status string `json:"status" orm:"status" description:"状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)"` // 状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)
ErrorMessage string `json:"error_message" orm:"error_message" description:"错误信息(成功时为 NULL)"` // 错误信息(成功时为 NULL)
ClientIp string `json:"client_ip" orm:"client_ip" description:"客户端 IP"` // 客户端 IP
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
CacheCreationTokens int `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数 (Claude)"` // 缓存创建 token 数 (Claude)
CacheReadTokens int `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存读取 token 数 (Claude/OpenAI)"` // 缓存读取 token 数 (Claude/OpenAI)
InputCost decimal.Decimal `json:"input_cost" orm:"input_cost" description:"输入 token 费用"` // 输入 token 费用
OutputCost decimal.Decimal `json:"output_cost" orm:"output_cost" description:"输出 token 费用"` // 输出 token 费用
CacheCreationCost decimal.Decimal `json:"cache_creation_cost" orm:"cache_creation_cost" description:"缓存创建费用"` // 缓存创建费用
CacheReadCost decimal.Decimal `json:"cache_read_cost" orm:"cache_read_cost" description:"缓存读取费用"` // 缓存读取费用
ActualCost decimal.Decimal `json:"actual_cost" orm:"actual_cost" description:"实际扣除费用(含折扣后)"` // 实际扣除费用(含折扣后)
RequestedModel string `json:"requested_model" orm:"requested_model" description:"用户请求的模型名"` // 用户请求的模型名
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际模型名(模型映射后)"` // 上游实际模型名(模型映射后)
RequestType int `json:"request_type" orm:"request_type" description:"请求类型: 1=sync, 2=stream, 3=async, 4=websocket"` // 请求类型: 1=sync, 2=stream, 3=async, 4=websocket
UserAgent string `json:"user_agent" orm:"user_agent" description:"客户端 User-Agent"` // 客户端 User-Agent
FirstTokenMs int `json:"first_token_ms" orm:"first_token_ms" description:"首 token 延迟(毫秒)"` // 首 token 延迟(毫秒)
ServiceTier string `json:"service_tier" orm:"service_tier" description:"服务等级 (default/flex等)"` // 服务等级 (default/flex等)
ReasoningEffort string `json:"reasoning_effort" orm:"reasoning_effort" description:"推理力度 (low/medium/high)"` // 推理力度 (low/medium/high)
ChannelName string `json:"channel_name" orm:"channel_name" description:"渠道名称"` // 渠道名称
ChannelType int `json:"channel_type" orm:"channel_type" description:"渠道类型 (ProviderType)"` // 渠道类型 (ProviderType)
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式 (token/per_request/tiered)"` // 计费模式 (token/per_request/tiered)
BillingSource string `json:"billing_source" orm:"billing_source" description:"定价来源 (base/tenant/custom)"` // 定价来源 (base/tenant/custom)
RateMultiplier decimal.Decimal `json:"rate_multiplier" orm:"rate_multiplier" description:"费率倍率/折扣"` // 费率倍率/折扣
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:"预扣金额"` // 预扣金额
RefundAmount decimal.Decimal `json:"refund_amount" orm:"refund_amount" description:"退款金额"` // 退款金额
SupplementAmount decimal.Decimal `json:"supplement_amount" orm:"supplement_amount" description:"补扣金额"` // 补扣金额
ImageCount int `json:"image_count" orm:"image_count" description:"生成图片数量"` // 生成图片数量
ImageSize string `json:"image_size" orm:"image_size" description:"图片尺寸"` // 图片尺寸
StreamEndReason string `json:"stream_end_reason" orm:"stream_end_reason" description:"流结束原因 (done/timeout/client_gone/error/panic)"` // 流结束原因 (done/timeout/client_gone/error/panic)
RetryIndex int `json:"retry_index" orm:"retry_index" description:"重试次数(0=首次成功)"` // 重试次数(0=首次成功)
BillingSummary string `json:"billing_summary" orm:"billing_summary" description:"计费快照文本(人类可读的计费过程描述)"` // 计费快照文本(人类可读的计费过程描述)
CacheCreation5MTokens int `json:"cache_creation_5m_tokens" orm:"cache_creation_5m_tokens" description:"Claude 5分钟缓存创建 token 数"` // Claude 5分钟缓存创建 token 数
CacheCreation1HTokens int `json:"cache_creation_1h_tokens" orm:"cache_creation_1h_tokens" description:"Claude 1小时缓存创建 token 数"` // Claude 1小时缓存创建 token 数
AudioInputTokens int `json:"audio_input_tokens" orm:"audio_input_tokens" description:"音频输入 token 数"` // 音频输入 token 数
AudioOutputTokens int `json:"audio_output_tokens" orm:"audio_output_tokens" description:"音频输出 token 数"` // 音频输出 token 数
ImageOutputTokens int `json:"image_output_tokens" orm:"image_output_tokens" description:"图像输出 token 数(DALL-E 等)"` // 图像输出 token 数(DALL-E 等)
ReasoningTokens int `json:"reasoning_tokens" orm:"reasoning_tokens" description:"推理 token 数(O1/o3 等)"` // 推理 token 数(O1/o3 等)
AccountCost decimal.Decimal `json:"account_cost" orm:"account_cost" description:"上游账户成本(用于利润分析)"` // 上游账户成本(用于利润分析)
InboundEndpoint string `json:"inbound_endpoint" orm:"inbound_endpoint" description:"客户端请求路径(如 /v1/chat/completions)"` // 客户端请求路径(如 /v1/chat/completions)
UpstreamEndpoint string `json:"upstream_endpoint" orm:"upstream_endpoint" description:"上游实际请求路径"` // 上游实际请求路径
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(通过API Key关联,NULL表示个人密钥无项目)"` // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
TaskId string `json:"task_id" orm:"task_id" description:"异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空"` // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"使用的渠道ID"` // 使用的渠道ID
ModelName string `json:"model_name" orm:"model_name" description:"调用的模型名"` // 调用的模型名
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID"` // 请求唯一ID
RelayMode string `json:"relay_mode" orm:"relay_mode" description:"代理模式:chat_completions / embeddings / images_generations 等"` // 代理模式:chat_completions / embeddings / images_generations 等
InputTokens int `json:"input_tokens" orm:"input_tokens" description:"输入 token 数(含缓存总输入:base + cache_read + cache_creation,跨渠道统一口径)"` // 输入 token 数(含缓存总输入:base + cache_read + cache_creation,跨渠道统一口径)
OutputTokens int `json:"output_tokens" orm:"output_tokens" description:"输出 token 数"` // 输出 token 数
TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"本次调用费用"` // 本次调用费用
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
LatencyMs int `json:"latency_ms" orm:"latency_ms" description:"请求延迟(毫秒)"` // 请求延迟(毫秒)
Status string `json:"status" orm:"status" description:"状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)"` // 状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)
ErrorMessage string `json:"error_message" orm:"error_message" description:"错误信息(成功时为 NULL)"` // 错误信息(成功时为 NULL)
ClientIp string `json:"client_ip" orm:"client_ip" description:"客户端 IP"` // 客户端 IP
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
CacheCreationTokens int `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"写入缓存的 token 数(Claude cache_creation_input_tokens / OpenAI Responses cache_write_tokens)"` // 写入缓存的 token 数(Claude cache_creation_input_tokens / OpenAI Responses cache_write_tokens)
CacheReadTokens int `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存读取 token 数 (Claude/OpenAI)"` // 缓存读取 token 数 (Claude/OpenAI)
InputCost decimal.Decimal `json:"input_cost" orm:"input_cost" description:"输入 token 费用"` // 输入 token 费用
OutputCost decimal.Decimal `json:"output_cost" orm:"output_cost" description:"输出 token 费用"` // 输出 token 费用
CacheCreationCost decimal.Decimal `json:"cache_creation_cost" orm:"cache_creation_cost" description:"缓存创建费用"` // 缓存创建费用
CacheReadCost decimal.Decimal `json:"cache_read_cost" orm:"cache_read_cost" description:"缓存读取费用"` // 缓存读取费用
ActualCost decimal.Decimal `json:"actual_cost" orm:"actual_cost" description:"实际扣除费用(含折扣后)"` // 实际扣除费用(含折扣后)
RequestedModel string `json:"requested_model" orm:"requested_model" description:"用户请求的模型名"` // 用户请求的模型名
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际模型名(模型映射后)"` // 上游实际模型名(模型映射后)
RequestType int `json:"request_type" orm:"request_type" description:"请求类型: 1=sync, 2=stream, 3=async, 4=websocket"` // 请求类型: 1=sync, 2=stream, 3=async, 4=websocket
UserAgent string `json:"user_agent" orm:"user_agent" description:"客户端 User-Agent"` // 客户端 User-Agent
FirstTokenMs int `json:"first_token_ms" orm:"first_token_ms" description:"首 token 延迟(毫秒)"` // 首 token 延迟(毫秒)
ServiceTier string `json:"service_tier" orm:"service_tier" description:"服务等级 (default/flex等)"` // 服务等级 (default/flex等)
ReasoningEffort string `json:"reasoning_effort" orm:"reasoning_effort" description:"推理力度 (low/medium/high)"` // 推理力度 (low/medium/high)
ChannelName string `json:"channel_name" orm:"channel_name" description:"渠道名称"` // 渠道名称
ChannelType int `json:"channel_type" orm:"channel_type" description:"渠道类型 (ProviderType)"` // 渠道类型 (ProviderType)
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式 (token/per_request/tiered)"` // 计费模式 (token/per_request/tiered)
BillingSource string `json:"billing_source" orm:"billing_source" description:"定价来源 (base/tenant/custom)"` // 定价来源 (base/tenant/custom)
RateMultiplier decimal.Decimal `json:"rate_multiplier" orm:"rate_multiplier" description:"费率倍率/折扣"` // 费率倍率/折扣
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:"预扣金额"` // 预扣金额
RefundAmount decimal.Decimal `json:"refund_amount" orm:"refund_amount" description:"退款金额"` // 退款金额
SupplementAmount decimal.Decimal `json:"supplement_amount" orm:"supplement_amount" description:"补扣金额"` // 补扣金额
ImageCount int `json:"image_count" orm:"image_count" description:"生成图片数量"` // 生成图片数量
ImageSize string `json:"image_size" orm:"image_size" description:"图片尺寸"` // 图片尺寸
StreamEndReason string `json:"stream_end_reason" orm:"stream_end_reason" description:"流结束原因 (done/timeout/client_gone/error/panic)"` // 流结束原因 (done/timeout/client_gone/error/panic)
RetryIndex int `json:"retry_index" orm:"retry_index" description:"重试次数(0=首次成功)"` // 重试次数(0=首次成功)
BillingSummary string `json:"billing_summary" orm:"billing_summary" description:"计费快照文本(人类可读的计费过程描述)"` // 计费快照文本(人类可读的计费过程描述)
CacheCreation5MTokens int `json:"cache_creation_5m_tokens" orm:"cache_creation_5m_tokens" description:"Claude 5分钟缓存创建 token 数"` // Claude 5分钟缓存创建 token 数
CacheCreation1HTokens int `json:"cache_creation_1h_tokens" orm:"cache_creation_1h_tokens" description:"Claude 1小时缓存创建 token 数"` // Claude 1小时缓存创建 token 数
AudioInputTokens int `json:"audio_input_tokens" orm:"audio_input_tokens" description:"音频输入 token 数"` // 音频输入 token 数
AudioOutputTokens int `json:"audio_output_tokens" orm:"audio_output_tokens" description:"音频输出 token 数"` // 音频输出 token 数
ImageOutputTokens int `json:"image_output_tokens" orm:"image_output_tokens" description:"图像输出 token 数(DALL-E 等)"` // 图像输出 token 数(DALL-E 等)
ReasoningTokens int `json:"reasoning_tokens" orm:"reasoning_tokens" description:"推理 token 数(O1/o3 等)"` // 推理 token 数(O1/o3 等)
AccountCost decimal.Decimal `json:"account_cost" orm:"account_cost" description:"上游账户成本(用于利润分析)"` // 上游账户成本(用于利润分析)
InboundEndpoint string `json:"inbound_endpoint" orm:"inbound_endpoint" description:"客户端请求路径(如 /v1/chat/completions)"` // 客户端请求路径(如 /v1/chat/completions)
UpstreamEndpoint string `json:"upstream_endpoint" orm:"upstream_endpoint" description:"上游实际请求路径"` // 上游实际请求路径
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(通过API Key关联,NULL表示个人密钥无项目)"` // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
TaskId string `json:"task_id" orm:"task_id" description:"异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空"` // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
}
+53
View File
@@ -0,0 +1,53 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// ChnDebugLogs is the golang structure for table chn_debug_logs.
type ChnDebugLogs struct {
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"渠道ID(逻辑关联 chn_channels.id,无外键)"` // 渠道ID(逻辑关联 chn_channels.id,无外键)
ChannelName string `json:"channel_name" orm:"channel_name" description:"渠道名称(冗余存储,渠道删除后仍可辨识)"` // 渠道名称(冗余存储,渠道删除后仍可辨识)
ChannelType int `json:"channel_type" orm:"channel_type" description:"渠道类型(ProviderType 枚举值)"` // 渠道类型(ProviderType 枚举值)
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID(同一请求多次重试共享)"` // 请求唯一ID(同一请求多次重试共享)
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"API Key ID"` // API Key ID
ModelName string `json:"model_name" orm:"model_name" description:"用户请求的模型名"` // 用户请求的模型名
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际使用的模型名(模型映射后)"` // 上游实际使用的模型名(模型映射后)
RelayMode string `json:"relay_mode" orm:"relay_mode" description:"转发模式(chat_completions/claude_messages/embeddings 等)"` // 转发模式(chat_completions/claude_messages/embeddings 等)
InboundPath string `json:"inbound_path" orm:"inbound_path" description:"入站端点路径(如 /v1/chat/completions)"` // 入站端点路径(如 /v1/chat/completions)
UpstreamUrl string `json:"upstream_url" orm:"upstream_url" description:"上游请求 URL(query 中的凭证参数已脱敏)"` // 上游请求 URL(query 中的凭证参数已脱敏)
IsStream bool `json:"is_stream" orm:"is_stream" description:"是否流式请求"` // 是否流式请求
RetryIndex int `json:"retry_index" orm:"retry_index" description:"重试轮次(0=首次尝试)"` // 重试轮次(0=首次尝试)
IsFinal bool `json:"is_final" orm:"is_final" description:"是否为产生客户端响应的最终尝试(成功/终止/流中断)"` // 是否为产生客户端响应的最终尝试(成功/终止/流中断)
UpstreamStatusCode int `json:"upstream_status_code" orm:"upstream_status_code" description:"上游 HTTP 状态码(未发起请求或连接失败为 NULL)"` // 上游 HTTP 状态码(未发起请求或连接失败为 NULL)
ClientStatusCode int `json:"client_status_code" orm:"client_status_code" description:"返回客户端的状态码"` // 返回客户端的状态码
Error string `json:"error" orm:"error" description:"本尝试的错误信息(成功为空)"` // 本尝试的错误信息(成功为空)
ClientReqHeaders string `json:"client_req_headers" orm:"client_req_headers" description:"段1 客户端请求头(凭证类头脱敏:前6后4)JSON"` // 段1 客户端请求头(凭证类头脱敏:前6后4)JSON
ClientReqBody string `json:"client_req_body" orm:"client_req_body" description:"段1 客户端请求体(完整不截断;二进制为 base64,见 encoding 列)"` // 段1 客户端请求体(完整不截断;二进制为 base64,见 encoding 列)
ClientReqEncoding string `json:"client_req_encoding" orm:"client_req_encoding" description:"段1 编码:plain / base64"` // 段1 编码:plain / base64
UpstreamReqHeaders string `json:"upstream_req_headers" orm:"upstream_req_headers" description:"段2 发往上游的最终请求头(协议转换+override 后,凭证类脱敏)JSON"` // 段2 发往上游的最终请求头(协议转换+override 后,凭证类脱敏)JSON
UpstreamReqBody string `json:"upstream_req_body" orm:"upstream_req_body" description:"段2 发往上游的请求体(实际发送字节,含协议转换;二进制为 base64)"` // 段2 发往上游的请求体(实际发送字节,含协议转换;二进制为 base64)
UpstreamReqEncoding string `json:"upstream_req_encoding" orm:"upstream_req_encoding" description:"段2 编码:plain / base64"` // 段2 编码:plain / base64
UpstreamRespHeaders string `json:"upstream_resp_headers" orm:"upstream_resp_headers" description:"段3 上游响应头 JSON(Content-Encoding 可能已被 net/http 透明解压移除)"` // 段3 上游响应头 JSON(Content-Encoding 可能已被 net/http 透明解压移除)
UpstreamRespBody string `json:"upstream_resp_body" orm:"upstream_resp_body" description:"段3 上游响应体(Go 透明解压后的字节;流式为 SSE 原文;二进制为 base64)"` // 段3 上游响应体(Go 透明解压后的字节;流式为 SSE 原文;二进制为 base64)
UpstreamRespEncoding string `json:"upstream_resp_encoding" orm:"upstream_resp_encoding" description:"段3 编码:plain / base64"` // 段3 编码:plain / base64
ClientRespHeaders string `json:"client_resp_headers" orm:"client_resp_headers" description:"段4 返回客户端的响应头 JSON"` // 段4 返回客户端的响应头 JSON
ClientRespBody string `json:"client_resp_body" orm:"client_resp_body" description:"段4 返回客户端的响应体(协议转换后,完整不截断;二进制为 base64)"` // 段4 返回客户端的响应体(协议转换后,完整不截断;二进制为 base64)
ClientRespEncoding string `json:"client_resp_encoding" orm:"client_resp_encoding" description:"段4 编码:plain / base64"` // 段4 编码:plain / base64
UpstreamLatencyMs int `json:"upstream_latency_ms" orm:"upstream_latency_ms" description:"上游往返耗时(RoundTrip 到响应头)毫秒"` // 上游往返耗时(RoundTrip 到响应头)毫秒
TotalLatencyMs int `json:"total_latency_ms" orm:"total_latency_ms" description:"请求总耗时毫秒"` // 请求总耗时毫秒
FirstTokenMs int `json:"first_token_ms" orm:"first_token_ms" description:"首字节延迟毫秒"` // 首字节延迟毫秒
ClientReqBytes int64 `json:"client_req_bytes" orm:"client_req_bytes" description:"段1 请求体原始字节数(base64 落库膨胀前的真实大小)"` // 段1 请求体原始字节数(base64 落库膨胀前的真实大小)
UpstreamReqBytes int64 `json:"upstream_req_bytes" orm:"upstream_req_bytes" description:"段2 请求体原始字节数"` // 段2 请求体原始字节数
UpstreamRespBytes int64 `json:"upstream_resp_bytes" orm:"upstream_resp_bytes" description:"段3 响应体原始字节数"` // 段3 响应体原始字节数
ClientRespBytes int64 `json:"client_resp_bytes" orm:"client_resp_bytes" description:"段4 响应体原始字节数"` // 段4 响应体原始字节数
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
Conversion string `json:"conversion" orm:"conversion" description:"协议转换信息 JSON:client_format 客户端协议 / upstream_format 上游协议 / chain 请求侧转换链 / bridge 桥接方式(responses_api|responses_direct|pass_through,空=常规转换)"` // 协议转换信息 JSON:client_format 客户端协议 / upstream_format 上游协议 / chain 请求侧转换链 / bridge 桥接方式(responses_api|responses_direct|pass_through,空=常规转换)
}
+16 -12
View File
@@ -11,16 +11,20 @@ import (
// MdlPricing is the golang structure for table mdl_pricing.
type MdlPricing struct {
Id int64 `json:"id" orm:"id" description:""` //
ModelId int64 `json:"model_id" orm:"model_id" description:"关联模型ID"` // 关联模型ID
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)"` // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
MinTokens int64 `json:"min_tokens" orm:"min_tokens" description:"阶梯起始 token 数(仅 tiered 模式,其他模式为 0)"` // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0)
MaxTokens int64 `json:"max_tokens" orm:"max_tokens" description:"阶梯结束 token 数(NULL=无上限,仅 tiered 模式)"` // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式)
InputPrice decimal.Decimal `json:"input_price" orm:"input_price" description:"每 1M input token 价格(token/tiered 模式)"` // 每 1M input token 价格(token/tiered 模式)
OutputPrice decimal.Decimal `json:"output_price" orm:"output_price" description:"每 1M output token 价格(token/tiered 模式)"` // 每 1M output token 价格(token/tiered 模式)
PerRequestPrice *decimal.Decimal `json:"per_request_price" orm:"per_request_price" description:"按次计费单价(仅 per_request 模式)"` // 按次计费单价(仅 per_request 模式)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
CacheReadPrice decimal.Decimal `json:"cache_read_price" orm:"cache_read_price" description:"缓存读取每 1M token 价格(直接定价)"` // 缓存读取每 1M token 价格(直接定价)
CacheCreationPrice decimal.Decimal `json:"cache_creation_price" orm:"cache_creation_price" description:"缓存创建每 1M token 价格(直接定价)"` // 缓存创建每 1M token 价格(直接定价)
Id int64 `json:"id" orm:"id" description:""` //
ModelId int64 `json:"model_id" orm:"model_id" description:"关联模型ID"` // 关联模型ID
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)"` // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
MinTokens int64 `json:"min_tokens" orm:"min_tokens" description:"阶梯起始 token 数(仅 tiered 模式,其他模式为 0)"` // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0)
MaxTokens int64 `json:"max_tokens" orm:"max_tokens" description:"阶梯结束 token 数(NULL=无上限,仅 tiered 模式)"` // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式)
InputPrice decimal.Decimal `json:"input_price" orm:"input_price" description:"每 1M input token 价格(token/tiered 模式)"` // 每 1M input token 价格(token/tiered 模式)
OutputPrice decimal.Decimal `json:"output_price" orm:"output_price" description:"每 1M output token 价格(token/tiered 模式)"` // 每 1M output token 价格(token/tiered 模式)
PerRequestPrice *decimal.Decimal `json:"per_request_price" orm:"per_request_price" description:"按次计费单价(仅 per_request 模式)"` // 按次计费单价(仅 per_request 模式)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
CacheReadPrice decimal.Decimal `json:"cache_read_price" orm:"cache_read_price" description:"缓存读取每 1M token 价格(直接定价)"` // 缓存读取每 1M token 价格(直接定价)
CacheCreationPrice decimal.Decimal `json:"cache_creation_price" orm:"cache_creation_price" description:"缓存创建每 1M token 价格(直接定价)"` // 缓存创建每 1M token 价格(直接定价)
TimeSegments string `json:"time_segments" orm:"time_segments" description:"时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{\"name\":\"闲时\",\"days\":[1,2,3,4,5],\"start_time\":\"00:00\",\"end_time\":\"08:00\",\"valid_from\":\"\",\"valid_to\":\"\",\"multiplier\":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点"` // 时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{"name":"闲时","days":[1,2,3,4,5],"start_time":"00:00","end_time":"08:00","valid_from":"","valid_to":"","multiplier":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点
PriceNote string `json:"price_note" orm:"price_note" description:"价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无"` // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
DiscountLabel string `json:"discount_label" orm:"discount_label" description:"折扣标签(对外展示,如\"7折起\"、\"限时5折\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
PriceChangeNote string `json:"price_change_note" orm:"price_change_note" description:"价格调整说明(对外展示,提示用户价格有变动,如\"9月1日起输入价下调\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
}
+16 -1
View File
@@ -124,6 +124,16 @@ type (
ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (*v1.ChannelResetHealthRes, error)
// ExportChannels exports channel list to CSV or Excel.
ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (*v1.ChannelExportRes, error)
// ChannelDebugLogList 渠道调试日志列表(原生 SQL:octet_length 表达式经 gdb Fields 有引号化风险)
ChannelDebugLogList(ctx context.Context, req *v1.ChannelDebugLogListReq) (*v1.ChannelDebugLogListRes, error)
// ChannelDebugLogStats 渠道调试日志统计(条数/落库体积/最早时间,供清理提示)
ChannelDebugLogStats(ctx context.Context, req *v1.ChannelDebugLogStatsReq) (*v1.ChannelDebugLogStatsRes, error)
// ChannelDebugLogDetail 渠道调试日志详情(四段完整报文;headers JSONB 解析为对象便于前端渲染)
ChannelDebugLogDetail(ctx context.Context, req *v1.ChannelDebugLogDetailReq) (*v1.ChannelDebugLogDetailRes, error)
// ChannelDebugLogDelete 删除单条调试日志(硬删除)
ChannelDebugLogDelete(ctx context.Context, req *v1.ChannelDebugLogDeleteReq) (*v1.ChannelDebugLogDeleteRes, error)
// ChannelDebugLogClear 清空渠道调试日志(按渠道硬删除全部)
ChannelDebugLogClear(ctx context.Context, req *v1.ChannelDebugLogClearReq) (*v1.ChannelDebugLogClearRes, error)
// ChannelErrorEventList 渠道错误事件列表
ChannelErrorEventList(ctx context.Context, req *v1.ChannelErrorEventListReq) (*v1.ChannelErrorEventListRes, error)
// ChannelErrorStats 渠道错误统计
@@ -140,7 +150,9 @@ type (
ChannelOAuthExchange(ctx context.Context, req *v1.ChannelOAuthExchangeReq) (*v1.ChannelOAuthExchangeRes, error)
// ChannelOAuthRefresh 手动刷新 OAuth 令牌
ChannelOAuthRefresh(ctx context.Context, req *v1.ChannelOAuthRefreshReq) (*v1.ChannelOAuthRefreshRes, error)
// TestChannel 测试渠道可用性(发送最小请求验证)
// TestChannel 测试渠道可用性(发送最小请求验证)。
// 不限制渠道状态:禁用中的渠道也允许手动测试,便于管理员在启用放流量前确认已恢复;
// 自动探测 cron 只查 active 渠道,禁用渠道不会被自动探测。
TestChannel(ctx context.Context, req *v1.ChannelTestReq) (*v1.ChannelTestRes, error)
// CronJobList returns all registered cron jobs with their last execution status.
CronJobList(ctx context.Context, _ *v1.CronJobListReq) (*v1.CronJobListRes, error)
@@ -483,6 +495,9 @@ type (
UpdateTenantChannelScope(ctx context.Context, req *v1.TenantChannelScopeUpdateReq) (*v1.TenantChannelScopeUpdateRes, error)
// ExportTenants exports tenant list to CSV or Excel.
ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v1.TenantExportRes, error)
// TenantApiKeySelect 租户 API Key 下拉选择列表(轻量,不返回密钥原值)。
// 供渠道调试目标过滤等管理端选择器使用;user_id > 0 时按创建者过滤。
TenantApiKeySelect(ctx context.Context, req *v1.TenantApiKeySelectReq) (*v1.TenantApiKeySelectRes, error)
ListTenantLevelConfigs(ctx context.Context, _ *v1.TenantLevelConfigListReq) (*v1.TenantLevelConfigListRes, error)
CreateTenantLevelConfig(ctx context.Context, req *v1.TenantLevelConfigCreateReq) (*v1.TenantLevelConfigCreateRes, error)
UpdateTenantLevelConfig(ctx context.Context, req *v1.TenantLevelConfigUpdateReq) (*v1.TenantLevelConfigUpdateRes, error)
+26
View File
@@ -0,0 +1,26 @@
-- +goose Up
-- 模型时段定价:mdl_pricing 增加时段定价 JSONB 列(峰谷定价/工作时段/限时促销)
ALTER TABLE mdl_pricing
ADD COLUMN IF NOT EXISTS time_segments JSONB;
COMMENT ON COLUMN mdl_pricing.time_segments IS '时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{"name":"闲时","days":[1,2,3,4,5],"start_time":"00:00","end_time":"08:00","valid_from":"","valid_to":"","multiplier":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点';
-- 模型定价展示字段:价格说明(内部)+ 折扣标签 / 价格调整说明(对外展示)
ALTER TABLE mdl_pricing
ADD COLUMN IF NOT EXISTS price_note VARCHAR(500),
ADD COLUMN IF NOT EXISTS discount_label VARCHAR(50),
ADD COLUMN IF NOT EXISTS price_change_note VARCHAR(200);
COMMENT ON COLUMN mdl_pricing.price_note IS '价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无';
COMMENT ON COLUMN mdl_pricing.discount_label IS '折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示';
COMMENT ON COLUMN mdl_pricing.price_change_note IS '价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示';
-- +goose Down
ALTER TABLE mdl_pricing
DROP COLUMN IF EXISTS time_segments;
ALTER TABLE mdl_pricing
DROP COLUMN IF EXISTS price_change_note,
DROP COLUMN IF EXISTS discount_label,
DROP COLUMN IF EXISTS price_note;
+94
View File
@@ -0,0 +1,94 @@
-- +goose Up
-- 渠道调试日志:渠道调试开关开启时,记录经该渠道每次请求尝试(per-attempt,failover 各自成一条)
-- 的四段完整报文(客户端↔系统↔上游)。调试用途:body 不截断、无自动过期,由管理员手动删除/清空。
-- 不按月分区(需按渠道硬删,分区反而碍事);BRIN 应对按时间扫描。
CREATE TABLE IF NOT EXISTS chn_debug_logs (
id BIGSERIAL PRIMARY KEY,
channel_id BIGINT NOT NULL,
channel_name VARCHAR(128),
channel_type INT,
request_id VARCHAR(64) NOT NULL DEFAULT '',
tenant_id BIGINT,
user_id BIGINT,
api_key_id BIGINT,
model_name VARCHAR(128),
upstream_model VARCHAR(128),
relay_mode VARCHAR(32),
inbound_path VARCHAR(128),
upstream_url TEXT,
is_stream BOOLEAN NOT NULL DEFAULT FALSE,
retry_index INT NOT NULL DEFAULT 0,
is_final BOOLEAN NOT NULL DEFAULT TRUE,
upstream_status_code INT,
client_status_code INT,
error TEXT,
client_req_headers JSONB,
client_req_body TEXT,
client_req_encoding VARCHAR(8) NOT NULL DEFAULT 'plain',
upstream_req_headers JSONB,
upstream_req_body TEXT,
upstream_req_encoding VARCHAR(8) NOT NULL DEFAULT 'plain',
upstream_resp_headers JSONB,
upstream_resp_body TEXT,
upstream_resp_encoding VARCHAR(8) NOT NULL DEFAULT 'plain',
client_resp_headers JSONB,
client_resp_body TEXT,
client_resp_encoding VARCHAR(8) NOT NULL DEFAULT 'plain',
upstream_latency_ms INT,
total_latency_ms INT,
first_token_ms INT,
client_req_bytes BIGINT,
upstream_req_bytes BIGINT,
upstream_resp_bytes BIGINT,
client_resp_bytes BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_chn_debug_logs_channel_id ON chn_debug_logs (channel_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_chn_debug_logs_request_id ON chn_debug_logs (request_id);
CREATE INDEX IF NOT EXISTS idx_chn_debug_logs_created_brin ON chn_debug_logs USING BRIN (created_at);
COMMENT ON TABLE chn_debug_logs IS '渠道调试日志:调试开关开启的渠道,per-attempt 记录客户端↔系统↔上游四段完整报文(头/体/编码),敏感凭证脱敏,二进制体 base64;body 不截断、无自动过期,按渠道手动清理';
COMMENT ON COLUMN chn_debug_logs.id IS '主键ID';
COMMENT ON COLUMN chn_debug_logs.channel_id IS '渠道ID(逻辑关联 chn_channels.id,无外键)';
COMMENT ON COLUMN chn_debug_logs.channel_name IS '渠道名称(冗余存储,渠道删除后仍可辨识)';
COMMENT ON COLUMN chn_debug_logs.channel_type IS '渠道类型(ProviderType 枚举值)';
COMMENT ON COLUMN chn_debug_logs.request_id IS '请求唯一ID(同一请求多次重试共享)';
COMMENT ON COLUMN chn_debug_logs.tenant_id IS '租户ID';
COMMENT ON COLUMN chn_debug_logs.user_id IS '用户ID';
COMMENT ON COLUMN chn_debug_logs.api_key_id IS 'API Key ID';
COMMENT ON COLUMN chn_debug_logs.model_name IS '用户请求的模型名';
COMMENT ON COLUMN chn_debug_logs.upstream_model IS '上游实际使用的模型名(模型映射后)';
COMMENT ON COLUMN chn_debug_logs.relay_mode IS '转发模式(chat_completions/claude_messages/embeddings 等)';
COMMENT ON COLUMN chn_debug_logs.inbound_path IS '入站端点路径(如 /v1/chat/completions)';
COMMENT ON COLUMN chn_debug_logs.upstream_url IS '上游请求 URL(query 中的凭证参数已脱敏)';
COMMENT ON COLUMN chn_debug_logs.is_stream IS '是否流式请求';
COMMENT ON COLUMN chn_debug_logs.retry_index IS '重试轮次(0=首次尝试)';
COMMENT ON COLUMN chn_debug_logs.is_final IS '是否为产生客户端响应的最终尝试(成功/终止/流中断)';
COMMENT ON COLUMN chn_debug_logs.upstream_status_code IS '上游 HTTP 状态码(未发起请求或连接失败为 NULL)';
COMMENT ON COLUMN chn_debug_logs.client_status_code IS '返回客户端的状态码';
COMMENT ON COLUMN chn_debug_logs.error IS '本尝试的错误信息(成功为空)';
COMMENT ON COLUMN chn_debug_logs.client_req_headers IS '段1 客户端请求头(凭证类头脱敏:前6后4)JSON';
COMMENT ON COLUMN chn_debug_logs.client_req_body IS '段1 客户端请求体(完整不截断;二进制为 base64,见 encoding 列)';
COMMENT ON COLUMN chn_debug_logs.client_req_encoding IS '段1 编码:plain / base64';
COMMENT ON COLUMN chn_debug_logs.upstream_req_headers IS '段2 发往上游的最终请求头(协议转换+override 后,凭证类脱敏)JSON';
COMMENT ON COLUMN chn_debug_logs.upstream_req_body IS '段2 发往上游的请求体(实际发送字节,含协议转换;二进制为 base64)';
COMMENT ON COLUMN chn_debug_logs.upstream_req_encoding IS '段2 编码:plain / base64';
COMMENT ON COLUMN chn_debug_logs.upstream_resp_headers IS '段3 上游响应头 JSON(Content-Encoding 可能已被 net/http 透明解压移除)';
COMMENT ON COLUMN chn_debug_logs.upstream_resp_body IS '段3 上游响应体(Go 透明解压后的字节;流式为 SSE 原文;二进制为 base64)';
COMMENT ON COLUMN chn_debug_logs.upstream_resp_encoding IS '段3 编码:plain / base64';
COMMENT ON COLUMN chn_debug_logs.client_resp_headers IS '段4 返回客户端的响应头 JSON';
COMMENT ON COLUMN chn_debug_logs.client_resp_body IS '段4 返回客户端的响应体(协议转换后,完整不截断;二进制为 base64)';
COMMENT ON COLUMN chn_debug_logs.client_resp_encoding IS '段4 编码:plain / base64';
COMMENT ON COLUMN chn_debug_logs.upstream_latency_ms IS '上游往返耗时(RoundTrip 到响应头)毫秒';
COMMENT ON COLUMN chn_debug_logs.total_latency_ms IS '请求总耗时毫秒';
COMMENT ON COLUMN chn_debug_logs.first_token_ms IS '首字节延迟毫秒';
COMMENT ON COLUMN chn_debug_logs.client_req_bytes IS '段1 请求体原始字节数(base64 落库膨胀前的真实大小)';
COMMENT ON COLUMN chn_debug_logs.upstream_req_bytes IS '段2 请求体原始字节数';
COMMENT ON COLUMN chn_debug_logs.upstream_resp_bytes IS '段3 响应体原始字节数';
COMMENT ON COLUMN chn_debug_logs.client_resp_bytes IS '段4 响应体原始字节数';
COMMENT ON COLUMN chn_debug_logs.created_at IS '创建时间';
-- +goose Down
DROP TABLE IF EXISTS chn_debug_logs;
@@ -0,0 +1,11 @@
-- +goose Up
-- 渠道调试日志:新增协议转换信息列。
-- 记录客户端协议 →(relaykit 转换链)→ 上游协议及桥接方式(responses_api 桥接 / responses 直连 / 直传),
-- 供排查协议转换类问题(字段丢失/格式不符)时与四段报文对照。
ALTER TABLE chn_debug_logs ADD COLUMN IF NOT EXISTS conversion JSONB;
COMMENT ON COLUMN chn_debug_logs.conversion IS '协议转换信息 JSON:client_format 客户端协议 / upstream_format 上游协议 / chain 请求侧转换链 / bridge 桥接方式(responses_api|responses_direct|pass_through,空=常规转换)';
-- +goose Down
ALTER TABLE chn_debug_logs DROP COLUMN IF EXISTS conversion;
+5 -4
View File
@@ -3,6 +3,7 @@ package common
import (
"context"
"errors"
"time"
)
// ErrModelPricingNotConfigured 模型未配置定价(fail-closed 哨兵错误)。
@@ -17,8 +18,8 @@ type BillingProvider interface {
// 检查余额 → 冻结预扣金额 → 返回是否成功
PreDeduct(ctx context.Context, tenantID int64, modelName string, inputTokens, maxTokens int, isStream bool, requestID string) (preDeductAmount float64, err error)
// Settle 结算费用(成功请求)
Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64) (*SettlementResult, error)
// Settle 结算费用(成功请求)。billAt 为请求受理时刻(时段定价按该时刻评估)
Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64, billAt time.Time) (*SettlementResult, error)
// SettleWithUsage 完整 Usage 结算(含 cache token + 计费快照)
SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
@@ -28,8 +29,8 @@ type BillingProvider interface {
// SettleFailed 失败请求结算(退还预扣)
SettleFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount float64) error
// SettleStreamInterrupted 流式中断结算
SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64) (*SettlementResult, error)
// SettleStreamInterrupted 流式中断结算。billAt 为请求受理时刻(时段定价按该时刻评估)
SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64, billAt time.Time) (*SettlementResult, error)
// CheckRateLimit QPS 限流检查
CheckRateLimit(ctx context.Context, tenantID, userID, apiKeyID int64, keyQPS int) (allowed bool, limitLevel string, limit int, remaining int, resetAt int64)
+340
View File
@@ -0,0 +1,340 @@
package common
import (
"bytes"
"context"
"encoding/base64"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"unicode/utf8"
)
// 渠道调试日志传输层捕获:仅在渠道调试开关开启时,经 ctx 携带捕获器镜像
// 「系统→远程」「远程→系统」两段报文(最终形态,含协议转换与 header override 后的结果)。
// 开关关闭的请求 ctx 中无捕获器,DebugRoundTripper 直接透传,热路径零开销。
type debugAttemptCtxKey struct{}
// WithDebugAttempt 将本次尝试的捕获器挂入 ctx(作用域仅限当前 attempt,勿跨重试轮次复用)
func WithDebugAttempt(ctx context.Context, a *DebugAttemptCapture) context.Context {
return context.WithValue(ctx, debugAttemptCtxKey{}, a)
}
// DebugAttemptFromContext 取出当前 ctx 的捕获器;未开启调试时返回 nil
func DebugAttemptFromContext(ctx context.Context) *DebugAttemptCapture {
if ctx == nil {
return nil
}
a, _ := ctx.Value(debugAttemptCtxKey{}).(*DebugAttemptCapture)
return a
}
// DebugAttemptCapture 单次上游尝试的传输层捕获器。tee 完整累积请求/响应字节,不截断
// (调试用途,可能含 base64 图片;靠管理端提示及时清理控制数据量)。
type DebugAttemptCapture struct {
mu sync.Mutex
upstreamMethod string
upstreamURL string // query 中凭证参数已脱敏
upstreamReqHeaders map[string]string
upstreamRespHeaders map[string]string
upstreamReqBody []byte
upstreamRespBody []byte
upstreamStatus int
upstreamError string
upstreamLatencyMs int64
}
// captureUpstreamRequest 快照最终上游请求并替换 Body 为 tee(在 RoundTrip 内调用)
func (a *DebugAttemptCapture) captureUpstreamRequest(req *http.Request) {
a.mu.Lock()
a.upstreamMethod = req.Method
a.upstreamURL = MaskURLSecret(req.URL.String())
a.upstreamReqHeaders = MaskHeaders(req.Header)
a.mu.Unlock()
if req.Body != nil {
req.Body = newDebugTee(req.Body, a, &a.upstreamReqBody)
}
}
// captureUpstreamResponse 快照上游响应并替换 Body 为 tee
func (a *DebugAttemptCapture) captureUpstreamResponse(resp *http.Response) {
a.mu.Lock()
a.upstreamStatus = resp.StatusCode
a.upstreamRespHeaders = MaskHeaders(resp.Header)
a.mu.Unlock()
if resp.Body != nil {
resp.Body = newDebugTee(resp.Body, a, &a.upstreamRespBody)
}
}
func (a *DebugAttemptCapture) captureError(err error) {
a.mu.Lock()
a.upstreamError = err.Error()
a.mu.Unlock()
}
func (a *DebugAttemptCapture) setLatency(d time.Duration) {
a.mu.Lock()
a.upstreamLatencyMs = d.Milliseconds()
a.mu.Unlock()
}
// upstreamSnapshot 捕获数据快照(构建落库记录时调用一次;body 切片引用捕获缓冲,此后不再写入)
type upstreamSnapshot struct {
Method string
URL string
ReqHeaders map[string]string
RespHeaders map[string]string
ReqBody []byte
RespBody []byte
Status int
Err string
LatencyMs int64
}
func (a *DebugAttemptCapture) snapshot() upstreamSnapshot {
a.mu.Lock()
defer a.mu.Unlock()
return upstreamSnapshot{
Method: a.upstreamMethod,
URL: a.upstreamURL,
ReqHeaders: a.upstreamReqHeaders,
RespHeaders: a.upstreamRespHeaders,
ReqBody: a.upstreamReqBody,
RespBody: a.upstreamRespBody,
Status: a.upstreamStatus,
Err: a.upstreamError,
LatencyMs: a.upstreamLatencyMs,
}
}
// debugTeeReadCloser 透传读写并在读取时镜像字节到捕获缓冲
type debugTeeReadCloser struct {
src io.ReadCloser
a *DebugAttemptCapture
field *[]byte
}
func newDebugTee(src io.ReadCloser, a *DebugAttemptCapture, field *[]byte) *debugTeeReadCloser {
return &debugTeeReadCloser{src: src, a: a, field: field}
}
func (t *debugTeeReadCloser) Read(p []byte) (int, error) {
n, err := t.src.Read(p)
if n > 0 {
t.a.mu.Lock()
*t.field = append(*t.field, p[:n]...)
t.a.mu.Unlock()
}
return n, err
}
func (t *debugTeeReadCloser) Close() error {
return t.src.Close()
}
// DebugRoundTripper 渠道调试日志传输层包装。ctx 无捕获器时直接透传(一次 ctx 查找);
// 有捕获器时镜像最终上游请求/响应。连接池与 HTTP/2 仍由底层 *http.Transport 管理。
type DebugRoundTripper struct {
Base http.RoundTripper
}
// WrapDebugTransport 包装传输层供 http.Client 的 Transport 字段使用
func WrapDebugTransport(base *http.Transport) http.RoundTripper {
return &DebugRoundTripper{Base: base}
}
func (t *DebugRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
a := DebugAttemptFromContext(req.Context())
if a == nil {
return t.Base.RoundTrip(req)
}
// Clone 后再替换 Body:不污染适配器持有的原始 req(适配器在 client.Do 返回后会
// Close httpReq.Body,tee 的 Close 透传保持原有语义)。
// 已知边角:底层 Transport 建连失败内部重放走 GetBody(重读原始 body),会绕过 tee,
// 该次捕获的段2 可能不完整——罕见且不影响请求本身,可接受。
clone := req.Clone(req.Context())
a.captureUpstreamRequest(clone)
start := time.Now()
resp, err := t.Base.RoundTrip(clone)
a.setLatency(time.Since(start))
if err != nil {
a.captureError(err)
return resp, err
}
a.captureUpstreamResponse(resp)
return resp, nil
}
// DebugClientWriter 渠道调试日志的客户端响应捕获 writer:完整累积、不截断
// (区别于审计的 ResponseCaptureWriter,后者有 256KB head+tail 截断)。
// 仅在调试开关开启时由 RelayHandler 包在 rc.Writer 外层,请求结束后由
// DebugSession.FinalizeAndSubmit 取走数据。
type DebugClientWriter struct {
http.ResponseWriter
mu sync.Mutex
buf []byte
status int
}
// NewDebugClientWriter 创建客户端响应捕获 writer
func NewDebugClientWriter(w http.ResponseWriter) *DebugClientWriter {
return &DebugClientWriter{ResponseWriter: w}
}
// WriteHeader 捕获状态码并委托底层 writer(仅首次生效)
func (w *DebugClientWriter) WriteHeader(code int) {
w.mu.Lock()
if w.status != 0 {
w.mu.Unlock()
return
}
w.status = code
w.mu.Unlock()
w.ResponseWriter.WriteHeader(code)
}
// Write 委托底层 writer 并完整镜像实际写出的字节
func (w *DebugClientWriter) Write(b []byte) (int, error) {
w.mu.Lock()
if w.status == 0 {
w.status = http.StatusOK
}
w.mu.Unlock()
n, err := w.ResponseWriter.Write(b)
w.mu.Lock()
w.buf = append(w.buf, b[:n]...)
w.mu.Unlock()
return n, err
}
// Flush 实现 http.Flusher,转发到底层 writer(流式响应依赖)
func (w *DebugClientWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Bytes 返回已捕获的完整响应体
func (w *DebugClientWriter) Bytes() []byte {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf
}
// StatusCode 返回捕获的状态码(未显式设置时按 200)
func (w *DebugClientWriter) StatusCode() int {
w.mu.Lock()
defer w.mu.Unlock()
if w.status == 0 {
return http.StatusOK
}
return w.status
}
// HeaderSnapshot 从底层 writer 提取当前响应头快照(响应已写出后调用,取最终形态)
func (w *DebugClientWriter) HeaderSnapshot() map[string]string {
headers := make(map[string]string)
for k, vals := range w.ResponseWriter.Header() {
if len(vals) > 0 {
headers[k] = vals[0]
}
}
return headers
}
// ===== 脱敏与编码 =====
// sensitiveHeaderNames 凭证类请求/响应头,落库前脱敏(保留前6后4,足以区分 Key 又不泄露完整值)
var sensitiveHeaderNames = map[string]bool{
"authorization": true,
"proxy-authorization": true,
"cookie": true,
"set-cookie": true,
"x-api-key": true,
"api-key": true,
"x-goog-api-key": true,
"x-auth-token": true,
}
// urlSecretParams URL query 中携带凭证的参数名(如 Gemini 的 ?key=)
var urlSecretParams = map[string]bool{
"key": true,
"api_key": true,
"apikey": true,
"token": true,
"access_token": true,
}
// MaskSecret 脱敏单个凭证值:长度 < 12 全遮蔽;否则保留前 6 后 4 字符
func MaskSecret(v string) string {
if len(v) < 12 {
return "****"
}
return v[:6] + "****" + v[len(v)-4:]
}
// MaskHeaders 头快照并脱敏凭证类头;多值头用 ", " 连接
func MaskHeaders(h http.Header) map[string]string {
if h == nil {
return nil
}
out := make(map[string]string, len(h))
for k, vals := range h {
v := strings.Join(vals, ", ")
if sensitiveHeaderNames[strings.ToLower(k)] {
v = MaskSecret(v)
}
out[k] = v
}
return out
}
// MaskURLSecret 遮蔽 URL query 中的凭证参数值
func MaskURLSecret(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u.RawQuery == "" {
return rawURL
}
q, err := url.ParseQuery(u.RawQuery)
if err != nil {
return rawURL
}
changed := false
for name, vals := range q {
if !urlSecretParams[strings.ToLower(name)] {
continue
}
for i := range vals {
vals[i] = MaskSecret(vals[i])
}
changed = true
}
if !changed {
return rawURL
}
// Encode 会把脱敏占位符 * 百分号转义成 %2A(可读性差),还原为字面 *
u.RawQuery = strings.ReplaceAll(q.Encode(), "%2A", "*")
return u.String()
}
// EncodeBody body 落库编码:有效 UTF-8 且不含 NUL 字节 → 原文 plain;
// 否则(multipart/图片/音频等二进制)→ base64(PostgreSQL TEXT 列不接受 NUL 与无效 UTF-8)
func EncodeBody(b []byte) (string, string) {
if len(b) == 0 {
return "", "plain"
}
if utf8.Valid(b) && !bytes.ContainsRune(b, 0) {
return string(b), "plain"
}
return base64.StdEncoding.EncodeToString(b), "base64"
}
+241
View File
@@ -0,0 +1,241 @@
package common
import (
"bytes"
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ===== 脱敏 =====
func TestMaskSecret(t *testing.T) {
cases := []struct {
in, want string
}{
{"short", "****"}, // < 12 全遮蔽
{"12345678901", "****"}, // 11 位仍全遮蔽
{"sk-abcdef1234wxyz", "sk-abc****wxyz"}, // 前 6 后 4
{"", "****"}, // 空值
{"Bearer sk-abcdefghijk", "Bearer****hijk"}, // 带前缀的完整头值
}
for _, c := range cases {
if got := MaskSecret(c.in); got != c.want {
t.Errorf("MaskSecret(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestMaskSensitiveHeaders(t *testing.T) {
h := http.Header{}
h.Set("Authorization", "Bearer sk-abcdefghijklmnop")
h.Set("X-Api-Key", "short")
h.Set("Content-Type", "application/json")
h.Add("X-Multi", "a")
h.Add("X-Multi", "b")
out := MaskHeaders(h)
if got := out["Authorization"]; got != "Bearer****mnop" {
t.Errorf("Authorization 未按前6后4脱敏: %q", got)
}
if got := out["X-Api-Key"]; got != "****" {
t.Errorf("短凭证值应全遮蔽: %q", got)
}
if got := out["Content-Type"]; got != "application/json" {
t.Errorf("非敏感头不应改动: %q", got)
}
if got := out["X-Multi"]; got != "a, b" {
t.Errorf("多值头应逗号连接: %q", got)
}
if MaskHeaders(nil) != nil {
t.Error("nil 头应返回 nil")
}
}
func TestMaskURLSecret(t *testing.T) {
raw := "https://example.com/v1/models?key=abcdefghijk1234&x=1"
got := MaskURLSecret(raw)
if strings.Contains(got, "abcdefghijk1234") {
t.Errorf("URL query 凭证未脱敏: %q", got)
}
if !strings.Contains(got, "abcdef****1234") {
t.Errorf("脱敏格式不符合前6后4: %q", got)
}
if !strings.Contains(got, "x=1") {
t.Errorf("非凭证参数不应改动: %q", got)
}
// 无凭证参数的 URL 原样返回
plain := "https://example.com/v1/chat"
if got := MaskURLSecret(plain); got != plain {
t.Errorf("无凭证 URL 被改动: %q", got)
}
}
// ===== body 编码 =====
func TestEncodeBody(t *testing.T) {
// 有效 UTF-8 → plain
data, enc := EncodeBody([]byte(`{"model":"gpt"}`))
if enc != "plain" || data != `{"model":"gpt"}` {
t.Errorf("UTF-8 body 应原样 plain: %q %q", data, enc)
}
// 含 NUL → base64(PG TEXT 不接受 NUL)
nul := []byte{'a', 0x00, 'b'}
data, enc = EncodeBody(nul)
if enc != "base64" {
t.Errorf("含 NUL body 应 base64: %q", enc)
}
if decoded, err := base64Decode(data); err != nil || !bytes.Equal(decoded, nul) {
t.Errorf("base64 往返不一致: %v %v", decoded, err)
}
// 无效 UTF-8 → base64
invalid := []byte{0xff, 0xfe, 0xfd}
if _, enc = EncodeBody(invalid); enc != "base64" {
t.Errorf("无效 UTF-8 body 应 base64: %q", enc)
}
// 空 body
if data, enc = EncodeBody(nil); enc != "plain" || data != "" {
t.Errorf("空 body 应 plain 空串: %q %q", data, enc)
}
}
// ===== DebugClientWriter:不截断 =====
func TestDebugClientWriter(t *testing.T) {
rec := httptest.NewRecorder()
w := NewDebugClientWriter(rec)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
// 写 2MB,验证完整累积不截断
chunk := bytes.Repeat([]byte("x"), 64*1024)
total := 0
for i := 0; i < 32; i++ {
n, err := w.Write(chunk)
if err != nil || n != len(chunk) {
t.Fatalf("write 失败: n=%d err=%v", n, err)
}
total += n
}
if got := len(w.Bytes()); got != total {
t.Errorf("捕获 %d 字节,期望 %d(不允许截断)", got, total)
}
if w.StatusCode() != 200 {
t.Errorf("状态码 = %d, want 200", w.StatusCode())
}
if ct := w.HeaderSnapshot()["Content-Type"]; ct != "text/event-stream" {
t.Errorf("响应头快照错误: %q", ct)
}
// 未显式 WriteHeader 时默认 200
if code := NewDebugClientWriter(httptest.NewRecorder()).StatusCode(); code != 200 {
t.Errorf("默认状态码 = %d, want 200", code)
}
}
// ===== DebugRoundTripper =====
func TestDebugRoundTripperPassthrough(t *testing.T) {
// ctx 无捕获器:直接透传,行为与裸 transport 一致
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
client := &http.Client{Transport: WrapDebugTransport(http.DefaultTransport.(*http.Transport).Clone())}
resp, err := client.Get(srv.URL)
if err != nil {
t.Fatalf("passthrough 请求失败: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) != "ok" {
t.Errorf("透传响应被篡改: %q", body)
}
}
func TestDebugRoundTripperCapture(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 分块写入模拟流式响应,验证 tee 完整镜像
_, _ = w.Write([]byte(`{"chunk":1}`))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
_, _ = w.Write([]byte(`{"chunk":2}`))
}))
defer srv.Close()
a := &DebugAttemptCapture{}
ctx := WithDebugAttempt(context.Background(), a)
client := &http.Client{Transport: WrapDebugTransport(http.DefaultTransport.(*http.Transport).Clone())}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/v1/chat?key=topsecret12345",
strings.NewReader(`{"model":"gpt","messages":[]}`))
if err != nil {
t.Fatalf("构建请求失败: %v", err)
}
req.Header.Set("Authorization", "Bearer sk-abcdefghijklmnop")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
snap := a.snapshot()
// 段2:上游请求捕获(headers 脱敏 + URL 凭证脱敏 + body 完整)
if snap.Method != http.MethodPost {
t.Errorf("method = %q", snap.Method)
}
if !strings.Contains(snap.URL, "topsec****2345") {
t.Errorf("URL 凭证未脱敏: %q", snap.URL)
}
if got := snap.ReqHeaders["Authorization"]; got != "Bearer****mnop" {
t.Errorf("Authorization 未脱敏: %q", got)
}
if string(snap.ReqBody) != `{"model":"gpt","messages":[]}` {
t.Errorf("上游请求体捕获不完整: %q", snap.ReqBody)
}
// 段3:上游响应捕获(分块写入也应完整)
if snap.Status != 200 {
t.Errorf("status = %d", snap.Status)
}
if !strings.Contains(string(body), `{"chunk":1}`) {
t.Errorf("客户端读取的响应不完整: %q", body)
}
if string(snap.RespBody) != `{"chunk":1}{"chunk":2}` {
t.Errorf("上游响应体镜像不完整: %q", snap.RespBody)
}
if snap.LatencyMs < 0 {
t.Errorf("latency = %d", snap.LatencyMs)
}
}
func TestDebugRoundTripperError(t *testing.T) {
// 连接失败:err 路径记录 upstreamError,不 panic
a := &DebugAttemptCapture{}
ctx := WithDebugAttempt(context.Background(), a)
client := &http.Client{Transport: WrapDebugTransport(http.DefaultTransport.(*http.Transport).Clone())}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/nope", nil)
_, err := client.Do(req)
if err == nil {
t.Fatal("期望连接失败")
}
snap := a.snapshot()
if snap.Err == "" {
t.Error("传输层错误未被记录")
}
}
func base64Decode(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}
+57
View File
@@ -0,0 +1,57 @@
package common
import "context"
// DebugLogRecord 渠道调试日志记录(四段报文 + 元数据),经 SubmitDebugLog 钩子异步落库。
// headers 均已脱敏;body 为原始字节(落库时经 EncodeBody 处理二进制)。
type DebugLogRecord struct {
ChannelID int64
ChannelName string
ChannelType int
RequestID string
TenantID int64
UserID int64
ApiKeyID int64
ModelName string
UpstreamModel string
RelayMode string
InboundPath string
UpstreamURL string
IsStream bool
RetryIndex int
IsFinal bool
UpstreamStatusCode int // 0 = 未发起请求或连接失败(落库为 NULL)
ClientStatusCode int
Error string
// 四段报文:段1 客户端请求 / 段2 发往上游最终请求 / 段3 上游响应 / 段4 返回客户端响应
ClientReqHeaders map[string]string
ClientReqBody []byte
UpstreamReqHeaders map[string]string
UpstreamReqBody []byte
UpstreamRespHeaders map[string]string
UpstreamRespBody []byte
ClientRespHeaders map[string]string
ClientRespBody []byte
UpstreamLatencyMs int64
TotalLatencyMs int64
FirstTokenMs int64
// Conversion 协议转换信息(客户端协议 → 上游协议及转换链),buildRelayInfo 后由 CaptureProtocol 快照
Conversion *DebugConversion
}
// DebugConversion 协议转换信息:追踪客户端协议经哪些格式转换到上游协议。
// 段3/段4 的协议无需单独记录——段3 = upstream_format,段4 = client_format(推导关系)。
type DebugConversion struct {
ClientFormat string `json:"client_format"` // 客户端原始协议(openai/claude/gemini/responses)
UpstreamFormat string `json:"upstream_format"` // 发往上游的协议
Chain []string `json:"chain,omitempty"` // 请求侧转换链(relaykit 填充;为空且两端不同时兜底两端)
Bridge string `json:"bridge,omitempty"` // 桥接方式:responses_api / responses_direct / pass_through,空=常规转换
}
// SubmitDebugLog 调试日志提交钩子。relay 层不直接依赖 internal/logic(避免反向导入),
// 由 internal/logic/relay 在进程启动时注入实现;nil(未初始化/单测)时调用方静默丢弃。
var SubmitDebugLog func(ctx context.Context, record *DebugLogRecord)
+229
View File
@@ -0,0 +1,229 @@
package common
import (
"context"
"net/http"
"github.com/qianfree/team-api/relay/constant"
)
// DebugSession 渠道调试日志会话:挂载于 RelayContext.Debug,整请求存活。
// 重试循环中首个「调试开关开启」的渠道尝试触发创建:捕获段1(客户端请求)并包装段4
// 的客户端响应 writer(保持到请求结束,中间失败尝试不向客户端写字节,累积字节即最终响应)。
// 中间失败尝试经 DebugAttempt.Submit 立即落库(is_final=false);最终尝试经 MarkFinal
// 挂起,由外层 handler 在响应写完后调用 FinalizeAndSubmit 补段4并统一提交(幂等)。
type DebugSession struct {
requestID string
tenantID int64
userID int64
apiKeyID int64
path string
clientReqHeaders map[string]string // 段1(脱敏后)
clientReqBody []byte // 段1 完整请求体
clientWriter *DebugClientWriter // 段4 捕获 writer
pending *DebugAttempt // MarkFinal 挂起的最终尝试
finalized bool
}
// NewDebugSession 创建调试会话
func NewDebugSession(requestID string, tenantID, userID, apiKeyID int64, path string) *DebugSession {
return &DebugSession{
requestID: requestID,
tenantID: tenantID,
userID: userID,
apiKeyID: apiKeyID,
path: path,
}
}
// CaptureClientRequest 捕获段1:客户端请求头(脱敏)+ 完整请求体
func (s *DebugSession) CaptureClientRequest(headers http.Header, body []byte) {
s.clientReqHeaders = MaskHeaders(headers)
s.clientReqBody = body
}
// SetClientWriter 回填段4捕获 writer(由 RelayHandler 包装 rc.Writer 后调用)
func (s *DebugSession) SetClientWriter(w *DebugClientWriter) {
s.clientWriter = w
}
// BeginAttempt 为当前渠道尝试创建捕获上下文(每轮重试一次)
func (s *DebugSession) BeginAttempt(selection *ChannelSelection, modelName, relayModeStr string, isStream bool, retryIndex int) *DebugAttempt {
return &DebugAttempt{
session: s,
Capture: &DebugAttemptCapture{},
channelID: selection.ChannelID,
channelName: selection.ChannelName,
channelType: selection.ChannelType,
modelName: modelName,
upstreamModel: selection.UpstreamModelName,
relayMode: relayModeStr,
isStream: isStream,
retryIndex: retryIndex,
}
}
// DebugAttempt 单次尝试的调试上下文:会话引用 + 渠道元数据 + 传输层捕获器。
// Capture 经 WithDebugAttempt 挂入 ctx,由 DebugRoundTripper 填充段2/段3。
type DebugAttempt struct {
session *DebugSession
Capture *DebugAttemptCapture
channelID int64
channelName string
channelType int
modelName string
upstreamModel string
relayMode string
isStream bool
retryIndex int
errText string
conversion *DebugConversion // 协议转换信息(CaptureProtocol 快照)
}
// CaptureProtocol 快照协议转换信息(RelayHandler 在 buildRelayInfo 之后调用一次)。
// 上游协议按桥接标志与渠道类型推导:直传 > Responses 桥接 > Responses 直连 > 渠道原生协议。
func (a *DebugAttempt) CaptureProtocol(info *RelayInfo) {
if a == nil || info == nil {
return
}
conv := &DebugConversion{
ClientFormat: string(info.GetOriginalClientFormat()),
}
switch {
case info.ChannelMeta != nil && info.ChannelMeta.Settings.PassThroughBodyEnabled:
conv.UpstreamFormat = conv.ClientFormat
conv.Bridge = "pass_through"
case info.UseResponsesAPI:
conv.UpstreamFormat = string(constant.RelayFormatResponses)
conv.Bridge = "responses_api"
case info.ChannelMeta != nil && info.ChannelMeta.UpstreamSpeaksResponses():
conv.UpstreamFormat = string(constant.RelayFormatResponses)
conv.Bridge = "responses_direct"
default:
conv.UpstreamFormat = nativeFormatOfProvider(a.channelType)
}
for _, f := range info.ConversionChain() {
conv.Chain = append(conv.Chain, string(f))
}
// relaykit 链未覆盖(直传/同构/adaptor 手写转换)且两端协议不同时,兜底记录两端
if len(conv.Chain) == 0 && conv.UpstreamFormat != conv.ClientFormat {
conv.Chain = []string{conv.ClientFormat, conv.UpstreamFormat}
}
a.conversion = conv
}
// nativeFormatOfProvider 渠道类型 → 上游原生协议(展示用途)。
// openai 兼容系渠道占绝大多数,默认 openai;anthropic / gemini 系例外。
func nativeFormatOfProvider(providerType int) string {
switch constant.ProviderType(providerType) {
case constant.ProviderClaude:
return string(constant.RelayFormatClaude)
case constant.ProviderGemini, constant.ProviderVertex:
return string(constant.RelayFormatGemini)
default:
return string(constant.RelayFormatOpenAI)
}
}
// Submit 中间失败尝试:立即经钩子异步落库(is_final=false,段4为空——该尝试未向客户端写出字节)
func (a *DebugAttempt) Submit(err error) {
if a == nil {
return
}
a.errText = debugErrText(err)
if hook := SubmitDebugLog; hook != nil {
hook(context.Background(), a.buildRecord(false, 0, 0))
}
}
// MarkFinal 标记为最终尝试(成功/终止/流中断):挂起等待外层 FinalizeAndSubmit 补段4后提交
func (a *DebugAttempt) MarkFinal(err error) {
if a == nil {
return
}
a.errText = debugErrText(err)
a.session.pending = a
}
// FinalizeAndSubmit 补段4并提交最终尝试记录。由外层 handler 在客户端响应写完后调用
// (recordAudit 收尾处),幂等:重复调用或无挂起尝试时为 no-op。
func (s *DebugSession) FinalizeAndSubmit(totalLatencyMs, firstTokenMs int64) {
if s == nil || s.finalized {
return
}
s.finalized = true
a := s.pending
if a == nil {
return
}
if hook := SubmitDebugLog; hook != nil {
hook(context.Background(), a.buildRecord(true, totalLatencyMs, firstTokenMs))
}
}
// buildRecord 汇总四段与元数据构建落库记录。isFinal=true 时从 clientWriter 补段4。
func (a *DebugAttempt) buildRecord(isFinal bool, totalLatencyMs, firstTokenMs int64) *DebugLogRecord {
s := a.session
snap := a.Capture.snapshot()
rec := &DebugLogRecord{
ChannelID: a.channelID,
ChannelName: a.channelName,
ChannelType: a.channelType,
RequestID: s.requestID,
TenantID: s.tenantID,
UserID: s.userID,
ApiKeyID: s.apiKeyID,
ModelName: a.modelName,
UpstreamModel: a.upstreamModel,
RelayMode: a.relayMode,
InboundPath: s.path,
UpstreamURL: snap.URL,
IsStream: a.isStream,
RetryIndex: a.retryIndex,
IsFinal: isFinal,
UpstreamStatusCode: snap.Status,
Error: a.errText,
// 段1(会话级,全部尝试共享)
ClientReqHeaders: s.clientReqHeaders,
ClientReqBody: s.clientReqBody,
// 段2/段3(传输层捕获)
UpstreamReqHeaders: snap.ReqHeaders,
UpstreamReqBody: snap.ReqBody,
UpstreamRespHeaders: snap.RespHeaders,
UpstreamRespBody: snap.RespBody,
UpstreamLatencyMs: snap.LatencyMs,
Conversion: a.conversion,
}
if snap.Err != "" && rec.Error == "" {
// 传输层错误(连接失败等)未被上层 err 覆盖时兜底保留
rec.Error = snap.Err
}
if isFinal && s.clientWriter != nil {
// 段4:客户端响应
rec.ClientRespHeaders = s.clientWriter.HeaderSnapshot()
rec.ClientRespBody = s.clientWriter.Bytes()
rec.ClientStatusCode = s.clientWriter.StatusCode()
rec.TotalLatencyMs = totalLatencyMs
rec.FirstTokenMs = firstTokenMs
}
return rec
}
func debugErrText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
+199
View File
@@ -0,0 +1,199 @@
package common
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relaykit/types"
)
// 验证调试会话完整生命周期:
// 中间失败尝试 Submit 立即提交(is_final=false、段4为空),
// 最终尝试 MarkFinal 挂起、FinalizeAndSubmit 补段4后提交,且幂等。
func TestDebugSessionLifecycle(t *testing.T) {
var records []*DebugLogRecord
origHook := SubmitDebugLog
SubmitDebugLog = func(ctx context.Context, r *DebugLogRecord) { records = append(records, r) }
defer func() { SubmitDebugLog = origHook }()
s := NewDebugSession("req-1", 1, 2, 3, "/v1/chat/completions")
// 段1:客户端请求(含凭证头,应脱敏)
h := http.Header{}
h.Set("Authorization", "Bearer sk-test-key-12345678")
h.Set("Content-Type", "application/json")
s.CaptureClientRequest(h, []byte(`{"model":"m"}`))
selection := &ChannelSelection{ChannelID: 9, ChannelName: "ch9", ChannelType: 1, UpstreamModelName: "up-m"}
// 第一次尝试失败(中间):Submit 立即提交
a1 := s.BeginAttempt(selection, "m", "chat_completions", true, 0)
a1.Submit(errors.New("boom"))
if len(records) != 1 {
t.Fatalf("中间尝试应提交 1 条,实际 %d", len(records))
}
r1 := records[0]
if r1.IsFinal {
t.Error("中间尝试 is_final 应为 false")
}
if r1.Error != "boom" || r1.ChannelID != 9 || r1.RetryIndex != 0 || !r1.IsStream {
t.Errorf("中间尝试元数据错误: %+v", r1)
}
if string(r1.ClientReqBody) != `{"model":"m"}` {
t.Errorf("段1 请求体错误: %q", r1.ClientReqBody)
}
if r1.ClientReqHeaders["Authorization"] != "Bearer****5678" {
t.Errorf("段1 凭证头未脱敏: %q", r1.ClientReqHeaders["Authorization"])
}
if r1.ClientRespBody != nil {
t.Errorf("中间尝试段4应为空: %q", r1.ClientRespBody)
}
// 第二次尝试成功:MarkFinal → 模拟段2捕获 → FinalizeAndSubmit 补段4
a2 := s.BeginAttempt(selection, "m", "chat_completions", true, 1)
req, _ := http.NewRequest(http.MethodPost, "http://upstream/v1/chat", strings.NewReader("converted-body"))
a2.Capture.captureUpstreamRequest(req)
// 模拟传输层读取请求体 + 上游响应
_, _ = io.ReadAll(req.Body)
_ = req.Body.Close()
resp := &http.Response{StatusCode: 200, Header: http.Header{"X-Up": []string{"v"}}, Body: io.NopCloser(strings.NewReader("upstream-resp"))}
a2.Capture.captureUpstreamResponse(resp)
_, _ = io.ReadAll(resp.Body)
_ = resp.Body.Close()
// 段4 writer
rec := httptest.NewRecorder()
dw := NewDebugClientWriter(rec)
s.SetClientWriter(dw)
dw.WriteHeader(200)
_, _ = dw.Write([]byte("client-resp"))
a2.MarkFinal(nil)
s.FinalizeAndSubmit(123, 45)
if len(records) != 2 {
t.Fatalf("最终尝试应提交第 2 条,实际 %d", len(records))
}
r2 := records[1]
if !r2.IsFinal || r2.RetryIndex != 1 || r2.Error != "" {
t.Errorf("最终尝试元数据错误: %+v", r2)
}
if string(r2.UpstreamReqBody) != "converted-body" {
t.Errorf("段2 请求体错误: %q", r2.UpstreamReqBody)
}
if r2.UpstreamStatusCode != 200 || r2.UpstreamRespHeaders["X-Up"] != "v" {
t.Errorf("段3 状态/响应头错误: %d %v", r2.UpstreamStatusCode, r2.UpstreamRespHeaders)
}
if string(r2.UpstreamRespBody) != "upstream-resp" {
t.Errorf("段3 响应体错误: %q", r2.UpstreamRespBody)
}
if string(r2.ClientRespBody) != "client-resp" || r2.ClientStatusCode != 200 {
t.Errorf("段4 错误: %q %d", r2.ClientRespBody, r2.ClientStatusCode)
}
if r2.TotalLatencyMs != 123 || r2.FirstTokenMs != 45 {
t.Errorf("耗时元数据错误: %d %d", r2.TotalLatencyMs, r2.FirstTokenMs)
}
// 幂等:重复 Finalize 不再提交
s.FinalizeAndSubmit(1, 1)
if len(records) != 2 {
t.Errorf("FinalizeAndSubmit 应幂等,实际提交 %d 条", len(records))
}
}
// nil 会话(开关未开启)调用 FinalizeAndSubmit 应安全 no-op
func TestDebugSessionNilSafe(t *testing.T) {
var s *DebugSession
s.FinalizeAndSubmit(0, 0) // 不应 panic
var a *DebugAttempt
a.Submit(errors.New("x")) // 不应 panic
a.MarkFinal(nil) // 不应 panic
}
// 调试目标过滤:AND 组合,0 = 不限
func TestDebugTargetMatch(t *testing.T) {
s := ChannelSettings{DebugLogEnabled: true}
if !s.DebugTargetMatch(1, 2, 3) {
t.Error("未设过滤器应全部匹配")
}
s.DebugLogTenantID = 10
if !s.DebugTargetMatch(10, 99, 99) {
t.Error("匹配租户时应通过")
}
if s.DebugTargetMatch(11, 2, 3) {
t.Error("租户不匹配应拒绝")
}
s.DebugLogUserID = 20
if !s.DebugTargetMatch(10, 20, 99) {
t.Error("租户+成员均匹配应通过")
}
if s.DebugTargetMatch(10, 21, 99) {
t.Error("成员不匹配应拒绝")
}
s.DebugLogApiKeyID = 30
if !s.DebugTargetMatch(10, 20, 30) {
t.Error("三者均匹配应通过")
}
if s.DebugTargetMatch(10, 20, 31) {
t.Error("密钥不匹配应拒绝")
}
}
// 协议转换快照:常规转换按渠道类型推导上游协议;桥接标志优先;链路兜底两端
func TestCaptureProtocol(t *testing.T) {
s := NewDebugSession("req", 1, 2, 3, "/v1/chat/completions")
selection := &ChannelSelection{ChannelID: 9, ChannelType: int(constant.ProviderClaude)}
a := s.BeginAttempt(selection, "m", "chat_completions", false, 0)
// 客户端 openai → claude 渠道:上游协议 claude,链路兜底两端
info := &RelayInfo{
InboundFormat: constant.RelayFormatOpenAI,
ChannelMeta: &ChannelMeta{ChannelType: int(constant.ProviderClaude)},
}
info.AppendRequestConversion(types.RelayFormat(constant.RelayFormatOpenAI))
a.CaptureProtocol(info)
if a.conversion == nil {
t.Fatal("conversion 未快照")
}
if a.conversion.ClientFormat != "openai" || a.conversion.UpstreamFormat != "claude" {
t.Errorf("协议推导错误: %+v", a.conversion)
}
if a.conversion.Bridge != "" {
t.Errorf("常规转换不应有桥接标志: %q", a.conversion.Bridge)
}
// 直传:上游协议 = 客户端协议
info2 := &RelayInfo{
InboundFormat: constant.RelayFormatOpenAI,
ChannelMeta: &ChannelMeta{
ChannelType: int(constant.ProviderOpenAI),
Settings: ChannelSettings{PassThroughBodyEnabled: true},
},
}
a2 := s.BeginAttempt(selection, "m", "chat_completions", false, 1)
a2.CaptureProtocol(info2)
if a2.conversion.UpstreamFormat != "openai" || a2.conversion.Bridge != "pass_through" {
t.Errorf("直传推导错误: %+v", a2.conversion)
}
// Responses 桥接
info3 := &RelayInfo{
InboundFormat: constant.RelayFormatOpenAI,
UseResponsesAPI: true,
ChannelMeta: &ChannelMeta{ChannelType: int(constant.ProviderOpenAI)},
}
a3 := s.BeginAttempt(selection, "m", "chat_completions", false, 2)
a3.CaptureProtocol(info3)
if a3.conversion.UpstreamFormat != "responses" || a3.conversion.Bridge != "responses_api" {
t.Errorf("Responses 桥接推导错误: %+v", a3.conversion)
}
}
+8 -6
View File
@@ -48,13 +48,15 @@ var longRunSharedTransport = &http.Transport{
}
// nonStreamClient / streamClient 预创建的单例 Client(无代理)。
// Transport 统一经 WrapDebugTransport 包装:渠道调试开关开启的请求经 ctx 捕获器
// 镜像上游报文,其余请求一次 ctx 查找后直接透传(连接池/H2 仍由底层 Transport 管理)。
var (
nonStreamClient = &http.Client{
Transport: sharedTransport,
Transport: WrapDebugTransport(sharedTransport),
Timeout: 300 * time.Second,
}
streamClient = &http.Client{
Transport: sharedTransport,
Transport: WrapDebugTransport(sharedTransport),
}
)
@@ -140,11 +142,11 @@ func getProxiedClients() (*http.Client, *http.Client) {
proxiedState.longRun = buildProxiedTransport(proxyURL, 0)
proxiedState.nonStream = &http.Client{
Transport: proxiedState.transport,
Transport: WrapDebugTransport(proxiedState.transport),
Timeout: 300 * time.Second,
}
proxiedState.stream = &http.Client{
Transport: proxiedState.transport,
Transport: WrapDebugTransport(proxiedState.transport),
}
proxiedState.proxyURL = proxyURL
@@ -179,7 +181,7 @@ func NewPooledClient(timeoutSeconds int, useProxy bool, isStream ...bool) *http.
}
proxiedState.mu.RUnlock()
return &http.Client{
Transport: transport,
Transport: WrapDebugTransport(transport),
Timeout: time.Duration(timeoutSeconds) * time.Second,
}
}
@@ -195,7 +197,7 @@ func NewPooledClient(timeoutSeconds int, useProxy bool, isStream ...bool) *http.
transport = longRunSharedTransport
}
return &http.Client{
Transport: transport,
Transport: WrapDebugTransport(transport),
Timeout: time.Duration(timeoutSeconds) * time.Second,
}
}
+13 -10
View File
@@ -11,13 +11,19 @@ import (
// 避免图片/音频等长耗时同步请求被 180s 头超时误杀。
// - timeout ≤ 180s → 普通传输层(ResponseHeaderTimeout=180s),保留对短超时请求的假死保护。
// 非代理路径直接断言底层 *http.Transport 的字段,是最稳定的判定方式。
// Transport 统一经 WrapDebugTransport 包装(渠道调试日志捕获),测试中先解包再断言底层传输层。
// unwrapDebugTransport 解开调试捕获包装,返回底层 *http.Transport
func unwrapDebugTransport(rt http.RoundTripper) *http.Transport {
if d, ok := rt.(*DebugRoundTripper); ok {
return d.Base.(*http.Transport)
}
return rt.(*http.Transport)
}
func TestNewPooledClient_LongRunDisablesResponseHeaderTimeout(t *testing.T) {
c := NewPooledClient(600, false) // 图片生成的典型超时(GetTimeoutSeconds 下限)
tr, ok := c.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", c.Transport)
}
tr := unwrapDebugTransport(c.Transport)
if tr.ResponseHeaderTimeout != 0 {
t.Errorf("long-run transport: ResponseHeaderTimeout=%v, want 0 (disabled)", tr.ResponseHeaderTimeout)
}
@@ -28,20 +34,17 @@ func TestNewPooledClient_LongRunDisablesResponseHeaderTimeout(t *testing.T) {
func TestNewPooledClient_LongRunAtBoundary(t *testing.T) {
// 恰好 181s 触发 longRun;180s 仍走普通传输层
if c := NewPooledClient(181, false); c.Transport != longRunSharedTransport {
if tr := unwrapDebugTransport(NewPooledClient(181, false).Transport); tr != longRunSharedTransport {
t.Error("timeout=181s should select longRunSharedTransport")
}
if c := NewPooledClient(180, false); c.Transport != sharedTransport {
if tr := unwrapDebugTransport(NewPooledClient(180, false).Transport); tr != sharedTransport {
t.Error("timeout=180s should select sharedTransport")
}
}
func TestNewPooledClient_NormalKeepsResponseHeaderTimeout(t *testing.T) {
c := NewPooledClient(60, false) // 普通对话默认超时
tr, ok := c.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", c.Transport)
}
tr := unwrapDebugTransport(c.Transport)
if tr.ResponseHeaderTimeout != defaultResponseHeaderTimeout {
t.Errorf("normal transport: ResponseHeaderTimeout=%v, want %v", tr.ResponseHeaderTimeout, defaultResponseHeaderTimeout)
}
+34
View File
@@ -84,6 +84,31 @@ type ChannelSettings struct {
// UseProxy 启用代理,使用系统配置的代理地址转发请求
UseProxy bool `json:"use_proxy,omitempty"`
// DebugLogEnabled 启用渠道调试日志:记录经该渠道每次请求尝试的四段完整报文
//(客户端↔系统↔上游,body 不截断、凭证脱敏)。数据量大,仅排查问题时开启
DebugLogEnabled bool `json:"debug_log_enabled,omitempty"`
// 调试目标过滤(AND 组合,0 = 不限):只捕捉匹配租户/成员/密钥的请求,
// 避免调试期间把全部租户的报文都录下来
DebugLogTenantID int64 `json:"debug_log_tenant_id,omitempty"`
DebugLogUserID int64 `json:"debug_log_user_id,omitempty"`
DebugLogApiKeyID int64 `json:"debug_log_api_key_id,omitempty"`
}
// DebugTargetMatch 调试目标过滤:已设置的过滤器全部匹配(AND),未设置(0)= 不限。
// 在渠道调试开关开启的前提下由 relay 接线处调用,不匹配则整条捕获链跳过。
func (s ChannelSettings) DebugTargetMatch(tenantID, userID, apiKeyID int64) bool {
if s.DebugLogTenantID > 0 && tenantID != s.DebugLogTenantID {
return false
}
if s.DebugLogUserID > 0 && userID != s.DebugLogUserID {
return false
}
if s.DebugLogApiKeyID > 0 && apiKeyID != s.DebugLogApiKeyID {
return false
}
return true
}
// RelayInfo 代理请求上下文,贯穿整个 relay 请求链路
@@ -290,6 +315,15 @@ func (info *RelayInfo) AppendRequestConversion(format types.RelayFormat) {
info.conversionChain = append(info.conversionChain, format)
}
// ConversionChain 返回请求侧协议转换链(relaykit 转换器追加;直传/同构路径可能为空)。
// 供渠道调试日志记录协议转换轨迹。
func (info *RelayInfo) ConversionChain() []types.RelayFormat {
if info == nil {
return nil
}
return info.conversionChain
}
func (info *RelayInfo) ConvOptions() *convmeta.Options {
if info == nil {
return &convmeta.Options{}
+6 -4
View File
@@ -2,6 +2,7 @@ package common
import (
"context"
"time"
"github.com/shopspring/decimal"
)
@@ -27,8 +28,9 @@ type TaskBillingProvider interface {
// CheckApiKeyQuota 检查 API Key 额度是否足够
CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount decimal.Decimal) error
// SettleTaskSuccess 任务成功结算(含计费快照)
SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]float64, taskID string) (*SettlementResult, error)
// SettleTaskSuccess 任务成功结算(含计费快照)。billAt 为任务受理时刻(时段定价按该时刻评估,
// 异步任务结算滞后数分钟~小时,按结算时刻会丢失受理时的时段价)
SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]float64, taskID string, billAt time.Time) (*SettlementResult, error)
// SettleTaskFailed 任务失败退还预扣
SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount decimal.Decimal) error
@@ -39,8 +41,8 @@ type TaskBillingProvider interface {
// AdjustTaskBilling 调整预扣金额(提交后上游确认了新参数)
AdjustTaskBilling(ctx context.Context, tenantID int64, requestID string, preDeductAmount, newEstimatedCost decimal.Decimal) (decimal.Decimal, error)
// RecalculateByTokens 根据上游返回的 total_tokens 重算费用
// RecalculateByTokens 根据上游返回的 total_tokens 重算费用(billAt 为任务受理时刻,时段定价用)
// totalTokens: 上游返回的 token 计费单位
// ratios: 提交时保存的计费比率(如 video_input 折扣)
RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64) (decimal.Decimal, error)
RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64, billAt time.Time) (decimal.Decimal, error)
}
+2 -2
View File
@@ -321,14 +321,14 @@ func HandleRealtime(w http.ResponseWriter, r *http.Request, rc *RealtimeContext,
streamUsage = &common.Usage{}
}
settleResult, _ := billing.SettleStreamInterrupted(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
modelName, rc.RequestID, "realtime", streamUsage, preDeductAmount, rc.ProjectID)
modelName, rc.RequestID, "realtime", streamUsage, preDeductAmount, rc.ProjectID, info.StartTime)
if settleResult != nil && settleResult.ActualCost > 0 && !settleResult.DuplicateSkip {
billing.IncrMemberQuotaUsed(ctx, rc.TenantID, rc.UserID, settleResult.ActualCost)
billing.IncrApiKeyQuotaUsed(ctx, rc.ApiKeyID, settleResult.ActualCost)
}
} else if usage != nil {
settleResult, _ := billing.Settle(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
modelName, rc.RequestID, "realtime", usage, preDeductAmount, rc.ProjectID)
modelName, rc.RequestID, "realtime", usage, preDeductAmount, rc.ProjectID, info.StartTime)
if settleResult != nil && settleResult.ActualCost > 0 && !settleResult.DuplicateSkip {
billing.IncrMemberQuotaUsed(ctx, rc.TenantID, rc.UserID, settleResult.ActualCost)
billing.IncrApiKeyQuotaUsed(ctx, rc.ApiKeyID, settleResult.ActualCost)
+37 -3
View File
@@ -41,6 +41,7 @@ type RelayContext struct {
KeyTotalQuota float64 // API Key 总额度,0 表示不限制
KeyUsedQuota float64 // API Key 已用额度(仅展示/审计,额度检查会鲜读 DB)
ForwardingTrace *common.ForwardingTrace // 转发路径追踪(仅管理员可见)
Debug *common.DebugSession // 渠道调试日志会话(仅调试开关开启的请求非 nil)
}
// BillingResult 计费结果(返回给调用方用于设置响应头)
@@ -569,7 +570,30 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
"this image model uses asynchronous generation on Alibaba DashScope; submit via POST /v1/images/generations/async and poll for the result", nil)
}
info := buildRelayInfo(ctx, rc, v, selection, path, headers, attempt)
// 渠道调试日志:首个开启调试且匹配目标过滤(租户/成员/密钥,AND 组合,0=不限)的尝试
// 创建会话(捕获段1客户端请求 + 包装段4 writer,writer 保持到请求结束——中间失败尝试
// 不向客户端写字节,累积字节即最终响应);每轮尝试独立捕获器经 attemptCtx 传递给传输层。
// 必须用独立变量 attemptCtx,不可重赋值 ctx:否则陈旧捕获器会泄漏进下一轮 sess.Next
// 选中无调试渠道的请求。
var dbgAttempt *common.DebugAttempt
if selection.Settings.DebugLogEnabled &&
selection.Settings.DebugTargetMatch(rc.TenantID, rc.UserID, rc.ApiKeyID) {
if rc.Debug == nil {
rc.Debug = common.NewDebugSession(rc.RequestID, rc.TenantID, rc.UserID, rc.ApiKeyID, path)
rc.Debug.CaptureClientRequest(headers, body)
dw := common.NewDebugClientWriter(rc.Writer)
rc.Debug.SetClientWriter(dw)
rc.Writer = dw
}
dbgAttempt = rc.Debug.BeginAttempt(selection, v.modelName, v.relayModeStr, v.isStream, attempt)
}
attemptCtx := ctx
if dbgAttempt != nil {
attemptCtx = common.WithDebugAttempt(ctx, dbgAttempt.Capture)
}
info := buildRelayInfo(attemptCtx, rc, v, selection, path, headers, attempt)
dbgAttempt.CaptureProtocol(info)
if tr := monitor.GetTrackedRequest(rc.RequestID); tr != nil {
tr.ChannelID = selection.ChannelID
@@ -582,6 +606,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
if billing != nil && preDeductAmount > 0 {
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
}
dbgAttempt.MarkFinal(fmt.Errorf("unsupported channel type: %d", selection.ChannelType))
return nil, v.billingResult, fmt.Errorf("unsupported channel type: %d", selection.ChannelType)
}
adaptor.Init(info)
@@ -607,21 +632,24 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
if billing != nil && preDeductAmount > 0 {
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
}
dbgAttempt.MarkFinal(err)
return nil, v.billingResult, constant.NewRequestError(
"previous_response_id is only supported on responses-native channels; no eligible channel remains for this model. Resend the full conversation input without previous_response_id.", nil)
}
dbgAttempt.Submit(err)
continue
}
if billing != nil && preDeductAmount > 0 {
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
}
dbgAttempt.MarkFinal(err)
return nil, v.billingResult, err
}
// 容量租约已由调度器在 Next 内获取;长流式请求由续期器保活
leaseRefresh := startDispatchLeaseRefresher(d.Channel.ID, rc.RequestID)
upstreamCtx := context.WithoutCancel(ctx)
upstreamCtx := context.WithoutCancel(attemptCtx)
settleCtx, settleCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer settleCancel()
@@ -645,6 +673,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
appendHop(trace, hop, false, err.Error(), info.LatencyMs())
settleCancel()
sleepBackoff(ctx, backoff)
dbgAttempt.Submit(err)
continue
}
@@ -654,6 +683,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err)
recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs())
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
dbgAttempt.MarkFinal(err)
return nil, v.billingResult, helper.RemapStatusCode(constant.NewUpstreamError(502, "upstream request failed", err), info.ChannelMeta.Settings.StatusCodeMapping)
}
@@ -678,7 +708,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
}
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)
v.modelName, rc.RequestID, v.relayModeStr, streamUsage, preDeductAmount, rc.ProjectID, info.StartTime)
if err != nil {
g.Log().Warningf(settleCtx, "[RelayHandler] Stream interrupted settlement failed: tenant=%d project=%d request=%s err=%v",
rc.TenantID, rc.ProjectID, rc.RequestID, err)
@@ -695,6 +725,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
}
recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage)
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
dbgAttempt.MarkFinal(err)
return usage, v.billingResult, err
}
@@ -733,6 +764,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
appendHop(trace, hop, false, err.Error(), info.LatencyMs())
settleCancel()
sleepBackoff(ctx, backoff)
dbgAttempt.Submit(err)
continue
}
@@ -742,10 +774,12 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err)
recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs())
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
dbgAttempt.MarkFinal(err)
return nil, v.billingResult, err
}
// 成功路径:绑定续期 + 健康上报由 Finish 完成
dbgAttempt.MarkFinal(nil)
sess.Finish(settleCtx, true, info.LatencyMs())
appendHop(trace, hop, true, "", info.LatencyMs())
trace.TotalAttempts = attempt + 1
@@ -1,578 +0,0 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import request from '@/utils/request'
const props = defineProps<{
visible: boolean
modelId: number | null
modelIdStr: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
saved: []
}>()
// ============================================================
// Pricing Editor State
// ============================================================
const editorLoading = ref(false)
const editorSaving = ref(false)
const editorBillingMode = ref('token')
const editorItems = reactive<any[]>([])
const billingModeOptions = [
{ label: '按量计费 (Token)', value: 'token' },
{ label: '按次计费', value: 'per_request' },
{ label: '阶梯计费', value: 'tiered' },
]
function createEmptyItem(mode: string) {
return {
billing_mode: mode,
min_tokens: 0,
max_tokens: null,
input_price: 0,
output_price: 0,
per_request_price: null,
cache_read_price: 0,
cache_creation_price: 0,
}
}
function resetEditorDefaults() {
editorBillingMode.value = 'token'
editorItems.length = 0
editorItems.push(createEmptyItem('token'))
}
// ============================================================
// Official Pricing Fetch
// ============================================================
const officialLoading = ref(false)
const officialData = ref<any>(null)
const officialError = ref('')
function resetOfficialData() {
officialData.value = null
officialError.value = ''
}
async function fetchOfficialPricing() {
if (!props.modelId) return
officialLoading.value = true
officialError.value = ''
officialData.value = null
try {
const res: any = await request.get(`/admin/models/${props.modelId}/official-pricing`)
officialData.value = res.data?.data
} catch {
officialError.value = '获取官方定价失败'
} finally {
officialLoading.value = false
}
}
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
}
if (pricing.billing_mode && pricing.billing_mode !== editorBillingMode.value) {
editorBillingMode.value = pricing.billing_mode
}
Message.success('已填入官方参考定价')
}
// ============================================================
// Load Pricing Data
// ============================================================
async function loadPricing() {
if (!props.modelId) return
editorItems.length = 0
editorBillingMode.value = 'token'
resetOfficialData()
editorLoading.value = true
try {
const res: any = await request.get(`/admin/models/${props.modelId}/pricing`)
const list: any[] = res.data?.data?.list || []
if (list.length > 0) {
editorBillingMode.value = list[0].billing_mode || 'token'
editorItems.length = 0
for (const item of list) {
editorItems.push({
billing_mode: item.billing_mode,
min_tokens: item.min_tokens || 0,
max_tokens: item.max_tokens ?? null,
input_price: item.input_price ?? 0,
output_price: item.output_price ?? 0,
per_request_price: item.per_request_price ?? null,
cache_read_price: item.cache_read_price ?? 0,
cache_creation_price: item.cache_creation_price ?? 0,
})
}
} else {
resetEditorDefaults()
}
} catch {
resetEditorDefaults()
} finally {
editorLoading.value = false
}
}
// ============================================================
// Tiered Mode Helpers
// ============================================================
watch(editorBillingMode, (mode) => {
if (mode === 'tiered') {
if (editorItems.length === 0) {
editorItems.push(createEmptyItem('tiered'))
}
} else {
if (editorItems.length === 0) {
editorItems.push(createEmptyItem(mode))
} else {
editorItems[0].billing_mode = mode
}
}
})
function addTier() {
const last = editorItems[editorItems.length - 1]
const newMin = last?.max_tokens ?? 0
editorItems.push(createEmptyItem('tiered'))
const newItem = editorItems[editorItems.length - 1]
newItem.min_tokens = newMin
if (last && last.max_tokens === null) {
last.max_tokens = newMin
}
}
function removeTier(index: number) {
editorItems.splice(index, 1)
if (editorItems.length > 0) {
editorItems[editorItems.length - 1].max_tokens = null
}
}
// ============================================================
// Save
// ============================================================
async function savePricing() {
if (!props.modelId) return
editorSaving.value = true
try {
const items = editorItems.map((item, index) => ({
billing_mode: editorBillingMode.value,
min_tokens: editorBillingMode.value === 'tiered' ? item.min_tokens : 0,
max_tokens: editorBillingMode.value === 'tiered' ? (index < editorItems.length - 1 ? item.max_tokens : null) : null,
input_price: item.input_price,
output_price: item.output_price,
per_request_price: editorBillingMode.value === 'per_request' ? (item.per_request_price ?? null) : null,
cache_read_price: item.cache_read_price,
cache_creation_price: item.cache_creation_price,
}))
await request.put(`/admin/models/${props.modelId}/pricing`, { items })
Message.success('定价已保存')
emit('update:visible', false)
emit('saved')
} catch {
// error handled by interceptor
} finally {
editorSaving.value = false
}
}
// ============================================================
// Watchers
// ============================================================
// 打开时自动加载定价数据
watch(() => props.visible, (val) => {
if (val && props.modelId) {
loadPricing()
}
})
// 关闭时重置状态
watch(() => props.visible, (val) => {
if (!val) {
resetEditorDefaults()
resetOfficialData()
}
})
</script>
<template>
<ADrawer
:visible="visible"
:width="580"
:title="modelId ? `定价设置 - ${modelIdStr}` : '定价设置'"
:mask-closable="false"
:footer="true"
@cancel="emit('update:visible', false)"
>
<template #footer>
<AButton @click="emit('update:visible', false)">取消</AButton>
<AButton type="primary" :loading="editorSaving" @click="savePricing">保存定价</AButton>
</template>
<ASpin :loading="editorLoading" style="width: 100%">
<AForm :model="{}" layout="vertical">
<div class="pricing-editor">
<!-- Official Pricing Reference -->
<div class="editor-section">
<div class="editor-section-header">
<h3>官方参考定价</h3>
<span class="section-hint">数据来源: LiteLLM + models.dev</span>
</div>
<AButton
type="outline"
size="small"
:loading="officialLoading"
@click="fetchOfficialPricing"
>拉取官方定价</AButton>
<div v-if="officialData" class="official-pricing-card mt-3">
<div v-for="src in officialData.sources" :key="src.source" class="official-source-block">
<div class="official-source-header">
<ATag size="small" :color="src.source === 'litellm' ? 'arcoblue' : 'green'">{{ src.source === 'litellm' ? 'LiteLLM' : 'models.dev' }}</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>
<span v-if="src.max_context_tokens" class="official-meta-text">上下文: {{ (src.max_context_tokens / 1000).toFixed(0) }}K</span>
<span v-if="src.max_output_tokens" class="official-meta-text">输出上限: {{ (src.max_output_tokens / 1000).toFixed(0) }}K</span>
</template>
</div>
<template v-if="src.found && src.pricing">
<div class="official-prices mt-2">
<div class="official-price-row">
<span class="official-price-label">输入价格</span>
<span class="official-price-value">${{ src.pricing.input_price.toFixed(2) }} / 1M</span>
</div>
<div class="official-price-row">
<span class="official-price-label">输出价格</span>
<span class="official-price-value">${{ src.pricing.output_price.toFixed(2) }} / 1M</span>
</div>
<div class="official-price-row">
<span class="official-price-label">缓存读取</span>
<span class="official-price-value">{{ src.pricing.cache_read_price ? '$' + src.pricing.cache_read_price.toFixed(2) + ' / 1M' : '—' }}</span>
</div>
<div class="official-price-row">
<span class="official-price-label">缓存创建</span>
<span class="official-price-value">{{ src.pricing.cache_creation_price ? '$' + src.pricing.cache_creation_price.toFixed(2) + ' / 1M' : '—' }}</span>
</div>
</div>
<AButton type="primary" size="small" class="mt-2" @click="applyOfficialPricing(src.pricing)">应用此价格</AButton>
</template>
<template v-else>
<div v-if="src.error" class="official-not-found" style="color: rgb(var(--danger-6))">获取远程数据失败: {{ src.error }}</div>
<div v-else class="official-not-found">未找到定价数据</div>
</template>
</div>
</div>
<div v-if="officialError" class="official-error mt-2">{{ officialError }}</div>
</div>
<!-- Billing Mode -->
<div class="editor-section">
<div class="editor-section-header">
<h3>计费模式</h3>
</div>
<ARadioGroup v-model="editorBillingMode" type="button">
<ARadio v-for="opt in billingModeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</ARadio>
</ARadioGroup>
</div>
<!-- Per-request Mode -->
<template v-if="editorBillingMode === 'per_request' && editorItems.length > 0">
<div class="editor-section">
<div class="editor-section-header">
<h3>按次定价</h3>
</div>
<div class="form-row">
<AFormItem label="按次单价" class="flex-1">
<AInputNumber
v-model="editorItems[0].per_request_price"
:min="0"
:precision="4"
placeholder="每次调用价格"
class="w-full"
>
<template #suffix>$ / 次</template>
</AInputNumber>
</AFormItem>
</div>
</div>
</template>
<!-- Token Mode -->
<template v-else-if="editorBillingMode === 'token' && editorItems.length > 0">
<div class="editor-section">
<div class="editor-section-header">
<h3>按量定价</h3>
<span class="section-hint">价格单位为 $ / 1M Token</span>
</div>
<div class="form-row form-row--two">
<AFormItem label="输入价格" class="flex-1">
<AInputNumber
v-model="editorItems[0].input_price"
:min="0"
:precision="4"
placeholder="0"
class="w-full"
>
<template #suffix>$ / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格" class="flex-1">
<AInputNumber
v-model="editorItems[0].output_price"
:min="0"
:precision="4"
placeholder="0"
class="w-full"
>
<template #suffix>$ / 1M</template>
</AInputNumber>
</AFormItem>
</div>
<div class="form-row form-row--two mt-3">
<AFormItem label="缓存读取价格" class="flex-1">
<AInputNumber
v-model="editorItems[0].cache_read_price"
:min="0"
:precision="4"
placeholder="0"
class="w-full"
>
<template #suffix>$ / 1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建价格" class="flex-1">
<AInputNumber
v-model="editorItems[0].cache_creation_price"
:min="0"
:precision="4"
placeholder="0"
class="w-full"
>
<template #suffix>$ / 1M</template>
</AInputNumber>
</AFormItem>
</div>
</div>
</template>
<!-- Tiered Mode -->
<template v-else>
<div class="editor-section">
<div class="editor-section-header">
<h3>阶梯定价</h3>
<span class="section-hint">按 Token 用量分段设置不同价格</span>
</div>
<div v-for="(tier, index) in editorItems" :key="index" class="tier-card">
<div class="tier-header">
<span class="tier-label">第 {{ index + 1 }} 梯</span>
<AButton
v-if="editorItems.length > 1"
size="mini"
status="danger"
@click="removeTier(index)"
>删除</AButton>
</div>
<div class="form-row form-row--two">
<AFormItem label="起始 Token" class="flex-1">
<AInputNumber
v-model="tier.min_tokens"
:min="0"
:step="1000"
placeholder="0"
class="w-full"
/>
</AFormItem>
<AFormItem label="结束 Token" class="flex-1">
<AInputNumber
v-if="index < editorItems.length - 1"
v-model="tier.max_tokens"
:min="0"
:step="1000"
placeholder="上限"
class="w-full"
/>
<AInput v-else model-value="无上限" disabled class="w-full" />
</AFormItem>
</div>
<div class="form-row form-row--two mt-2">
<AFormItem label="输入价格" class="flex-1">
<AInputNumber
v-model="tier.input_price"
:min="0"
:precision="4"
class="w-full"
>
<template #suffix>$/1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="输出价格" class="flex-1">
<AInputNumber
v-model="tier.output_price"
:min="0"
:precision="4"
class="w-full"
>
<template #suffix>$/1M</template>
</AInputNumber>
</AFormItem>
</div>
<div class="form-row form-row--two mt-2">
<AFormItem label="缓存读取价格" class="flex-1">
<AInputNumber
v-model="tier.cache_read_price"
:min="0"
:precision="4"
class="w-full"
>
<template #suffix>$/1M</template>
</AInputNumber>
</AFormItem>
<AFormItem label="缓存创建价格" class="flex-1">
<AInputNumber
v-model="tier.cache_creation_price"
:min="0"
:precision="4"
class="w-full"
>
<template #suffix>$/1M</template>
</AInputNumber>
</AFormItem>
</div>
</div>
<AButton type="dashed" long @click="addTier" class="mt-3">+ 添加梯度</AButton>
</div>
</template>
</div>
</AForm>
</ASpin>
</ADrawer>
</template>
<style scoped>
.editor-section {
margin-bottom: 24px;
}
.editor-section-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.editor-section-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--ta-text-primary);
margin: 0;
}
.section-hint {
font-size: 12px;
color: var(--ta-text-tertiary);
}
.form-row {
display: flex;
gap: 16px;
}
.form-row--two {
flex-wrap: wrap;
}
.tier-card {
padding: 12px 16px;
background: var(--color-fill-1);
border-radius: 8px;
margin-bottom: 8px;
}
.tier-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.tier-label {
font-size: 13px;
font-weight: 600;
color: var(--ta-text-secondary);
}
.official-pricing-card {
padding: 12px 16px;
background: var(--color-fill-1);
border-radius: 8px;
border: 1px solid var(--ta-border-light);
}
.official-source-block {
padding: 10px 0;
border-bottom: 1px dashed var(--ta-border-light);
}
.official-source-block:last-child {
border-bottom: none;
padding-bottom: 0;
}
.official-source-block:first-child {
padding-top: 0;
}
.official-source-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.official-meta-text {
font-size: 12px;
color: var(--ta-text-tertiary);
}
.official-price-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.official-price-label {
font-size: 13px;
color: var(--ta-text-secondary);
}
.official-price-value {
font-size: 13px;
font-weight: 500;
color: var(--ta-text-primary);
font-family: monospace;
}
.official-not-found {
font-size: 13px;
color: var(--ta-text-tertiary);
}
.official-error {
font-size: 13px;
color: rgb(var(--danger-6));
}
</style>
File diff suppressed because it is too large. Load diff
+615 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h, nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Tag, Button, Popconfirm, Message, RadioGroup, Radio, InputNumber, Input, Switch, Alert } from '@arco-design/web-vue'
import { Tag, Button, Popconfirm, Message, RadioGroup, Radio, InputNumber, Input, Switch, Alert, DatePicker } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
@@ -501,6 +501,451 @@ function handleTrendHoursChange(val: string | number | boolean) {
fetchHealthTrend()
}
// === Tab 5: Debug Logs(渠道调试日志) ===
const debugLogs = ref<any[]>([])
const debugLoading = ref(false)
const debugPage = ref(1)
const debugPageSize = 20
const debugTotal = ref(0)
const debugFilter = reactive({
request_id: '',
model_name: '',
only_error: false,
dateRange: undefined as string[] | undefined,
})
const debugStats = ref<{ total: number; total_bytes: number; oldest_at: string } | null>(null)
const debugSwitchLoading = ref(false)
// 详情抽屉
const showDebugDetail = ref(false)
const debugDetailLoading = ref(false)
const debugDetail = ref<any>(null)
function formatBytes(n: number | null | undefined): string {
if (n === null || n === undefined) return '-'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(2)} MB`
}
async function fetchDebugStats() {
try {
const res: any = await request.get(`/admin/channels/${channelId}/debug-logs/stats`)
debugStats.value = res.data?.data || null
} catch {
debugStats.value = null
}
}
async function fetchDebugLogs() {
debugLoading.value = true
try {
const params: Record<string, any> = { page: debugPage.value, page_size: debugPageSize }
if (debugFilter.request_id) params.request_id = debugFilter.request_id
if (debugFilter.model_name) params.model_name = debugFilter.model_name
if (debugFilter.only_error) params.only_error = true
if (debugFilter.dateRange && debugFilter.dateRange.length === 2) {
params.start_date = debugFilter.dateRange[0]
params.end_date = debugFilter.dateRange[1]
}
const res: any = await request.get(`/admin/channels/${channelId}/debug-logs`, { params })
const raw = res.data?.data
debugLogs.value = Array.isArray(raw?.list) ? raw.list : []
debugTotal.value = raw?.total || 0
} catch {
debugLogs.value = []
debugTotal.value = 0
} finally {
debugLoading.value = false
}
fetchDebugStats()
}
function handleDebugFilter() {
debugPage.value = 1
fetchDebugLogs()
}
function resetDebugFilter() {
debugFilter.request_id = ''
debugFilter.model_name = ''
debugFilter.only_error = false
debugFilter.dateRange = undefined
debugPage.value = 1
fetchDebugLogs()
}
function handleDebugPageChange(page: number) {
debugPage.value = page
fetchDebugLogs()
}
// 调试开关:乐观更新 + 失败回滚
async function toggleDebugLog(value: string | number | boolean) {
const enabled = Boolean(value)
if (!detail.value) return
debugSwitchLoading.value = true
try {
await request.put(`/admin/channels/${channelId}`, { debug_log_enabled: enabled })
detail.value.debug_log_enabled = enabled
Message.success(enabled ? '调试日志已开启,新的请求将记录四段完整报文' : '调试日志已关闭')
fetchDetail()
} catch {
// 失败保持原状态,interceptor 已提示
} finally {
debugSwitchLoading.value = false
}
}
async function openDebugDetail(row: any) {
showDebugDetail.value = true
debugDetailLoading.value = true
debugDetail.value = null
try {
const res: any = await request.get(`/admin/channels/${channelId}/debug-logs/${row.id}`)
debugDetail.value = res.data?.data?.data || null
} catch {
debugDetail.value = null
} finally {
debugDetailLoading.value = false
}
}
async function deleteDebugLog(row: any) {
try {
await request.delete(`/admin/channels/${channelId}/debug-logs/${row.id}`)
Message.success('已删除')
fetchDebugLogs()
} catch {
// error handled by interceptor
}
}
async function clearDebugLogs() {
try {
await request.delete(`/admin/channels/${channelId}/debug-logs`)
Message.success('调试日志已清空')
fetchDebugLogs()
} catch {
// error handled by interceptor
}
}
// 详情四段定义(标题/字段前缀),供抽屉 collapse 渲染
const debugSegments = computed(() => {
const d = debugDetail.value
if (!d) return []
const seg = (prefix: string, title: string) => {
const headers = d[`${prefix}_headers`]
const ct = headers?.['Content-Type'] || headers?.['content-type'] || ''
return {
key: prefix,
title,
headers,
body: d[`${prefix}_body`] || '',
encoding: d[`${prefix}_encoding`] || 'plain',
bytes: d[`${prefix}_bytes`],
contentType: ct,
// base64 且内容类型为图片时可预览(JSON 内嵌 b64 图片不自动预览,可复制后查看)
imageUri: d[`${prefix}_encoding`] === 'base64' && ct.startsWith('image/')
? `data:${ct};base64,${d[`${prefix}_body`]}` : '',
bodyText: d[`${prefix}_encoding`] === 'base64' ? '' : prettyJson(d[`${prefix}_body`]),
}
}
return [
seg('client_req', '① 客户端 → 系统(请求)'),
seg('upstream_req', '② 系统 → 上游(请求,协议转换后 · 凭证已脱敏)'),
seg('upstream_resp', '③ 上游 → 系统(响应原文)'),
seg('client_resp', '④ 系统 → 客户端(响应,协议转换后)'),
]
})
function prettyJson(body: string): string {
if (!body) return ''
try { return JSON.stringify(JSON.parse(body), null, 2) } catch { return body }
}
// === 调试目标过滤(租户 → 成员 → 密钥 联动,全部用 ID,不选 = 不限) ===
interface DebugSelectOption { label: string; value: number }
const debugTarget = reactive({
tenantId: null as number | null,
userId: null as number | null,
apiKeyId: null as number | null,
})
const debugTenantOptions = ref<DebugSelectOption[]>([])
const debugUserOptions = ref<DebugSelectOption[]>([])
const debugKeyOptions = ref<DebugSelectOption[]>([])
const debugTargetSaving = ref(false)
const debugTargetLoaded = ref(false)
// mergeOptions 合并下拉选项并去重,保留已选中项避免回显丢失
function mergeOptions(target: { value: DebugSelectOption[] }, incoming: DebugSelectOption[]) {
const map = new Map(target.value.map(o => [o.value, o]))
for (const o of incoming) map.set(o.value, o)
target.value = Array.from(map.values())
}
async function searchDebugTenants(keyword: string) {
try {
const res: any = await request.get('/admin/tenants/select', { params: { keyword: keyword || '', page_size: 50 } })
const list = res.data?.data?.list || []
mergeOptions(debugTenantOptions, list.map((t: any) => ({ label: `${t.name}(${t.code})`, value: t.id })))
} catch {
// error handled by interceptor
}
}
async function loadDebugUsers(tenantId: number) {
try {
const res: any = await request.get('/admin/members', { params: { tenant_id: tenantId, page_size: 200 } })
const list = res.data?.data?.list || []
debugUserOptions.value = list.map((m: any) => ({
label: m.display_name && m.display_name !== m.username ? `${m.display_name}(${m.username})` : m.username,
value: m.id,
}))
} catch {
debugUserOptions.value = []
}
}
async function loadDebugKeys(tenantId: number, userId: number | null) {
try {
const params: Record<string, any> = { page_size: 200 }
if (userId) params.user_id = userId
const res: any = await request.get(`/admin/tenants/${tenantId}/api-keys/select`, { params })
const list = res.data?.data?.list || []
debugKeyOptions.value = list.map((k: any) => ({
label: `${k.name || '未命名'}(${k.key_prefix}…${k.status && k.status !== 'active' ? ' · 已停用' : ''})`,
value: k.id,
}))
} catch {
debugKeyOptions.value = []
}
}
function handleDebugTenantChange(v: any) {
debugTarget.tenantId = v ?? null
// 级联清空下游选择与选项
debugTarget.userId = null
debugTarget.apiKeyId = null
debugUserOptions.value = []
debugKeyOptions.value = []
if (debugTarget.tenantId) {
loadDebugUsers(debugTarget.tenantId)
loadDebugKeys(debugTarget.tenantId, null)
}
}
function handleDebugUserChange(v: any) {
debugTarget.userId = v ?? null
debugTarget.apiKeyId = null
if (debugTarget.tenantId) loadDebugKeys(debugTarget.tenantId, debugTarget.userId)
}
async function saveDebugTarget() {
debugTargetSaving.value = true
try {
await request.put(`/admin/channels/${channelId}`, {
debug_log_tenant_id: debugTarget.tenantId || 0,
debug_log_user_id: debugTarget.userId || 0,
debug_log_api_key_id: debugTarget.apiKeyId || 0,
})
Message.success('调试过滤条件已保存,新请求按过滤条件捕捉')
fetchDetail()
} catch {
// error handled by interceptor
} finally {
debugTargetSaving.value = false
}
}
// initDebugTarget 进入调试 tab 时回显已保存的过滤条件(解析名称,查不到显示 #ID)
async function initDebugTarget() {
if (debugTargetLoaded.value || !detail.value) return
debugTargetLoaded.value = true
searchDebugTenants('')
const t = detail.value.debug_log_tenant_id || 0
const u = detail.value.debug_log_user_id || 0
const k = detail.value.debug_log_api_key_id || 0
if (!t && !u && !k) return
debugTarget.tenantId = t || null
debugTarget.userId = u || null
debugTarget.apiKeyId = k || null
if (!t) return
// 回显租户名称
try {
const res: any = await request.get(`/admin/tenants/${t}`)
const item = res.data?.data || res.data
if (item?.name) debugTenantOptions.value = [{ label: `${item.name}(${item.code || ''})`, value: t }]
} catch {
debugTenantOptions.value = [{ label: `租户 #${t}`, value: t }]
}
await Promise.all([loadDebugUsers(t), loadDebugKeys(t, u || null)])
// 成员/密钥可能翻页外或已删除,兜底显示 #ID
if (u && !debugUserOptions.value.some(o => o.value === u)) {
debugUserOptions.value.unshift({ label: `成员 #${u}`, value: u })
}
if (k && !debugKeyOptions.value.some(o => o.value === k)) {
debugKeyOptions.value.unshift({ label: `密钥 #${k}`, value: k })
}
}
// 捕捉范围描述(警告条展示)
const debugTargetDesc = computed(() => {
const label = (opts: DebugSelectOption[], v: number | null) =>
opts.find(o => o.value === v)?.label || `#${v}`
const parts: string[] = []
if (debugTarget.tenantId) parts.push(`租户 ${label(debugTenantOptions.value, debugTarget.tenantId)}`)
if (debugTarget.userId) parts.push(`成员 ${label(debugUserOptions.value, debugTarget.userId)}`)
if (debugTarget.apiKeyId) parts.push(`密钥 ${label(debugKeyOptions.value, debugTarget.apiKeyId)}`)
return parts.length ? parts.join(' / ') : '全部请求(未设过滤)'
})
// 复制到剪切板(含非安全上下文降级)
function copyText(text: string, label = '内容') {
if (!text) { Message.warning('内容为空'); return }
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(() => {
Message.success(`${label}已复制`)
}).catch(() => {
Message.error('复制失败,请手动选择复制')
})
return
}
// 剪切板 API 不可用(如非 HTTPS 环境),降级 execCommand
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
if (ok) Message.success(`${label}已复制`)
else Message.error('复制失败,请手动选择复制')
}
// 段体复制:base64 段展示的只是提示文案,复制完整原始内容(base64 串)
function copySegBody(seg: { body: string; bodyText: string; encoding: string }) {
copyText(seg.encoding === 'base64' ? seg.body : seg.bodyText, '内容')
}
// 协议转换信息解析(JSONB 列可能以字符串返回)
function parseConversion(v: any): any {
if (!v) return null
if (typeof v === 'string') {
try { return JSON.parse(v) } catch { return null }
}
return v
}
const debugBridgeLabels: Record<string, string> = {
responses_api: 'Responses 桥接',
responses_direct: 'Responses 直连',
pass_through: '直传无转换',
}
// 列表紧凑展示:openai→claude(同构时单值)
function conversionShort(v: any): string {
const c = parseConversion(v)
if (!c) return ''
if (!c.client_format || !c.upstream_format) return ''
return c.client_format === c.upstream_format ? c.client_format : `${c.client_format}→${c.upstream_format}`
}
// 详情元信息条目(右侧统一带复制按钮)
const debugMetaItems = computed(() => {
const d = debugDetail.value
if (!d) return []
const items: { label: string; value: string; span?: number; color?: string; mono?: boolean }[] = [
{ label: 'Request ID', value: d.request_id || '' },
{ label: '时间', value: String(d.created_at || '') },
{ label: '模型', value: d.model_name || '' },
{ label: '上游模型', value: d.upstream_model || '' },
{ label: '转发模式', value: d.relay_mode || '' },
{ label: '入站路径', value: d.inbound_path || '' },
{ label: '尝试轮次', value: `#${d.retry_index}${d.is_final ? '(最终)' : '(中间失败)'}` },
{ label: '流式', value: d.is_stream ? '流式' : '非流式' },
{ label: '上游状态码', value: d.upstream_status_code ? String(d.upstream_status_code) : '', color: statusCodeColor(d.upstream_status_code) },
{ label: '客户端状态码', value: d.client_status_code ? String(d.client_status_code) : '', color: statusCodeColor(d.client_status_code) },
{ label: '上游耗时', value: d.upstream_latency_ms != null ? `${d.upstream_latency_ms}ms` : '' },
{
label: '总耗时 / 首字节',
value: `${d.total_latency_ms != null ? `${d.total_latency_ms}ms` : '-'} / ${d.first_token_ms != null ? `${d.first_token_ms}ms` : '-'}`,
},
{ label: '上游 URL', value: d.upstream_url || '', span: 2, mono: true },
]
// 协议转换条目:openai → claude(桥接方式 + 完整链路)
const conv = parseConversion(d.conversion)
if (conv && (conv.client_format || conv.upstream_format)) {
let convValue = `${conv.client_format || '-'} → ${conv.upstream_format || '-'}`
if (conv.bridge) convValue += `(${debugBridgeLabels[conv.bridge] || conv.bridge})`
if (Array.isArray(conv.chain) && conv.chain.length > 2) convValue += `,链路 ${conv.chain.join(' → ')}`
items.push({ label: '协议转换', value: convValue, span: 2 })
}
if (d.error) {
items.push({ label: '错误', value: d.error, span: 2, color: '#f53f3f' })
}
return items
})
const debugColumns: TableColumnData[] = [
{ title: '时间', dataIndex: 'created_at', width: 165, render: ({ record }) => h('span', { style: 'font-size:12px' }, String(record.created_at || '').replace('T', ' ').slice(0, 19)) },
{ title: '模型', dataIndex: 'model_name', width: 160, ellipsis: true, tooltip: true },
{
title: '尝试', dataIndex: 'retry_index', width: 75,
render: ({ record }) => h(Tag, {
color: record.is_final ? 'green' : 'orange', size: 'small',
}, { default: () => `#${record.retry_index}${record.is_final ? ' 终' : ''}` }),
},
{
title: '流式', dataIndex: 'is_stream', width: 60,
render: ({ record }) => h(Tag, { size: 'small' }, { default: () => record.is_stream ? '流' : '非流' }),
},
{
title: '上游', dataIndex: 'upstream_status_code', width: 65,
render: ({ record }) => h('span', { style: statusCodeColor(record.upstream_status_code) }, record.upstream_status_code || '—'),
},
{
title: '客户端', dataIndex: 'client_status_code', width: 65,
render: ({ record }) => h('span', { style: statusCodeColor(record.client_status_code) }, record.client_status_code || '—'),
},
{ title: '上游耗时', dataIndex: 'upstream_latency_ms', width: 85, render: ({ record }) => record.upstream_latency_ms != null ? `${record.upstream_latency_ms}ms` : '-' },
{
title: '协议转换', width: 125,
render: ({ record }) => {
const txt = conversionShort(record.conversion)
if (!txt) return '-'
return h(Tag, { size: 'small', color: 'arcoblue' }, { default: () => txt })
},
},
{ title: '总耗时', dataIndex: 'total_latency_ms', width: 85, render: ({ record }) => record.total_latency_ms != null ? `${record.total_latency_ms}ms` : '-' },
{
title: '报文体积', width: 150,
render: ({ record }) => h('span', { style: 'font-size:12px;color:var(--color-text-3)' },
`①${formatBytes(record.client_req_body_size)} ②${formatBytes(record.upstream_req_body_size)} ③${formatBytes(record.upstream_resp_body_size)} ④${formatBytes(record.client_resp_body_size)}`),
},
{
title: '错误', dataIndex: 'error', ellipsis: true, tooltip: true, width: 140,
render: ({ record }) => record.error ? h('span', { style: 'color:#f53f3f;font-size:12px' }, record.error) : '-',
},
{
title: '操作', width: 120, fixed: 'right',
render: ({ record }) =>
h('div', { style: 'display:flex;gap:6px' }, [
h(Button, { size: 'mini', type: 'outline', onClick: () => openDebugDetail(record) }, { default: () => '详情' }),
h(Button, { size: 'mini', status: 'danger', type: 'text', onClick: () => deleteDebugLog(record) }, { default: () => '删除' }),
]),
},
]
function statusCodeColor(code: number | null | undefined): string {
if (!code) return 'color:var(--color-text-3)'
if (code >= 200 && code < 300) return 'color:#00b42a;font-weight:600'
if (code >= 400 && code < 500) return 'color:#ff7d00;font-weight:600'
return 'color:#f53f3f;font-weight:600'
}
onBeforeUnmount(() => {
window.removeEventListener('resize', onWindowResize)
if (resizeTimer) clearTimeout(resizeTimer)
@@ -512,6 +957,10 @@ function onTabChange(key: string) {
if (key === 'abilities' && abilitiesData.value.length === 0) fetchAbilities()
if (key === 'test' && abilitiesData.value.length === 0) fetchAbilities()
if (key === 'health_trend' && trendData.value.length === 0) fetchHealthTrend()
if (key === 'debug_logs') {
if (debugLogs.value.length === 0) fetchDebugLogs()
initDebugTarget()
}
}
// === Update Key ===
@@ -838,6 +1287,111 @@ function formatHeaders(headers: Record<string, string>): string {
</ASpin>
</ACard>
</ATabPane>
<!-- Tab 5: Debug Logs -->
<ATabPane key="debug_logs" title="调试日志">
<ACard :bordered="false" class="mb-4">
<div class="flex items-center justify-between flex-wrap" style="gap: 12px;">
<div class="flex items-center" style="gap: 10px;">
<span style="font-weight: 600;">调试开关</span>
<Switch
:model-value="!!detail.debug_log_enabled"
:loading="debugSwitchLoading"
@change="toggleDebugLog"
/>
<span style="color: var(--color-text-3); font-size: 12px;">
开启后记录经该渠道每次请求尝试的四段完整报文(含脱敏凭证),排障完成请及时关闭并清理
</span>
</div>
<Popconfirm content="确定清空该渠道全部调试日志?此操作不可逆" @ok="clearDebugLogs">
<AButton status="danger" type="outline" size="small" :disabled="!debugStats || debugStats.total === 0">清空日志</AButton>
</Popconfirm>
</div>
<div class="flex items-center flex-wrap mt-3" style="gap: 8px;">
<span style="font-size: 12px; color: var(--color-text-3); flex-shrink: 0;">捕捉范围(不选 = 不限):</span>
<ASelect
:model-value="debugTarget.tenantId"
:options="debugTenantOptions"
placeholder="租户不限"
allow-search
allow-clear
size="small"
style="width: 220px"
@search="searchDebugTenants"
@change="handleDebugTenantChange"
/>
<ASelect
:model-value="debugTarget.userId"
:options="debugUserOptions"
placeholder="成员不限"
allow-search
allow-clear
size="small"
style="width: 200px"
:disabled="!debugTarget.tenantId"
@change="handleDebugUserChange"
/>
<ASelect
:model-value="debugTarget.apiKeyId"
:options="debugKeyOptions"
placeholder="密钥不限"
allow-search
allow-clear
size="small"
style="width: 240px"
:disabled="!debugTarget.tenantId"
@change="(v: any) => debugTarget.apiKeyId = v ?? null"
/>
<AButton size="small" type="primary" :loading="debugTargetSaving" @click="saveDebugTarget">保存过滤条件</AButton>
</div>
<Alert
v-if="detail.debug_log_enabled"
type="warning"
class="mt-3"
>
调试日志完整保留请求/响应体(可能数 MB/条),无自动过期。当前捕捉范围:{{ debugTargetDesc }};累计
{{ debugStats ? debugStats.total : 0 }} 条 / {{ formatBytes(debugStats?.total_bytes || 0) }}
<template v-if="debugStats && debugStats.oldest_at">,最早 {{ debugStats.oldest_at }}</template>
—— 请及时清理,避免存储膨胀。
</Alert>
</ACard>
<ACard :bordered="false">
<div class="flex items-center flex-wrap mb-4" style="gap: 8px;">
<Input v-model="debugFilter.request_id" placeholder="Request ID" style="width: 200px" allow-clear @clear="handleDebugFilter" @press-enter="handleDebugFilter" />
<Input v-model="debugFilter.model_name" placeholder="模型名" style="width: 160px" allow-clear @clear="handleDebugFilter" @press-enter="handleDebugFilter" />
<DatePicker
:model-value="debugFilter.dateRange"
range
style="width: 240px"
format="YYYY-MM-DD"
@change="(val: any) => { debugFilter.dateRange = val }"
/>
<Switch v-model="debugFilter.only_error" size="small">
<template #checked>仅错误</template>
<template #unchecked>仅错误</template>
</Switch>
<AButton type="primary" size="small" @click="handleDebugFilter">搜索</AButton>
<AButton size="small" @click="resetDebugFilter">重置</AButton>
</div>
<ATable
:columns="debugColumns"
:data="debugLogs"
:loading="debugLoading"
:bordered="false"
:scroll-x="1380"
size="small"
row-key="id"
:pagination="{
total: debugTotal,
current: debugPage,
pageSize: debugPageSize,
showTotal: true,
}"
@page-change="handleDebugPageChange"
/>
</ACard>
</ATabPane>
</ATabs>
</template>
</ASpin>
@@ -985,6 +1539,48 @@ function formatHeaders(headers: Record<string, string>): string {
</AFormItem>
</AForm>
</AModal>
<!-- Debug Log Detail Drawer -->
<ADrawer v-model:visible="showDebugDetail" title="调试日志详情" :width="880" unmount-on-close>
<ASpin :loading="debugDetailLoading" style="width: 100%; display: block;">
<template v-if="debugDetail">
<ADescriptions :column="2" bordered size="small" class="mb-4">
<ADescriptionsItem v-for="item in debugMetaItems" :key="item.label" :label="item.label" :span="item.span || 1">
<div class="debug-meta-row">
<span class="debug-meta-text" :class="{ mono: item.mono }" :style="item.color ? { color: item.color, fontWeight: 600 } : undefined">
{{ item.value || '-' }}
</span>
<Button v-if="item.value" size="mini" type="text" class="debug-copy-btn" @click="copyText(item.value, item.label)">复制</Button>
</div>
</ADescriptionsItem>
</ADescriptions>
<ACollapse :bordered="false" :default-active-key="['client_req']">
<ACollapseItem v-for="seg in debugSegments" :key="seg.key" :header="`${seg.title}(${formatBytes(seg.bytes)})`" :header-style="{ fontSize: '13px', fontWeight: 600 }">
<div class="debug-seg-label">
<span>请求/响应头</span>
<Button v-if="seg.headers" size="mini" type="text" class="debug-copy-btn" @click="copyText(formatHeaders(seg.headers), '请求/响应头')">复制</Button>
</div>
<pre class="debug-pre">{{ seg.headers ? formatHeaders(seg.headers) : '(无)' }}</pre>
<div class="debug-seg-label" style="margin-top: 10px;">
<span>
内容({{ seg.contentType || '未知类型' }}<template v-if="seg.encoding === 'base64'"> · base64 编码</template>)
</span>
<Button v-if="seg.body" size="mini" type="text" class="debug-copy-btn" @click="copySegBody(seg)">复制</Button>
</div>
<template v-if="seg.encoding === 'base64'">
<div class="debug-binary-note">
二进制内容({{ formatBytes(seg.bytes) }},base64 存储{{ seg.imageUri ? ',下方预览' : ',点上方复制获取完整 base64' }})
</div>
<img v-if="seg.imageUri" :src="seg.imageUri" style="max-width: 100%; max-height: 320px; border-radius: 6px; margin-top: 8px; border: 1px solid var(--color-border-2);" alt="binary preview" />
</template>
<pre v-else class="debug-pre">{{ seg.bodyText || '(无内容)' }}</pre>
</ACollapseItem>
</ACollapse>
</template>
</ASpin>
</ADrawer>
</div>
</template>
@@ -1000,6 +1596,24 @@ function formatHeaders(headers: Record<string, string>): string {
.test-debug-value { font-size: 12px; color: var(--color-text-2); word-break: break-all; background: var(--color-fill-1); padding: 2px 6px; border-radius: 3px; }
.test-debug-code { font-size: 12px; background: var(--color-fill-1); padding: 6px 8px; border-radius: 4px; max-height: 150px; overflow: auto; color: var(--color-text-3); white-space: pre-wrap; word-break: break-all; margin: 0; flex: 1; }
.field-help { color: var(--color-text-3); font-size: 12px; }
.debug-seg-label {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-size: 12px; font-weight: 600; color: var(--color-text-2); margin-bottom: 6px;
}
.debug-meta-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
.debug-meta-text { min-width: 0; word-break: break-all; }
.debug-meta-text.mono { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 12px; }
.debug-copy-btn { flex-shrink: 0; color: var(--color-text-3); }
.debug-copy-btn:hover { color: rgb(var(--primary-6)); }
.debug-pre {
font-size: 12px; line-height: 1.6; margin: 0; padding: 10px; border-radius: 6px;
background: var(--color-fill-1); color: var(--color-text-2);
white-space: pre-wrap; word-break: break-all; max-height: 360px; overflow-y: auto;
}
.debug-binary-note {
font-size: 12px; color: var(--color-text-3); padding: 8px 10px; border-radius: 6px;
background: var(--color-fill-1);
}
.form-group-title {
font-size: 13px;
font-weight: 600;
+3 -3
View File
@@ -6,7 +6,7 @@ import {
import type { TableColumnData, FormInstance } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import TableStats from '@/components/TableStats.vue'
import ModelPricingDrawer from '@/components/ModelPricingDrawer.vue'
import ModelPricingModal from '@/components/ModelPricingModal.vue'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
import ResponsiveTable from '@/components/ResponsiveTable.vue'
@@ -914,8 +914,8 @@ function resetImport() {
</div>
</AModal>
<!-- Pricing Drawer -->
<ModelPricingDrawer
<!-- Pricing Modal -->
<ModelPricingModal
v-model:visible="pricingDrawerVisible"
:model-id="pricingDrawerModelId"
:model-id-str="pricingDrawerModelIdStr"
@@ -10,20 +10,12 @@ const values = useFormValues()
<div class="section-title">自动探测</div>
<div class="switch-row">
<span class="switch-label">渠道自动探测</span>
<span class="switch-desc">定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)</span>
<span class="switch-desc">定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)。禁用渠道不自动探测,由管理员手动测试确认后再启用</span>
<ASwitch
:model-value="!!values['channel_auto_test_enabled']"
@change="(v: string | number | boolean) => values['channel_auto_test_enabled'] = v"
/>
</div>
<div class="switch-row" style="margin-top: 12px">
<span class="switch-label">自动恢复探测</span>
<span class="switch-desc">定期测试已自动禁用的渠道,测试通过则恢复启用(依赖自动探测开启)</span>
<ASwitch
:model-value="!!values['channel_auto_test_recovery_enabled']"
@change="(v: string | number | boolean) => values['channel_auto_test_recovery_enabled'] = v"
/>
</div>
</div>
<!-- 自动禁用 -->
@@ -27,6 +27,9 @@ function handleMaintenanceModeChange(enabled: boolean) {
<AFormItem label="租户控制台地址" help="用于生成邀请链接等,如 https://console.example.com" class="field-full">
<AInput v-model="values['tenant_console_url']" placeholder="https://console.example.com" />
</AFormItem>
<AFormItem label="计费时区" help="模型时段定价(峰谷/促销)按此时区解释,IANA 时区名" class="field-full">
<AInput v-model="values['pricing_time_timezone']" placeholder="Asia/Shanghai" />
</AFormItem>
</div>
</div>
+20
View File
@@ -1,5 +1,18 @@
import request from '@/utils/request'
export interface TimePriceItem {
name: string
days?: number[] | null
start_time?: string
end_time?: string
valid_from?: string
valid_to?: string
multiplier: number
input_price?: number | null
output_price?: number | null
per_request_price?: number | null
}
export interface MarketplaceModel {
model_id: string
model_name: string
@@ -7,8 +20,15 @@ export interface MarketplaceModel {
description: string
max_context_tokens: number
max_output_tokens: number
billing_mode?: string
input_price: number
output_price: number
per_request_price?: number
cache_read_price?: number
cache_creation_price?: number
discount_label?: string | null
price_change_note?: string | null
time_prices?: TimePriceItem[] | null
tags: string[]
capabilities: Record<string, any>
}
@@ -140,6 +140,7 @@
<div class="model-provider">{{ getProvider(model) }}</div>
<div class="model-name-line">
<h3>{{ model.model_name || model.model_id }}</h3>
<span v-if="model.discount_label" class="promo-badge">{{ model.discount_label }}</span>
<button type="button" aria-label="复制模型 ID" title="复制模型 ID" @click="copyModelId(model.model_id)">
<Icon name="copy" size="xs" />
</button>
@@ -164,8 +165,28 @@
</div>
</div>
<div v-if="model.time_prices?.length" class="time-price-list">
<div v-for="(tp, idx) in model.time_prices" :key="idx" class="time-price-row">
<span>{{ tp.name }}<small>{{ timeWindow(tp) }}</small></span>
<strong>{{ timePriceText(model, tp) }}</strong>
</div>
</div>
<p v-if="model.price_change_note" class="price-change-note">
<Icon name="infoCircle" size="xs" />
{{ model.price_change_note }}
</p>
<div class="model-card-footer">
<div class="price-pair">
<template v-if="model.billing_mode === 'per_request'">
<div class="price-pair">
<div>
<span>调用单价</span>
<strong>{{ formatPrice(model.per_request_price) }}</strong>
</div>
<small>/ 次</small>
</div>
</template>
<div v-else class="price-pair">
<div>
<span>输入</span>
<strong>{{ formatPrice(model.input_price) }}</strong>
@@ -175,7 +196,7 @@
<span>输出</span>
<strong>{{ formatPrice(model.output_price) }}</strong>
</div>
<small>/ 1M tokens</small>
<small>{{ model.billing_mode === 'tiered' ? '/ 1M tokens 起' : '/ 1M tokens' }}</small>
</div>
<button type="button" class="detail-button" @click="openDetail(model)">
查看详情
@@ -249,6 +270,7 @@
</button>
</div>
</div>
<span v-if="selectedModel.discount_label" class="promo-badge large">{{ selectedModel.discount_label }}</span>
<span class="detail-category">{{ getCategoryMeta(selectedModel.category).label }}</span>
</div>
@@ -263,7 +285,51 @@
<span class="detail-section-label">模型简介</span>
<p class="detail-description">{{ selectedModel.description || '暂无模型描述。' }}</p>
</section>
<section class="detail-section">
<span class="detail-section-label">计费信息<em v-if="selectedModel.billing_mode === 'tiered'" class="price-tiered-hint">阶梯计费 · 以下为首档起价</em></span>
<div class="price-tile-grid">
<template v-if="selectedModel.billing_mode === 'per_request'">
<div class="price-tile accent">
<span>单次调用</span>
<strong>{{ formatPrice(selectedModel.per_request_price) }}</strong>
<small>USD / 次</small>
</div>
</template>
<template v-else>
<div class="price-tile accent">
<span>输入</span>
<strong>{{ formatPrice(selectedModel.input_price) }}</strong>
<small>USD / 1M tokens</small>
</div>
<div class="price-tile">
<span>输出</span>
<strong>{{ formatPrice(selectedModel.output_price) }}</strong>
<small>USD / 1M tokens</small>
</div>
<div v-if="selectedModel.cache_read_price" class="price-tile">
<span>缓存读取</span>
<strong>{{ formatPrice(selectedModel.cache_read_price) }}</strong>
<small>USD / 1M tokens</small>
</div>
<div v-if="selectedModel.cache_creation_price" class="price-tile">
<span>缓存创建</span>
<strong>{{ formatPrice(selectedModel.cache_creation_price) }}</strong>
<small>USD / 1M tokens</small>
</div>
</template>
</div>
<div v-if="selectedModel.time_prices?.length" class="detail-time-prices">
<div class="detail-time-head">分时段价格<small>命中时段按乘数计费,未命中按默认价</small></div>
<div v-for="(tp, idx) in selectedModel.time_prices" :key="idx" class="detail-time-row">
<span>{{ tp.name }}<small>{{ timeWindow(tp) }}</small></span>
<strong>{{ timePriceText(selectedModel, tp) }}</strong>
</div>
</div>
<p v-if="selectedModel.price_change_note" class="price-change-note">
<Icon name="infoCircle" size="xs" />
{{ selectedModel.price_change_note }}
</p>
</section>
<section class="detail-section">
<span class="detail-section-label">能力支持</span>
<div class="detail-capabilities">
@@ -278,12 +344,7 @@
</div>
</section>
<section v-if="selectedModel.tags?.length" class="detail-section">
<span class="detail-section-label">模型标签</span>
<div class="detail-tags">
<span v-for="tag in selectedModel.tags" :key="tag">{{ tag }}</span>
</div>
</section>
</div>
<aside class="detail-side-column">
@@ -301,19 +362,18 @@
<span><Icon name="grid" size="sm" /> 模型类别</span>
<strong>{{ getCategoryMeta(selectedModel.category).label }}</strong>
</div>
<div class="detail-spec-row">
<span><Icon name="creditCard" size="sm" /> 计费模式</span>
<strong>{{ billingModeLabel(selectedModel.billing_mode) }}</strong>
</div>
</div>
<div v-if="selectedModel.tags?.length" class="detail-spec-card">
<div class="detail-spec-title">模型标签</div>
<div class="detail-tags">
<span v-for="tag in selectedModel.tags" :key="tag">{{ tag }}</span>
</div>
</div>
<div class="detail-price-card">
<div class="detail-spec-title">Token 计费</div>
<div class="detail-price-row">
<div><span>输入价格</span><small>每百万 tokens</small></div>
<strong>{{ formatPrice(selectedModel.input_price) }}</strong>
</div>
<div class="detail-price-row">
<div><span>输出价格</span><small>每百万 tokens</small></div>
<strong>{{ formatPrice(selectedModel.output_price) }}</strong>
</div>
</div>
</aside>
</div>
@@ -333,7 +393,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { NModal, NPagination } from 'naive-ui'
import Icon from '@/components/common/Icon.vue'
import { getMarketplaceModelDetail, getMarketplaceModels, type MarketplaceModel } from '@/api/marketplace'
import { getMarketplaceModelDetail, getMarketplaceModels, type MarketplaceModel, type TimePriceItem } from '@/api/marketplace'
import { usePublicSettings } from '@/composables/usePublicSettings'
import { useSeo } from '@/composables/useSeo'
import { useTenantAuthStore } from '@/stores/tenant-auth'
@@ -469,6 +529,36 @@ function formatPrice(price?: number): string {
return `$${Number(value.toFixed(6))}`
}
function billingModeLabel(mode?: string): 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('、')
}
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(' · ')
}
function timePriceText(model: MarketplaceModel, tp: TimePriceItem): string {
if (model.billing_mode === 'per_request') return `${formatPrice(tp.per_request_price ?? undefined)} /次`
return `输入 ${formatPrice(tp.input_price ?? undefined)} · 输出 ${formatPrice(tp.output_price ?? undefined)}${model.billing_mode === 'tiered' ? ' 起' : ''}`
}
async function loadModels(): Promise<void> {
const requestId = ++listRequestId
loading.value = true
@@ -1244,6 +1334,186 @@ useSeo({
font-weight: 700;
}
.promo-badge {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
background: linear-gradient(135deg, #f43f5e, #fb923c);
color: #fff;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.02em;
box-shadow: 0 3px 10px rgba(244, 63, 94, 0.25);
}
.promo-badge.large {
margin-left: auto;
padding: 4px 11px;
font-size: 11px;
}
.price-change-note {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 10px;
padding: 7px 9px;
border: 1px solid rgba(180, 83, 9, 0.16);
border-radius: 9px;
background: rgba(180, 83, 9, 0.06);
color: #b45309;
font-size: 10px;
line-height: 1.5;
}
.time-price-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 11px;
padding-top: 9px;
border-top: 1px dashed #e2e8f0;
}
.time-price-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #64706c;
font-size: 9px;
}
.time-price-row span {
display: inline-flex;
align-items: baseline;
gap: 5px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time-price-row small {
flex: 0 0 auto;
color: #98a29f;
font-size: 8px;
}
.time-price-row strong {
flex: 0 0 auto;
color: #10231f;
font-size: 9px;
font-weight: 700;
}
.detail-time-prices {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed rgba(15, 118, 110, 0.22);
}
.detail-time-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #586660;
font-size: 11px;
}
.detail-time-row span {
display: inline-flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.detail-time-row small {
color: #98a29f;
font-size: 9px;
}
.detail-time-row strong {
flex: 0 0 auto;
color: #10231f;
font-size: 11px;
font-weight: 700;
}
.price-tile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 10px;
}
.price-tile {
display: flex;
flex-direction: column;
gap: 3px;
padding: 13px 15px;
border: 1px solid #e0e8e5;
border-radius: 13px;
background: #fafcfb;
}
.price-tile span {
color: #64706c;
font-size: 10px;
font-weight: 600;
}
.price-tile strong {
color: var(--ink);
font-size: 17px;
font-weight: 800;
letter-spacing: -0.01em;
}
.price-tile small {
color: #98a29f;
font-size: 9px;
}
.price-tile.accent {
border-color: color-mix(in srgb, var(--model-accent) 26%, #dfe7e4);
background: var(--model-soft);
}
.price-tile.accent strong {
color: var(--model-accent);
}
.price-tiered-hint {
margin-left: 7px;
color: #98a29f;
font-size: 9px;
font-style: normal;
letter-spacing: 0.02em;
text-transform: none;
}
.detail-time-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
color: #0f766e;
font-size: 10px;
font-weight: 700;
}
.detail-time-head small {
color: #98a29f;
font-size: 9px;
font-weight: 400;
}
.model-card-footer {
justify-content: space-between;
gap: 9px;
@@ -1727,19 +1997,12 @@ useSeo({
}
.detail-spec-card,
.detail-price-card {
padding: 18px;
border: 1px solid #dfe7e4;
border-radius: 16px;
background: #fafcfb;
}
.detail-spec-row,
.detail-price-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 11px 0;
border-bottom: 1px solid #e7ecea;
}
@@ -1763,14 +2026,8 @@ useSeo({
}
.detail-spec-row strong {
font-size: 12px;
font-size: 13px;
font-weight: 700;
text-align: right;
}
.detail-price-card {
border-color: color-mix(in srgb, var(--model-accent) 20%, #dfe7e4);
background: var(--model-soft);
}
.detail-price-row > div {
@@ -2479,11 +2736,6 @@ useSeo({
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.94);
}
.detail-price-card {
border-color: rgba(153, 246, 228, 0.55);
background: rgba(240, 253, 250, 0.82);
}
.detail-price-row strong {
color: #0d9488;
}
@@ -18,6 +18,19 @@ interface PricingTierItem {
output_price: number
}
interface TimePriceItem {
name: string
days?: number[] | null
start_time?: string
end_time?: string
valid_from?: string
valid_to?: string
multiplier: number
input_price?: number | null
output_price?: number | null
per_request_price?: number | null
}
interface ModelItem {
id: number
model_id: string
@@ -37,6 +50,9 @@ interface ModelItem {
cache_read_price: number | null
cache_creation_price: number | null
pricing_tiers: PricingTierItem[]
time_prices?: TimePriceItem[] | null
discount_label?: string | null
price_change_note?: string | null
}
const capabilityLabel: Record<string, string> = {
@@ -182,6 +198,31 @@ function getTieredStartPrice(m: ModelItem): string {
return formatPrice(first.output_price)
}
// ── 时段价目展示辅助(价格为后端换算好的展示价,前端直接渲染) ──
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('、')
}
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(' · ')
}
function timePriceText(m: ModelItem, tp: TimePriceItem): string {
if (m.billing_mode === 'per_request') return `${formatPrice(tp.per_request_price ?? null)} /次`
if (m.billing_mode === 'tiered') return `输入 ${formatPrice(tp.input_price ?? null)} · 输出 ${formatPrice(tp.output_price ?? null)} 起`
return `输入 ${formatPrice(tp.input_price ?? null)} · 输出 ${formatPrice(tp.output_price ?? null)}`
}
async function fetchModels() {
loading.value = true
try {
@@ -295,6 +336,13 @@ onMounted(fetchModels)
{{ categoryLabel[m.category] || m.category }}
</span>
<h3 class="text-sm font-semibold text-gray-900 truncate">{{ m.model_name || m.model_id }}</h3>
<span
v-if="m.discount_label"
class="shrink-0 px-1.5 py-0.5 text-[10px] font-semibold text-white rounded-md"
style="background: linear-gradient(135deg, #f43f5e, #fb923c);"
>
{{ m.discount_label }}
</span>
</div>
<!-- Model ID + Copy -->
@@ -343,6 +391,15 @@ onMounted(fetchModels)
</span>
</div>
<!-- 价格调整说明(平台调价提示,仅配置时展示) -->
<div
v-if="m.price_change_note"
class="mt-2.5 flex items-start gap-1.5 text-[11px] text-amber-700 bg-amber-50 border border-amber-100 rounded-lg px-2 py-1.5"
>
<Icon name="infoCircle" size="xs" class="shrink-0 mt-px text-amber-500" />
<span>{{ m.price_change_note }}</span>
</div>
<!-- Pricing (pushed to bottom) -->
<div v-if="hasPricing(m)" class="mt-auto pt-3 border-t border-gray-100">
<template v-if="m.billing_mode === 'token'">
@@ -401,6 +458,21 @@ onMounted(fetchModels)
</div>
</div>
</template>
<!-- 时段价目(平台配置了时段定价时展示,价格为换算后的展示价) -->
<div v-if="m.time_prices?.length" class="mt-2 space-y-1 border-t border-dashed border-gray-100 pt-2">
<div
v-for="(tp, idx) in m.time_prices"
:key="idx"
class="flex items-center justify-between gap-2 text-xs"
>
<span class="text-gray-500 truncate">
{{ tp.name }}
<span class="text-gray-400 ml-1">{{ timeWindow(tp) }}</span>
</span>
<span class="shrink-0 text-gray-600">{{ timePriceText(m, tp) }}</span>
</div>
</div>
</div>
</div>
</div>