diff --git a/CLAUDE.md b/CLAUDE.md index 87b98fa..1ef611b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,6 +168,12 @@ Relay 层作为顶级 `relay/` 模块独立于 GoFrame 脚手架生成目录(` 如果先执行 `gf gen ctrl` 再执行 `gf gen service`,控制器会是桩代码(返回 501 Not Implemented),需要手动修改。始终遵循 "logic → gen service → gen ctrl" 的顺序可避免此问题。 +**重要:`gf gen ctrl` 执行后必须验证**。即使顺序正确,`withService: true` 的自动接线对新方法也可能失败,生成 `CodeNotImplemented` 桩代码。执行后务必检查: +```bash +grep -r "CodeNotImplemented" internal/controller/ +``` +如果有匹配,手动将桩代码替换为 `return service.Xxx().Method(ctx, req)` 调用。切勿假设 `gf gen ctrl` 的输出一定正确。 + ### GoFrame 请求处理链路 ``` diff --git a/api/admin/admin.go b/api/admin/admin.go index dc93016..ebfa2dd 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -52,6 +52,11 @@ type IAdminV1 interface { ChannelHealthTrend(ctx context.Context, req *v1.ChannelHealthTrendReq) (res *v1.ChannelHealthTrendRes, err error) ChannelExport(ctx context.Context, req *v1.ChannelExportReq) (res *v1.ChannelExportRes, err error) ChannelClone(ctx context.Context, req *v1.ChannelCloneReq) (res *v1.ChannelCloneRes, 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) + ChannelErrorTopChannels(ctx context.Context, req *v1.ChannelErrorTopChannelsReq) (res *v1.ChannelErrorTopChannelsRes, err error) + ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (res *v1.ChannelErrorCategoriesRes, err error) ChannelOAuthAuthURL(ctx context.Context, req *v1.ChannelOAuthAuthURLReq) (res *v1.ChannelOAuthAuthURLRes, err error) ChannelOAuthExchange(ctx context.Context, req *v1.ChannelOAuthExchangeReq) (res *v1.ChannelOAuthExchangeRes, err error) ChannelOAuthRefresh(ctx context.Context, req *v1.ChannelOAuthRefreshReq) (res *v1.ChannelOAuthRefreshRes, err error) diff --git a/api/admin/v1/channel_error.go b/api/admin/v1/channel_error.go new file mode 100644 index 0000000..dce4a73 --- /dev/null +++ b/api/admin/v1/channel_error.go @@ -0,0 +1,66 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// === 渠道错误监控 === + +type ChannelErrorEventListReq struct { + g.Meta `path:"/monitor/channel-errors" method:"get" mime:"json" tags:"管理后台-监控" summary:"渠道错误事件列表"` + Page int `json:"page" in:"query" d:"1"` + PageSize int `json:"page_size" in:"query" d:"20"` + ChannelID int64 `json:"channel_id" in:"query"` + ErrorCategory string `json:"error_category" in:"query"` + StatusCode int `json:"status_code" in:"query"` + StartDate string `json:"start_date" in:"query"` + EndDate string `json:"end_date" in:"query"` + Keyword string `json:"keyword" in:"query"` +} + +type ChannelErrorEventListRes struct { + List []map[string]any `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ChannelErrorStatsReq struct { + g.Meta `path:"/monitor/channel-errors/stats" method:"get" mime:"json" tags:"管理后台-监控" summary:"渠道错误统计"` + Hours int `json:"hours" in:"query" d:"24"` + ChannelID int64 `json:"channel_id" in:"query"` +} + +type ChannelErrorStatsRes struct { + Total int `json:"total"` + ByCategory []map[string]any `json:"by_category"` + ByStatusCode []map[string]any `json:"by_status_code"` + TopChannels []map[string]any `json:"top_channels"` +} + +type ChannelErrorTrendReq struct { + g.Meta `path:"/monitor/channel-errors/trend" method:"get" mime:"json" tags:"管理后台-监控" summary:"渠道错误趋势"` + Hours int `json:"hours" in:"query" d:"24"` + ChannelID int64 `json:"channel_id" in:"query"` + Category string `json:"category" in:"query"` +} + +type ChannelErrorTrendRes struct { + Points []map[string]any `json:"points"` +} + +type ChannelErrorTopChannelsReq struct { + g.Meta `path:"/monitor/channel-errors/top-channels" method:"get" mime:"json" tags:"管理后台-监控" summary:"错误最多的渠道"` + Hours int `json:"hours" in:"query" d:"24"` + Limit int `json:"limit" in:"query" d:"10"` +} + +type ChannelErrorTopChannelsRes struct { + List []map[string]any `json:"list"` +} + +type ChannelErrorCategoriesReq struct { + g.Meta `path:"/monitor/channel-errors/categories" method:"get" mime:"json" tags:"管理后台-监控" summary:"错误分类选项"` +} + +type ChannelErrorCategoriesRes struct { + Data []map[string]string `json:"data"` +} diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index c6e27fe..f936289 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -271,3 +271,17 @@ r := g.RequestFromCtx(ctx) - `internal/logic/billing/wallet.go`(GetWallet、syncWalletToRedis、preDeductDB、preDeductSyncDB、unfreezeDB、recordTransaction) **正确做法**:查询单行记录时,始终使用指针类型接收 Scan 结果,通过 `nil` 检查判断无数据。 + +### 2026-05-23:gf gen ctrl withService:true 对新方法生成桩代码(Not Implemented) + +**问题**:按正确顺序执行 `logic → gf gen service → gf gen ctrl` 后,`gf gen ctrl` 生成的控制器文件中方法体是 `return nil, gerror.NewCode(gcode.CodeNotImplemented)`,调用 API 返回 501 Not Implemented。 + +**原因**:`gf gen ctrl` 的 `withService: true` 自动接线机制对新方法不可靠。即使 service 接口文件已包含正确的方法签名,ctrl 生成器仍可能无法匹配并生成桩代码。且一旦首次生成桩代码,后续重跑 `gf gen ctrl` 会跳过已存在的文件,不会覆盖修复。 + +**修复**:手动将控制器文件改为 `return service.Admin().MethodName(ctx, req)` 调用。 + +**正确做法**:每次执行 `gf gen ctrl` 后,必须检查生成的控制器文件是否包含 `CodeNotImplemented`: +```bash +grep -r "CodeNotImplemented" internal/controller/ +``` +如果有匹配,手动将桩代码替换为 `return service.Admin().MethodName(ctx, req)`。或者先删除对应控制器文件再重新执行 `gf gen ctrl`。 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 25c407d..7797a5e 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -86,6 +86,9 @@ var ( // Initialize async usage log writer common.InitUsageLogWriter() + // Initialize async channel error writer + common.InitChannelErrorWriter() + // Initialize async error log writer response.InitErrorLogWriter() @@ -285,6 +288,7 @@ var ( // Flush usage log writer on shutdown (s.Run blocks until server stops) defer plugin.Shutdown(ctx) + defer common.CloseChannelErrorWriter() defer common.CloseUsageLogWriter() defer response.CloseErrorLogWriter() diff --git a/internal/controller/admin/admin_v1_channel_error_categories.go b/internal/controller/admin/admin_v1_channel_error_categories.go new file mode 100644 index 0000000..058ab1f --- /dev/null +++ b/internal/controller/admin/admin_v1_channel_error_categories.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (res *v1.ChannelErrorCategoriesRes, err error) { + return service.Admin().ChannelErrorCategories(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_channel_error_event_list.go b/internal/controller/admin/admin_v1_channel_error_event_list.go new file mode 100644 index 0000000..8553a97 --- /dev/null +++ b/internal/controller/admin/admin_v1_channel_error_event_list.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) ChannelErrorEventList(ctx context.Context, req *v1.ChannelErrorEventListReq) (res *v1.ChannelErrorEventListRes, err error) { + return service.Admin().ChannelErrorEventList(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_channel_error_stats.go b/internal/controller/admin/admin_v1_channel_error_stats.go new file mode 100644 index 0000000..a44b9ae --- /dev/null +++ b/internal/controller/admin/admin_v1_channel_error_stats.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) ChannelErrorStats(ctx context.Context, req *v1.ChannelErrorStatsReq) (res *v1.ChannelErrorStatsRes, err error) { + return service.Admin().ChannelErrorStats(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_channel_error_top_channels.go b/internal/controller/admin/admin_v1_channel_error_top_channels.go new file mode 100644 index 0000000..9e8e9a4 --- /dev/null +++ b/internal/controller/admin/admin_v1_channel_error_top_channels.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) ChannelErrorTopChannels(ctx context.Context, req *v1.ChannelErrorTopChannelsReq) (res *v1.ChannelErrorTopChannelsRes, err error) { + return service.Admin().ChannelErrorTopChannels(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_channel_error_trend.go b/internal/controller/admin/admin_v1_channel_error_trend.go new file mode 100644 index 0000000..023db0a --- /dev/null +++ b/internal/controller/admin/admin_v1_channel_error_trend.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) ChannelErrorTrend(ctx context.Context, req *v1.ChannelErrorTrendReq) (res *v1.ChannelErrorTrendRes, err error) { + return service.Admin().ChannelErrorTrend(ctx, req) +} diff --git a/internal/dao/bil_prededuct_tracks.go b/internal/dao/bil_prededuct_tracks.go new file mode 100644 index 0000000..9149a21 --- /dev/null +++ b/internal/dao/bil_prededuct_tracks.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "github.com/qianfree/team-api/internal/dao/internal" +) + +// bilPredeductTracksDao is the data access object for the table bil_prededuct_tracks. +// You can define custom methods on it to extend its functionality as needed. +type bilPredeductTracksDao struct { + *internal.BilPredeductTracksDao +} + +var ( + // BilPredeductTracks is a globally accessible object for table bil_prededuct_tracks operations. + BilPredeductTracks = bilPredeductTracksDao{internal.NewBilPredeductTracksDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/chn_error_events.go b/internal/dao/chn_error_events.go new file mode 100644 index 0000000..0de3cd3 --- /dev/null +++ b/internal/dao/chn_error_events.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "github.com/qianfree/team-api/internal/dao/internal" +) + +// chnErrorEventsDao is the data access object for the table chn_error_events. +// You can define custom methods on it to extend its functionality as needed. +type chnErrorEventsDao struct { + *internal.ChnErrorEventsDao +} + +var ( + // ChnErrorEvents is a globally accessible object for table chn_error_events operations. + ChnErrorEvents = chnErrorEventsDao{internal.NewChnErrorEventsDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/dao/internal/bil_prededuct_tracks.go b/internal/dao/internal/bil_prededuct_tracks.go new file mode 100644 index 0000000..566fdbb --- /dev/null +++ b/internal/dao/internal/bil_prededuct_tracks.go @@ -0,0 +1,93 @@ +// ========================================================================== +// 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" +) + +// BilPredeductTracksDao is the data access object for the table bil_prededuct_tracks. +type BilPredeductTracksDao 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 BilPredeductTracksColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// BilPredeductTracksColumns defines and stores column names for the table bil_prededuct_tracks. +type BilPredeductTracksColumns struct { + Id string // + TenantId string // 租户 ID + RequestId string // 请求唯一 ID + Amount string // 预扣金额(USD) + ModelName string // 模型名称 + Status string // frozen=冻结中, settled=已结算, expired=超时自动释放, released=手动释放 + CreatedAt string // 创建时间 + ExpiredAt string // 过期释放时间(仅 status=expired 时有值) +} + +// bilPredeductTracksColumns holds the columns for the table bil_prededuct_tracks. +var bilPredeductTracksColumns = BilPredeductTracksColumns{ + Id: "id", + TenantId: "tenant_id", + RequestId: "request_id", + Amount: "amount", + ModelName: "model_name", + Status: "status", + CreatedAt: "created_at", + ExpiredAt: "expired_at", +} + +// NewBilPredeductTracksDao creates and returns a new DAO object for table data access. +func NewBilPredeductTracksDao(handlers ...gdb.ModelHandler) *BilPredeductTracksDao { + return &BilPredeductTracksDao{ + group: "default", + table: "bil_prededuct_tracks", + columns: bilPredeductTracksColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *BilPredeductTracksDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *BilPredeductTracksDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *BilPredeductTracksDao) Columns() BilPredeductTracksColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *BilPredeductTracksDao) 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 *BilPredeductTracksDao) 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 *BilPredeductTracksDao) 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/chn_error_events.go b/internal/dao/internal/chn_error_events.go new file mode 100644 index 0000000..dff0007 --- /dev/null +++ b/internal/dao/internal/chn_error_events.go @@ -0,0 +1,113 @@ +// ========================================================================== +// 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" +) + +// ChnErrorEventsDao is the data access object for the table chn_error_events. +type ChnErrorEventsDao 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 ChnErrorEventsColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// ChnErrorEventsColumns defines and stores column names for the table chn_error_events. +type ChnErrorEventsColumns struct { + Id string // 主键ID + ChannelId string // 发生错误的渠道ID + ChannelName string // 渠道名称(冗余存储,避免查询时JOIN) + ChannelType string // 渠道类型(ProviderType枚举值) + Provider string // 供应商名称(如 OpenAI, Claude, Zhipu 等) + ModelName string // 请求的模型名 + UpstreamModel string // 上游实际模型名(模型映射后) + RequestId string // 关联的请求唯一ID + TenantId string // 租户ID + ErrorCategory string // 错误分类:rate_limit/auth_error/timeout/upstream_error/server_error/network_error/unknown + StatusCode string // HTTP状态码(来自上游响应或RelayError.StatusCode) + ErrorType string // RelayError.Type原始值(upstream_error/channel_error/auth_error等) + ErrorMessage string // 错误详细信息 + IsRetryable string // 是否为可重试错误(429,500,502,503,504) + Attempt string // 重试轮次编号(0=首次) + IsFinal string // 是否为最终错误(非中间重试失败) + LatencyMs string // 请求耗时(毫秒) + CreatedAt string // 错误发生时间 +} + +// chnErrorEventsColumns holds the columns for the table chn_error_events. +var chnErrorEventsColumns = ChnErrorEventsColumns{ + Id: "id", + ChannelId: "channel_id", + ChannelName: "channel_name", + ChannelType: "channel_type", + Provider: "provider", + ModelName: "model_name", + UpstreamModel: "upstream_model", + RequestId: "request_id", + TenantId: "tenant_id", + ErrorCategory: "error_category", + StatusCode: "status_code", + ErrorType: "error_type", + ErrorMessage: "error_message", + IsRetryable: "is_retryable", + Attempt: "attempt", + IsFinal: "is_final", + LatencyMs: "latency_ms", + CreatedAt: "created_at", +} + +// NewChnErrorEventsDao creates and returns a new DAO object for table data access. +func NewChnErrorEventsDao(handlers ...gdb.ModelHandler) *ChnErrorEventsDao { + return &ChnErrorEventsDao{ + group: "default", + table: "chn_error_events", + columns: chnErrorEventsColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *ChnErrorEventsDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *ChnErrorEventsDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *ChnErrorEventsDao) Columns() ChnErrorEventsColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *ChnErrorEventsDao) 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 *ChnErrorEventsDao) 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 *ChnErrorEventsDao) 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/logic/admin/channel_error.go b/internal/logic/admin/channel_error.go new file mode 100644 index 0000000..65d54b2 --- /dev/null +++ b/internal/logic/admin/channel_error.go @@ -0,0 +1,202 @@ +package admin + +import ( + "context" + "fmt" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + + v1 "github.com/qianfree/team-api/api/admin/v1" +) + +// ChannelErrorEventList 渠道错误事件列表 +func (s *sAdmin) ChannelErrorEventList(ctx context.Context, req *v1.ChannelErrorEventListReq) (*v1.ChannelErrorEventListRes, error) { + var conditions []string + var args []any + + if req.ChannelID > 0 { + conditions = append(conditions, "channel_id = ?") + args = append(args, req.ChannelID) + } + if req.ErrorCategory != "" { + conditions = append(conditions, "error_category = ?") + args = append(args, req.ErrorCategory) + } + if req.StatusCode > 0 { + conditions = append(conditions, "status_code = ?") + args = append(args, req.StatusCode) + } + if req.StartDate != "" { + conditions = append(conditions, "created_at >= ?") + args = append(args, req.StartDate) + } + if req.EndDate != "" { + conditions = append(conditions, "created_at <= ?") + args = append(args, req.EndDate+" 23:59:59") + } + if req.Keyword != "" { + conditions = append(conditions, "(error_message ILIKE ? OR channel_name ILIKE ? OR model_name ILIKE ?)") + kw := "%" + req.Keyword + "%" + args = append(args, kw, kw, kw) + } + + where := strings.Join(conditions, " AND ") + items, total, err := queryPage(ctx, + "chn_error_events", + "id, channel_id, channel_name, provider, model_name, error_category, status_code, error_message, is_retryable, attempt, is_final, latency_ms, request_id, created_at", + where, "created_at DESC", + req.Page, req.PageSize, args...) + if err != nil { + return nil, err + } + if items == nil { + items = []map[string]any{} + } + + return &v1.ChannelErrorEventListRes{ + List: items, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +// ChannelErrorStats 渠道错误统计 +func (s *sAdmin) ChannelErrorStats(ctx context.Context, req *v1.ChannelErrorStatsReq) (*v1.ChannelErrorStatsRes, error) { + timeCondition := fmt.Sprintf("created_at >= NOW() - INTERVAL '%d hours'", req.Hours) + var args []any + if req.ChannelID > 0 { + timeCondition += " AND channel_id = ?" + args = append(args, req.ChannelID) + } + + // 总数 + totalRecord, err := g.DB().Ctx(ctx).Query(ctx, + "SELECT COUNT(*) as total FROM chn_error_events WHERE "+timeCondition, args...) + if err != nil { + return nil, err + } + total := 0 + if len(totalRecord) > 0 { + total = totalRecord[0]["total"].Int() + } + + // 按分类 + byCategory, err := g.DB().Ctx(ctx).Query(ctx, + "SELECT error_category, COUNT(*) as count FROM chn_error_events WHERE "+timeCondition+ + " GROUP BY error_category ORDER BY count DESC", args...) + if err != nil { + return nil, err + } + + // 按状态码 + byStatusCode, err := g.DB().Ctx(ctx).Query(ctx, + "SELECT status_code, COUNT(*) as count FROM chn_error_events WHERE "+timeCondition+ + " GROUP BY status_code ORDER BY count DESC", args...) + if err != nil { + return nil, err + } + + // Top 渠道 + topChannels, err := g.DB().Ctx(ctx).Query(ctx, + "SELECT channel_id, channel_name, provider, COUNT(*) as error_count FROM chn_error_events WHERE "+timeCondition+ + " GROUP BY channel_id, channel_name, provider ORDER BY error_count DESC LIMIT 5", args...) + if err != nil { + return nil, err + } + + return &v1.ChannelErrorStatsRes{ + Total: total, + ByCategory: toMapList(byCategory), + ByStatusCode: toMapList(byStatusCode), + TopChannels: toMapList(topChannels), + }, nil +} + +// ChannelErrorTrend 渠道错误趋势 +func (s *sAdmin) ChannelErrorTrend(ctx context.Context, req *v1.ChannelErrorTrendReq) (*v1.ChannelErrorTrendRes, error) { + interval := "1 hour" + if req.Hours <= 6 { + interval = "30 minutes" + } else if req.Hours <= 24 { + interval = "1 hour" + } else { + interval = "6 hours" + } + + timeCondition := fmt.Sprintf("created_at >= NOW() - INTERVAL '%d hours'", req.Hours) + var args []any + if req.ChannelID > 0 { + timeCondition += " AND channel_id = ?" + args = append(args, req.ChannelID) + } + if req.Category != "" { + timeCondition += " AND error_category = ?" + args = append(args, req.Category) + } + + bucketExpr := fmt.Sprintf("date_trunc('%s', created_at) AS bucket", interval) + result, err := g.DB().Ctx(ctx).Query(ctx, + "SELECT "+bucketExpr+", error_category, COUNT(*) as count FROM chn_error_events WHERE "+timeCondition+ + " GROUP BY bucket, error_category ORDER BY bucket", args...) + if err != nil { + return nil, err + } + + return &v1.ChannelErrorTrendRes{ + Points: toMapList(result), + }, nil +} + +// ChannelErrorTopChannels 错误最多的渠道 +func (s *sAdmin) ChannelErrorTopChannels(ctx context.Context, req *v1.ChannelErrorTopChannelsReq) (*v1.ChannelErrorTopChannelsRes, error) { + timeFilter := fmt.Sprintf("created_at >= NOW() - INTERVAL '%d hours'", req.Hours) + result, err := g.DB().Ctx(ctx).Query(ctx, + "SELECT channel_id, channel_name, provider, COUNT(*) as error_count, "+ + "COUNT(*) FILTER (WHERE error_category = 'rate_limit') as rate_limit_count, "+ + "COUNT(*) FILTER (WHERE error_category = 'server_error') as server_error_count, "+ + "COUNT(*) FILTER (WHERE error_category = 'timeout') as timeout_count "+ + "FROM chn_error_events WHERE "+timeFilter+ + " GROUP BY channel_id, channel_name, provider ORDER BY error_count DESC LIMIT ?", + req.Limit) + if err != nil { + return nil, err + } + + return &v1.ChannelErrorTopChannelsRes{ + List: toMapList(result), + }, nil +} + +// ChannelErrorCategories 错误分类选项 +func (s *sAdmin) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (*v1.ChannelErrorCategoriesRes, error) { + return &v1.ChannelErrorCategoriesRes{ + Data: []map[string]string{ + {"value": "rate_limit", "label": "频率限制"}, + {"value": "auth_error", "label": "认证错误"}, + {"value": "timeout", "label": "超时"}, + {"value": "upstream_error", "label": "上游错误"}, + {"value": "server_error", "label": "服务端错误"}, + {"value": "network_error", "label": "网络错误"}, + {"value": "unknown", "label": "未知错误"}, + }, + }, nil +} + +// toMapList 将 Result 转为 []map[string]any +func toMapList(result gdb.Result) []map[string]any { + if result == nil || len(result) == 0 { + return []map[string]any{} + } + list := make([]map[string]any, len(result)) + for i, row := range result { + data := make(map[string]any, len(row)) + for k, v := range row { + data[k] = v.Val() + } + list[i] = data + } + return list +} diff --git a/internal/logic/common/channel_error_writer.go b/internal/logic/common/channel_error_writer.go new file mode 100644 index 0000000..4f65d66 --- /dev/null +++ b/internal/logic/common/channel_error_writer.go @@ -0,0 +1,28 @@ +package common + +import ( + "time" +) + +// DefaultChannelErrorWriter 渠道错误事件全局写入器 +var DefaultChannelErrorWriter *UsageLogWriter + +// InitChannelErrorWriter 初始化渠道错误事件写入器 +func InitChannelErrorWriter() { + DefaultChannelErrorWriter = NewUsageLogWriter(UsageLogWriterConfig{ + Table: "chn_error_events", + QueueSize: 4096, + BatchSize: 32, + FlushInterval: 2 * time.Second, + Workers: 2, + Overflow: OverflowDrop, + }) + DefaultChannelErrorWriter.Start() +} + +// CloseChannelErrorWriter 关闭渠道错误事件写入器 +func CloseChannelErrorWriter() { + if DefaultChannelErrorWriter != nil { + DefaultChannelErrorWriter.Close() + } +} diff --git a/internal/logic/common/partition.go b/internal/logic/common/partition.go index 0a77468..94dd277 100644 --- a/internal/logic/common/partition.go +++ b/internal/logic/common/partition.go @@ -26,6 +26,10 @@ var partitionedTables = []partitionedTable{ table: "ops_system_metrics", partitionColumn: "collected_at", }, + { + table: "chn_error_events", + partitionColumn: "created_at", + }, } // EnsurePartitions 检查并补齐所有分区表的当前月+未来 3 个月分区 diff --git a/internal/logic/monitor/alert_engine.go b/internal/logic/monitor/alert_engine.go index 8f5c743..afede48 100644 --- a/internal/logic/monitor/alert_engine.go +++ b/internal/logic/monitor/alert_engine.go @@ -168,6 +168,10 @@ func getMetricValue(ctx context.Context, metricType string) (float64, error) { return GetDBActiveConnections(ctx) case "redis.used_memory_mb": return GetRedisUsedMemoryMB(ctx) + case "channel.error_count": + return GetChannelErrorCount(ctx) + case "channel.rate_limit_count": + return GetChannelRateLimitCount(ctx) default: return 0, gerror.Newf("unsupported metric type: %s", metricType) } diff --git a/internal/logic/monitor/alert_rule.go b/internal/logic/monitor/alert_rule.go index 96ee419..bbcf3f2 100644 --- a/internal/logic/monitor/alert_rule.go +++ b/internal/logic/monitor/alert_rule.go @@ -17,15 +17,17 @@ import ( ) var validMetricTypes = map[string]string{ - "api.error_rate": "API错误率(%)", - "api.p95_latency": "API P95延迟(ms)", - "api.p99_latency": "API P99延迟(ms)", - "api.qps": "API QPS(请求/秒)", - "system.cpu_percent": "CPU使用率(%)", - "system.memory_percent": "内存使用率(%)", - "system.disk_percent": "磁盘使用率(%)", - "db.active_connections": "DB活跃连接数", - "redis.used_memory_mb": "Redis内存使用(MB)", + "api.error_rate": "API错误率(%)", + "api.p95_latency": "API P95延迟(ms)", + "api.p99_latency": "API P99延迟(ms)", + "api.qps": "API QPS(请求/秒)", + "system.cpu_percent": "CPU使用率(%)", + "system.memory_percent": "内存使用率(%)", + "system.disk_percent": "磁盘使用率(%)", + "db.active_connections": "DB活跃连接数", + "redis.used_memory_mb": "Redis内存使用(MB)", + "channel.error_count": "渠道错误数(5min)", + "channel.rate_limit_count": "渠道限速错误数(5min)", } // Alert rule conditions diff --git a/internal/logic/monitor/channel_metrics.go b/internal/logic/monitor/channel_metrics.go new file mode 100644 index 0000000..fba6cbd --- /dev/null +++ b/internal/logic/monitor/channel_metrics.go @@ -0,0 +1,33 @@ +package monitor + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" +) + +// GetChannelErrorCount 返回最近5分钟的渠道错误总数 +func GetChannelErrorCount(ctx context.Context) (float64, error) { + record, err := g.DB().Ctx(ctx).Query(ctx, + "SELECT COUNT(*) as cnt FROM chn_error_events WHERE created_at >= NOW() - INTERVAL '5 minutes'") + if err != nil { + return 0, err + } + if len(record) > 0 { + return float64(record[0]["cnt"].Int()), nil + } + return 0, nil +} + +// GetChannelRateLimitCount 返回最近5分钟的限速错误数 +func GetChannelRateLimitCount(ctx context.Context) (float64, error) { + record, err := g.DB().Ctx(ctx).Query(ctx, + "SELECT COUNT(*) as cnt FROM chn_error_events WHERE error_category = 'rate_limit' AND created_at >= NOW() - INTERVAL '5 minutes'") + if err != nil { + return 0, err + } + if len(record) > 0 { + return float64(record[0]["cnt"].Int()), nil + } + return 0, nil +} diff --git a/internal/model/do/bil_prededuct_tracks.go b/internal/model/do/bil_prededuct_tracks.go new file mode 100644 index 0000000..c426033 --- /dev/null +++ b/internal/model/do/bil_prededuct_tracks.go @@ -0,0 +1,23 @@ +// ================================================================================= +// 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" +) + +// BilPredeductTracks is the golang structure of table bil_prededuct_tracks for DAO operations like Where/Data. +type BilPredeductTracks struct { + g.Meta `orm:"table:bil_prededuct_tracks, do:true"` + Id any // + TenantId any // 租户 ID + RequestId any // 请求唯一 ID + Amount any // 预扣金额(USD) + ModelName any // 模型名称 + Status any // frozen=冻结中, settled=已结算, expired=超时自动释放, released=手动释放 + CreatedAt *gtime.Time // 创建时间 + ExpiredAt *gtime.Time // 过期释放时间(仅 status=expired 时有值) +} diff --git a/internal/model/do/chn_error_events.go b/internal/model/do/chn_error_events.go new file mode 100644 index 0000000..b5a222f --- /dev/null +++ b/internal/model/do/chn_error_events.go @@ -0,0 +1,33 @@ +// ================================================================================= +// 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" +) + +// ChnErrorEvents is the golang structure of table chn_error_events for DAO operations like Where/Data. +type ChnErrorEvents struct { + g.Meta `orm:"table:chn_error_events, do:true"` + Id any // 主键ID + ChannelId any // 发生错误的渠道ID + ChannelName any // 渠道名称(冗余存储,避免查询时JOIN) + ChannelType any // 渠道类型(ProviderType枚举值) + Provider any // 供应商名称(如 OpenAI, Claude, Zhipu 等) + ModelName any // 请求的模型名 + UpstreamModel any // 上游实际模型名(模型映射后) + RequestId any // 关联的请求唯一ID + TenantId any // 租户ID + ErrorCategory any // 错误分类:rate_limit/auth_error/timeout/upstream_error/server_error/network_error/unknown + StatusCode any // HTTP状态码(来自上游响应或RelayError.StatusCode) + ErrorType any // RelayError.Type原始值(upstream_error/channel_error/auth_error等) + ErrorMessage any // 错误详细信息 + IsRetryable any // 是否为可重试错误(429,500,502,503,504) + Attempt any // 重试轮次编号(0=首次) + IsFinal any // 是否为最终错误(非中间重试失败) + LatencyMs any // 请求耗时(毫秒) + CreatedAt *gtime.Time // 错误发生时间 +} diff --git a/internal/model/entity/bil_prededuct_tracks.go b/internal/model/entity/bil_prededuct_tracks.go new file mode 100644 index 0000000..5c60a90 --- /dev/null +++ b/internal/model/entity/bil_prededuct_tracks.go @@ -0,0 +1,21 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// BilPredeductTracks is the golang structure for table bil_prededuct_tracks. +type BilPredeductTracks struct { + Id int64 `json:"id" orm:"id" description:""` // + TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户 ID"` // 租户 ID + RequestId string `json:"request_id" orm:"request_id" description:"请求唯一 ID"` // 请求唯一 ID + Amount float64 `json:"amount" orm:"amount" description:"预扣金额(USD)"` // 预扣金额(USD) + ModelName string `json:"model_name" orm:"model_name" description:"模型名称"` // 模型名称 + Status string `json:"status" orm:"status" description:"frozen=冻结中, settled=已结算, expired=超时自动释放, released=手动释放"` // frozen=冻结中, settled=已结算, expired=超时自动释放, released=手动释放 + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 + ExpiredAt *gtime.Time `json:"expired_at" orm:"expired_at" description:"过期释放时间(仅 status=expired 时有值)"` // 过期释放时间(仅 status=expired 时有值) +} diff --git a/internal/model/entity/chn_error_events.go b/internal/model/entity/chn_error_events.go new file mode 100644 index 0000000..05a3e01 --- /dev/null +++ b/internal/model/entity/chn_error_events.go @@ -0,0 +1,31 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// ChnErrorEvents is the golang structure for table chn_error_events. +type ChnErrorEvents struct { + Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID + ChannelId int64 `json:"channel_id" orm:"channel_id" description:"发生错误的渠道ID"` // 发生错误的渠道ID + ChannelName string `json:"channel_name" orm:"channel_name" description:"渠道名称(冗余存储,避免查询时JOIN)"` // 渠道名称(冗余存储,避免查询时JOIN) + ChannelType int `json:"channel_type" orm:"channel_type" description:"渠道类型(ProviderType枚举值)"` // 渠道类型(ProviderType枚举值) + Provider string `json:"provider" orm:"provider" description:"供应商名称(如 OpenAI, Claude, Zhipu 等)"` // 供应商名称(如 OpenAI, Claude, Zhipu 等) + ModelName string `json:"model_name" orm:"model_name" description:"请求的模型名"` // 请求的模型名 + UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际模型名(模型映射后)"` // 上游实际模型名(模型映射后) + RequestId string `json:"request_id" orm:"request_id" description:"关联的请求唯一ID"` // 关联的请求唯一ID + TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID + ErrorCategory string `json:"error_category" orm:"error_category" description:"错误分类:rate_limit/auth_error/timeout/upstream_error/server_error/network_error/unknown"` // 错误分类:rate_limit/auth_error/timeout/upstream_error/server_error/network_error/unknown + StatusCode int `json:"status_code" orm:"status_code" description:"HTTP状态码(来自上游响应或RelayError.StatusCode)"` // HTTP状态码(来自上游响应或RelayError.StatusCode) + ErrorType string `json:"error_type" orm:"error_type" description:"RelayError.Type原始值(upstream_error/channel_error/auth_error等)"` // RelayError.Type原始值(upstream_error/channel_error/auth_error等) + ErrorMessage string `json:"error_message" orm:"error_message" description:"错误详细信息"` // 错误详细信息 + IsRetryable bool `json:"is_retryable" orm:"is_retryable" description:"是否为可重试错误(429,500,502,503,504)"` // 是否为可重试错误(429,500,502,503,504) + Attempt int `json:"attempt" orm:"attempt" description:"重试轮次编号(0=首次)"` // 重试轮次编号(0=首次) + IsFinal bool `json:"is_final" orm:"is_final" description:"是否为最终错误(非中间重试失败)"` // 是否为最终错误(非中间重试失败) + LatencyMs float64 `json:"latency_ms" orm:"latency_ms" description:"请求耗时(毫秒)"` // 请求耗时(毫秒) + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"错误发生时间"` // 错误发生时间 +} diff --git a/internal/service/admin.go b/internal/service/admin.go index 6e2b311..0ae40ca 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -95,6 +95,16 @@ type ( GetChannelHealthTrend(ctx context.Context, req *v1.ChannelHealthTrendReq) (*v1.ChannelHealthTrendRes, error) // ExportChannels exports channel list to CSV or Excel. ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (*v1.ChannelExportRes, error) + // ChannelErrorEventList 渠道错误事件列表 + ChannelErrorEventList(ctx context.Context, req *v1.ChannelErrorEventListReq) (*v1.ChannelErrorEventListRes, error) + // ChannelErrorStats 渠道错误统计 + ChannelErrorStats(ctx context.Context, req *v1.ChannelErrorStatsReq) (*v1.ChannelErrorStatsRes, error) + // ChannelErrorTrend 渠道错误趋势 + ChannelErrorTrend(ctx context.Context, req *v1.ChannelErrorTrendReq) (*v1.ChannelErrorTrendRes, error) + // ChannelErrorTopChannels 错误最多的渠道 + ChannelErrorTopChannels(ctx context.Context, req *v1.ChannelErrorTopChannelsReq) (*v1.ChannelErrorTopChannelsRes, error) + // ChannelErrorCategories 错误分类选项 + ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (*v1.ChannelErrorCategoriesRes, error) // ChannelOAuthAuthURL 生成 OAuth 授权链接 ChannelOAuthAuthURL(ctx context.Context, req *v1.ChannelOAuthAuthURLReq) (*v1.ChannelOAuthAuthURLRes, error) // ChannelOAuthExchange OAuth 授权码换取令牌 diff --git a/migrations/000004_channel_error_events.sql b/migrations/000004_channel_error_events.sql new file mode 100644 index 0000000..137047c --- /dev/null +++ b/migrations/000004_channel_error_events.sql @@ -0,0 +1,61 @@ +-- +goose Up +-- 渠道上游错误事件表:记录每次渠道错误(含重试中间失败),用于管理后台错误监控和告警 +CREATE TABLE chn_error_events ( + id BIGSERIAL, + channel_id BIGINT NOT NULL, + channel_name VARCHAR(100) NOT NULL, + channel_type INT NOT NULL, + provider VARCHAR(50) NOT NULL, + model_name VARCHAR(100) NOT NULL, + upstream_model VARCHAR(100), + request_id VARCHAR(64) NOT NULL, + tenant_id BIGINT NOT NULL, + error_category VARCHAR(30) NOT NULL, + status_code INT NOT NULL, + error_type VARCHAR(30) NOT NULL, + error_message TEXT NOT NULL, + is_retryable BOOLEAN NOT NULL DEFAULT false, + attempt INT NOT NULL DEFAULT 0, + is_final BOOLEAN NOT NULL DEFAULT false, + latency_ms FLOAT8 DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (id, created_at) +) PARTITION BY RANGE (created_at); + +-- BRIN 索引:追加写表的时间范围查询 +CREATE INDEX idx_chn_error_events_created_brin ON chn_error_events USING BRIN (created_at); + +-- 渠道 + 时间:最常用的筛选组合 +CREATE INDEX idx_chn_error_events_channel_created ON chn_error_events (channel_id, created_at DESC); + +-- 分类 + 时间:按错误类型统计 +CREATE INDEX idx_chn_error_events_category_created ON chn_error_events (error_category, created_at DESC); + +-- 请求 ID:关联追踪 +CREATE INDEX idx_chn_error_events_request_id ON chn_error_events (request_id); + +-- 字段注释 +COMMENT ON TABLE chn_error_events IS '渠道上游错误事件表(追加写入,按月分区)'; +COMMENT ON COLUMN chn_error_events.id IS '主键ID'; +COMMENT ON COLUMN chn_error_events.channel_id IS '发生错误的渠道ID'; +COMMENT ON COLUMN chn_error_events.channel_name IS '渠道名称(冗余存储,避免查询时JOIN)'; +COMMENT ON COLUMN chn_error_events.channel_type IS '渠道类型(ProviderType枚举值)'; +COMMENT ON COLUMN chn_error_events.provider IS '供应商名称(如 OpenAI, Claude, Zhipu 等)'; +COMMENT ON COLUMN chn_error_events.model_name IS '请求的模型名'; +COMMENT ON COLUMN chn_error_events.upstream_model IS '上游实际模型名(模型映射后)'; +COMMENT ON COLUMN chn_error_events.request_id IS '关联的请求唯一ID'; +COMMENT ON COLUMN chn_error_events.tenant_id IS '租户ID'; +COMMENT ON COLUMN chn_error_events.error_category IS '错误分类:rate_limit/auth_error/timeout/upstream_error/server_error/network_error/unknown'; +COMMENT ON COLUMN chn_error_events.status_code IS 'HTTP状态码(来自上游响应或RelayError.StatusCode)'; +COMMENT ON COLUMN chn_error_events.error_type IS 'RelayError.Type原始值(upstream_error/channel_error/auth_error等)'; +COMMENT ON COLUMN chn_error_events.error_message IS '错误详细信息'; +COMMENT ON COLUMN chn_error_events.is_retryable IS '是否为可重试错误(429,500,502,503,504)'; +COMMENT ON COLUMN chn_error_events.attempt IS '重试轮次编号(0=首次)'; +COMMENT ON COLUMN chn_error_events.is_final IS '是否为最终错误(非中间重试失败)'; +COMMENT ON COLUMN chn_error_events.latency_ms IS '请求耗时(毫秒)'; +COMMENT ON COLUMN chn_error_events.created_at IS '错误发生时间'; + +-- 分区由 EnsurePartitions 自动管理(启动时创建当前月+未来3个月),不在迁移脚本中手动创建 + +-- +goose Down +DROP TABLE IF EXISTS chn_error_events; diff --git a/relay/constant/error_category.go b/relay/constant/error_category.go new file mode 100644 index 0000000..62c21ab --- /dev/null +++ b/relay/constant/error_category.go @@ -0,0 +1,85 @@ +package constant + +import ( + "errors" + "strings" +) + +// 错误分类常量 +const ( + ErrorCategoryRateLimit = "rate_limit" + ErrorCategoryAuthError = "auth_error" + ErrorCategoryTimeout = "timeout" + ErrorCategoryUpstreamError = "upstream_error" + ErrorCategoryServerError = "server_error" + ErrorCategoryNetworkError = "network_error" + ErrorCategoryUnknown = "unknown" +) + +// ClassifyError 将 RelayError 映射到标准错误分类 +func ClassifyError(err error) string { + if err == nil { + return ErrorCategoryUnknown + } + + var relayErr *RelayError + if errors.As(err, &relayErr) { + return classifyRelayError(relayErr) + } + + return classifyRawError(err) +} + +func classifyRelayError(err *RelayError) string { + switch err.StatusCode { + case 429: + return ErrorCategoryRateLimit + case 401, 403: + return ErrorCategoryAuthError + case 504: + return ErrorCategoryTimeout + case 500, 502, 503: + return ErrorCategoryServerError + case 400, 422: + return ErrorCategoryUpstreamError + default: + return classifyByType(err.Type) + } +} + +func classifyByType(errType string) string { + switch errType { + case "rate_limit_error": + return ErrorCategoryRateLimit + case "auth_error": + return ErrorCategoryAuthError + case "insufficient_quota": + return ErrorCategoryUpstreamError + case "model_gone": + return ErrorCategoryUpstreamError + case "channel_error": + return ErrorCategoryServerError + case "upstream_error": + return ErrorCategoryUpstreamError + case "request_error": + return ErrorCategoryUpstreamError + default: + return ErrorCategoryUnknown + } +} + +func classifyRawError(err error) string { + msg := err.Error() + switch { + case strings.Contains(msg, "deadline exceeded"), strings.Contains(msg, "context deadline"): + return ErrorCategoryTimeout + case strings.Contains(msg, "connection refused"), strings.Contains(msg, "connection reset"), + strings.Contains(msg, "DNS"), strings.Contains(msg, "no such host"), + strings.Contains(msg, "TLS handshake"), strings.Contains(msg, "certificate"): + return ErrorCategoryNetworkError + case strings.Contains(msg, "timeout"), strings.Contains(msg, "i/o timeout"): + return ErrorCategoryTimeout + default: + return ErrorCategoryUnknown + } +} diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index ed56985..887d2ec 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -11,6 +12,7 @@ import ( "github.com/gogf/gf/v2/frame/g" + commonlogic "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/logic/monitor" "github.com/qianfree/team-api/relay/channel" "github.com/qianfree/team-api/relay/common" @@ -380,6 +382,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He scheduler.GetGlobalAffinity().Delete(rc.TenantID, rc.UserID, modelName) if constant.IsRetryable(err) && attempt < maxRetries { + recordChannelError(rc, selection, modelName, attempt, false, err, info.LatencyMs()) hop.Success = false hop.Error = err.Error() hop.LatencyMs = info.LatencyMs() @@ -393,6 +396,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He } recordFailedUsage(provider, rc, selection.ChannelID, modelName, relayMode, isStream, err) + recordChannelError(rc, selection, modelName, attempt, true, err, info.LatencyMs()) hop.Success = false hop.Error = err.Error() @@ -458,6 +462,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He _ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount) } recordFailedUsage(provider, rc, selection.ChannelID, modelName, relayMode, isStream, err) + recordChannelError(rc, selection, modelName, attempt, true, err, info.LatencyMs()) hop.Success = false hop.Error = err.Error() hop.LatencyMs = info.LatencyMs() @@ -469,6 +474,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He return nil, billingResult, err } + recordChannelError(rc, selection, modelName, attempt, false, err, info.LatencyMs()) hop.Success = false hop.Error = err.Error() hop.LatencyMs = info.LatencyMs() @@ -642,6 +648,48 @@ func recordFailedUsage(provider common.DataProvider, rc *RelayContext, channelID }) } +// recordChannelError 记录渠道错误事件到 chn_error_events(异步,不阻塞请求) +func recordChannelError(rc *RelayContext, selection *common.ChannelSelection, modelName string, attempt int, isFinal bool, err error, latencyMs float64) { + if commonlogic.DefaultChannelErrorWriter == nil { + return + } + + errMsg := err.Error() + if len(errMsg) > 500 { + errMsg = errMsg[:500] + } + + statusCode := 0 + errType := "unknown" + var relayErr *constant.RelayError + if errors.As(err, &relayErr) { + statusCode = relayErr.StatusCode + errType = relayErr.Type + } + + event := map[string]any{ + "channel_id": selection.ChannelID, + "channel_name": selection.ChannelName, + "channel_type": selection.ChannelType, + "provider": constant.ProviderType(selection.ChannelType).String(), + "model_name": modelName, + "request_id": rc.RequestID, + "tenant_id": rc.TenantID, + "error_category": constant.ClassifyError(err), + "status_code": statusCode, + "error_type": errType, + "error_message": errMsg, + "is_retryable": constant.IsRetryable(err), + "attempt": attempt, + "is_final": isFinal, + "latency_ms": latencyMs, + } + if selection.UpstreamModelName != "" { + event["upstream_model"] = selection.UpstreamModelName + } + commonlogic.DefaultChannelErrorWriter.Submit(event) +} + // estimateInputTokens 粗略估算输入 token 数(按字符数 / 4) func estimateInputTokens(body []byte) int { return len(body) / 4 diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index ddf408d..465c2f6 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -252,6 +252,12 @@ const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/views/monitor/ErrorLogsPage.vue'), meta: { title: '系统错误日志' }, }, + { + path: 'channel-errors', + name: 'AdminChannelErrors', + component: () => import('@/views/monitor/ChannelErrorsPage.vue'), + meta: { title: '渠道错误监控' }, + }, { path: 'content-filter-logs', name: 'AdminContentFilterLogs', diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 767948e..9c478f0 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -214,6 +214,7 @@ const menuGroups = [ { name: 'AdminAlertRules', label: '告警规则', icon: IconNotification }, { name: 'AdminAlertEvents', label: '告警记录', icon: IconFile }, { name: 'AdminErrorLogs', label: '错误日志', icon: IconFile }, + { name: 'AdminChannelErrors', label: '渠道错误监控', icon: IconFile }, { name: 'AdminContentFilterLogs', label: '拦截日志', icon: IconFile }, { name: 'AdminCronJobs', label: '定时任务', icon: IconFile }, ], diff --git a/web/admin/src/views/monitor/ChannelErrorsPage.vue b/web/admin/src/views/monitor/ChannelErrorsPage.vue new file mode 100644 index 0000000..6cde8c6 --- /dev/null +++ b/web/admin/src/views/monitor/ChannelErrorsPage.vue @@ -0,0 +1,342 @@ + + + + +