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 @@
+
+
+
+
+
+
+
+
+
+
{{ card.value }}
+
{{ card.label }}
+
+
+
时间范围
+
+
+
+
+
+
+
+
+
+ filterStatusCode = val ? Number(val) : undefined"
+ />
+ filterDateRange = val || []"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+