Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
commit
ccdb314ecd
81 files changed
+5859
-1024
No files matched your search
@@ -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 物化(强约束)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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 导入结果响应
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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(¤tSettings)`——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(&' `命中基础类型指针。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,含失败状态行)
|
||||
}
|
||||
|
||||
@@ -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 费用
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(¤tSettings)
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// 结算信息
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" // 模拟的"新版本",必须高于当前版本才判定有更新
|
||||
|
||||
|
||||
@@ -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,含失败状态行)
|
||||
}
|
||||
@@ -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 费用
|
||||
|
||||
@@ -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,空=常规转换)
|
||||
}
|
||||
@@ -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/空=不展示
|
||||
}
|
||||
@@ -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,含失败状态行)
|
||||
}
|
||||
@@ -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,普通请求为空
|
||||
}
|
||||
@@ -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,空=常规转换)
|
||||
}
|
||||
@@ -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/空=不展示
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user