diff --git a/CLAUDE.md b/CLAUDE.md index c75284c..21c5865 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 物化(强约束) diff --git a/api/admin/admin.go b/api/admin/admin.go index b2f478a..e47a7c4 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -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) diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index d5a1e41..796a674 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -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"` diff --git a/api/admin/v1/channel_debug_log.go b/api/admin/v1/channel_debug_log.go new file mode 100644 index 0000000..915e890 --- /dev/null +++ b/api/admin/v1/channel_debug_log.go @@ -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{} diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index 5fd9a07..2a945ea 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -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;end0 计 1,含失败状态行) } diff --git a/internal/dao/internal/bil_usage_logs.go b/internal/dao/internal/bil_usage_logs.go index 3c5cfdb..8b5b155 100644 --- a/internal/dao/internal/bil_usage_logs.go +++ b/internal/dao/internal/bil_usage_logs.go @@ -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 费用 diff --git a/internal/dao/internal/chn_debug_logs.go b/internal/dao/internal/chn_debug_logs.go new file mode 100644 index 0000000..ec1160e --- /dev/null +++ b/internal/dao/internal/chn_debug_logs.go @@ -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) +} diff --git a/internal/dao/internal/mdl_pricing.go b/internal/dao/internal/mdl_pricing.go index 7cec603..dcdf6d2 100644 --- a/internal/dao/internal/mdl_pricing.go +++ b/internal/dao/internal/mdl_pricing.go @@ -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 ''") + } + 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 +} diff --git a/internal/logic/admin/channel_testing.go b/internal/logic/admin/channel_testing.go index 4b7bf90..5c11163 100644 --- a/internal/logic/admin/channel_testing.go +++ b/internal/logic/admin/channel_testing.go @@ -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 { diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index ee5edfd..6f1e022 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -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 { diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index e8106cf..0f95085 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -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++ } } diff --git a/internal/logic/admin/tenant.go b/internal/logic/admin/tenant.go index 693328a..92d1a88 100644 --- a/internal/logic/admin/tenant.go +++ b/internal/logic/admin/tenant.go @@ -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 +} diff --git a/internal/logic/billing/e2e_time_segment_test.go b/internal/logic/billing/e2e_time_segment_test.go new file mode 100644 index 0000000..a0b3138 --- /dev/null +++ b/internal/logic/billing/e2e_time_segment_test.go @@ -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) +} diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index bf89c6b..948d21f 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -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 diff --git a/internal/logic/billing/pricing_test.go b/internal/logic/billing/pricing_test.go index e9ebeaf..71b90e8 100644 --- a/internal/logic/billing/pricing_test.go +++ b/internal/logic/billing/pricing_test.go @@ -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) { diff --git a/internal/logic/billing/provider.go b/internal/logic/billing/provider.go index ce670ac..0f92268 100644 --- a/internal/logic/billing/provider.go +++ b/internal/logic/billing/provider.go @@ -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 } diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go index 187dd0b..ce24fd4 100644 --- a/internal/logic/billing/settlement.go +++ b/internal/logic/billing/settlement.go @@ -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) diff --git a/internal/logic/billing/snapshot.go b/internal/logic/billing/snapshot.go index 38c5646..214dea0 100644 --- a/internal/logic/billing/snapshot.go +++ b/internal/logic/billing/snapshot.go @@ -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)) } // 结算信息 diff --git a/internal/logic/billing/snapshot_test.go b/internal/logic/billing/snapshot_test.go index fd50ffe..7e6640e 100644 --- a/internal/logic/billing/snapshot_test.go +++ b/internal/logic/billing/snapshot_test.go @@ -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) + } +} diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 27fd7bc..28a0b2c 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -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 数中,不应再乘 diff --git a/internal/logic/billing/task_billing_test.go b/internal/logic/billing/task_billing_test.go index 2c3e3dd..50d401e 100644 --- a/internal/logic/billing/task_billing_test.go +++ b/internal/logic/billing/task_billing_test.go @@ -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{ diff --git a/internal/logic/billing/time_pricing.go b/internal/logic/billing/time_pricing.go new file mode 100644 index 0000000..6572df3 --- /dev/null +++ b/internal/logic/billing/time_pricing.go @@ -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 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 +} diff --git a/internal/logic/billing/time_pricing_test.go b/internal/logic/billing/time_pricing_test.go new file mode 100644 index 0000000..74375ca --- /dev/null +++ b/internal/logic/billing/time_pricing_test.go @@ -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) + } +} diff --git a/internal/logic/common/audit.go b/internal/logic/common/audit.go index 7f43873..96adcc0 100644 --- a/internal/logic/common/audit.go +++ b/internal/logic/common/audit.go @@ -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 { diff --git a/internal/logic/common/cache.go b/internal/logic/common/cache.go index 40b7dd8..699e0ff 100644 --- a/internal/logic/common/cache.go +++ b/internal/logic/common/cache.go @@ -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 { diff --git a/internal/logic/common/cache_test.go b/internal/logic/common/cache_test.go index 9e13419..f034775 100644 --- a/internal/logic/common/cache_test.go +++ b/internal/logic/common/cache_test.go @@ -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 应返回错误") + } +} diff --git a/internal/logic/common/channel_debug_log_writer.go b/internal/logic/common/channel_debug_log_writer.go new file mode 100644 index 0000000..ddec642 --- /dev/null +++ b/internal/logic/common/channel_debug_log_writer.go @@ -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() + } +} diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 673c249..67accf2 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -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", diff --git a/internal/logic/monitor/api_metrics_test.go b/internal/logic/monitor/api_metrics_test.go index 590bc42..8307a2d 100644 --- a/internal/logic/monitor/api_metrics_test.go +++ b/internal/logic/monitor/api_metrics_test.go @@ -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 } diff --git a/internal/logic/relay/debug_log.go b/internal/logic/relay/debug_log.go new file mode 100644 index 0000000..a0b7d8f --- /dev/null +++ b/internal/logic/relay/debug_log.go @@ -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) +} diff --git a/internal/logic/task/async_polling.go b/internal/logic/task/async_polling.go index ca04824..1cb0864 100644 --- a/internal/logic/task/async_polling.go +++ b/internal/logic/task/async_polling.go @@ -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 { diff --git a/internal/logic/task/channel_auto_test_cron.go b/internal/logic/task/channel_auto_test_cron.go index 954b50c..c30a8cb 100644 --- a/internal/logic/task/channel_auto_test_cron.go +++ b/internal/logic/task/channel_auto_test_cron.go @@ -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 } diff --git a/internal/logic/task/sync_image_worker.go b/internal/logic/task/sync_image_worker.go index e2a7a95..155e7de 100644 --- a/internal/logic/task/sync_image_worker.go +++ b/internal/logic/task/sync_image_worker.go @@ -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) diff --git a/internal/logic/tenant/marketplace.go b/internal/logic/tenant/marketplace.go index 145dd64..5052c0c 100644 --- a/internal/logic/tenant/marketplace.go +++ b/internal/logic/tenant/marketplace.go @@ -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 diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index b59f29e..d896b4e 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -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 +} diff --git a/internal/logic/update/update_integration_test.go b/internal/logic/update/update_integration_test.go index 926a810..90cc3ac 100644 --- a/internal/logic/update/update_integration_test.go +++ b/internal/logic/update/update_integration_test.go @@ -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" // 模拟的"新版本",必须高于当前版本才判定有更新 diff --git a/internal/model/do/bil_usage_daily.go b/internal/model/do/bil_usage_daily.go index 09720bd..9dbbc59 100644 --- a/internal/model/do/bil_usage_daily.go +++ b/internal/model/do/bil_usage_daily.go @@ -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,含失败状态行) } diff --git a/internal/model/do/bil_usage_logs.go b/internal/model/do/bil_usage_logs.go index d45a98b..84240bc 100644 --- a/internal/model/do/bil_usage_logs.go +++ b/internal/model/do/bil_usage_logs.go @@ -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 费用 diff --git a/internal/model/do/chn_debug_logs.go b/internal/model/do/chn_debug_logs.go new file mode 100644 index 0000000..4e8cc06 --- /dev/null +++ b/internal/model/do/chn_debug_logs.go @@ -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,空=常规转换) +} diff --git a/internal/model/do/mdl_pricing.go b/internal/model/do/mdl_pricing.go index 5e2c67e..608b74b 100644 --- a/internal/model/do/mdl_pricing.go +++ b/internal/model/do/mdl_pricing.go @@ -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=周日 空=每天,end0 计 1,含失败状态行)"` // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行) } diff --git a/internal/model/entity/bil_usage_logs.go b/internal/model/entity/bil_usage_logs.go index a84e811..9ae55e1 100644 --- a/internal/model/entity/bil_usage_logs.go +++ b/internal/model/entity/bil_usage_logs.go @@ -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,普通请求为空 } diff --git a/internal/model/entity/chn_debug_logs.go b/internal/model/entity/chn_debug_logs.go new file mode 100644 index 0000000..1f22193 --- /dev/null +++ b/internal/model/entity/chn_debug_logs.go @@ -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,空=常规转换) +} diff --git a/internal/model/entity/mdl_pricing.go b/internal/model/entity/mdl_pricing.go index 8d662d8..898fa5b 100644 --- a/internal/model/entity/mdl_pricing.go +++ b/internal/model/entity/mdl_pricing.go @@ -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 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) diff --git a/migrations/000016_v0_2_12_version.sql b/migrations/000016_v0_2_12_version.sql new file mode 100644 index 0000000..e098585 --- /dev/null +++ b/migrations/000016_v0_2_12_version.sql @@ -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 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" +} diff --git a/relay/common/debug_capture_test.go b/relay/common/debug_capture_test.go new file mode 100644 index 0000000..f521431 --- /dev/null +++ b/relay/common/debug_capture_test.go @@ -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) +} diff --git a/relay/common/debug_log.go b/relay/common/debug_log.go new file mode 100644 index 0000000..ffb493c --- /dev/null +++ b/relay/common/debug_log.go @@ -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) diff --git a/relay/common/debug_session.go b/relay/common/debug_session.go new file mode 100644 index 0000000..14efa63 --- /dev/null +++ b/relay/common/debug_session.go @@ -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() +} diff --git a/relay/common/debug_session_test.go b/relay/common/debug_session_test.go new file mode 100644 index 0000000..34dd584 --- /dev/null +++ b/relay/common/debug_session_test.go @@ -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) + } +} diff --git a/relay/common/http_client.go b/relay/common/http_client.go index b3d1b62..da5091f 100644 --- a/relay/common/http_client.go +++ b/relay/common/http_client.go @@ -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, } } diff --git a/relay/common/http_client_test.go b/relay/common/http_client_test.go index 1cdd0a5..c6b71b9 100644 --- a/relay/common/http_client_test.go +++ b/relay/common/http_client_test.go @@ -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) } diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 78c5c61..36ba205 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -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{} diff --git a/relay/common/task_billing.go b/relay/common/task_billing.go index d10f407..de7221f 100644 --- a/relay/common/task_billing.go +++ b/relay/common/task_billing.go @@ -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) } diff --git a/relay/handler/realtime_handler.go b/relay/handler/realtime_handler.go index 7379551..636b35b 100644 --- a/relay/handler/realtime_handler.go +++ b/relay/handler/realtime_handler.go @@ -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) diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index 4198d1d..1e7cd0f 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -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 diff --git a/web/admin/src/components/ModelPricingDrawer.vue b/web/admin/src/components/ModelPricingDrawer.vue deleted file mode 100644 index 53dd53e..0000000 --- a/web/admin/src/components/ModelPricingDrawer.vue +++ /dev/null @@ -1,578 +0,0 @@ - - - - - diff --git a/web/admin/src/components/ModelPricingModal.vue b/web/admin/src/components/ModelPricingModal.vue new file mode 100644 index 0000000..962d2d8 --- /dev/null +++ b/web/admin/src/components/ModelPricingModal.vue @@ -0,0 +1,1070 @@ + + + + + diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index 6ff9a41..988431d 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -1,7 +1,7 @@