feat(monitor):添加模型渠道监控

This commit is contained in:
qian committed 2026-05-23 13:33:29 +08:00
1 parent 33ab33ff29
commit f0160c57d8
31 files changed
+1348 -9

No files matched your search

+6
View File
@@ -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 请求处理链路
```
+5
View File
@@ -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)
+66
View File
@@ -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"`
}
+14
View File
@@ -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`。
+4
View File
@@ -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()
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"github.com/qianfree/team-api/internal/dao/internal"
)
// 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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"github.com/qianfree/team-api/internal/dao/internal"
)
// 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.
@@ -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)
}
+113
View File
@@ -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)
}
+202
View File
@@ -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
}
@@ -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()
}
}
+4
View File
@@ -26,6 +26,10 @@ var partitionedTables = []partitionedTable{
table: "ops_system_metrics",
partitionColumn: "collected_at",
},
{
table: "chn_error_events",
partitionColumn: "created_at",
},
}
// EnsurePartitions 检查并补齐所有分区表的当前月+未来 3 个月分区
+4
View File
@@ -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)
}
+11 -9
View File
@@ -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
+33
View File
@@ -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
}
+23
View File
@@ -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 时有值)
}
+33
View File
@@ -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 // 错误发生时间
}
@@ -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 时有值)
}
+31
View File
@@ -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:"错误发生时间"` // 错误发生时间
}
+10
View File
@@ -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 授权码换取令牌
@@ -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;
+85
View File
@@ -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
}
}
+48
View File
@@ -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
+6
View File
@@ -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',
+1
View File
@@ -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 },
],
@@ -0,0 +1,342 @@
<script setup lang="ts">
import { ref, reactive, onMounted, h, computed } from 'vue'
import { Tag, Button, Select, Input, DatePicker } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
const loading = ref(false)
const data = ref<any[]>([])
const total = ref(0)
const pagination = reactive({ current: 1, pageSize: 20 })
// Filters
const filterChannelId = ref<number | undefined>(undefined)
const filterCategory = ref('')
const filterStatusCode = ref<number | undefined>(undefined)
const filterDateRange = ref<string[]>([])
const filterKeyword = ref('')
// Stats
const stats = ref<any>(null)
const statsLoading = ref(false)
// Category options
const categoryOptions = ref<any[]>([])
const hoursRange = ref(24)
// Error category display
const categoryColorMap: Record<string, string> = {
rate_limit: 'orange',
auth_error: 'red',
timeout: 'purple',
upstream_error: 'arcoblue',
server_error: 'red',
network_error: 'magenta',
unknown: 'gray',
}
const categoryLabelMap: Record<string, string> = {
rate_limit: '频率限制',
auth_error: '认证错误',
timeout: '超时',
upstream_error: '上游错误',
server_error: '服务端错误',
network_error: '网络错误',
unknown: '未知',
}
const columns: TableColumnData[] = [
{
title: '时间',
dataIndex: 'created_at',
width: 160,
render({ record }: { record: any }) {
return new Date(record.created_at).toLocaleString('zh-CN')
},
},
{
title: '渠道',
dataIndex: 'channel_name',
width: 120,
ellipsis: true,
},
{
title: '供应商',
dataIndex: 'provider',
width: 90,
},
{
title: '模型',
dataIndex: 'model_name',
width: 140,
ellipsis: true,
},
{
title: '分类',
dataIndex: 'error_category',
width: 100,
render({ record }: { record: any }) {
return h(Tag, { color: categoryColorMap[record.error_category] || 'gray', size: 'small' }, () => categoryLabelMap[record.error_category] || record.error_category)
},
},
{
title: '状态码',
dataIndex: 'status_code',
width: 80,
render({ record }: { record: any }) {
const color = record.status_code >= 500 ? 'red' : record.status_code >= 400 ? 'orange' : 'blue'
return h(Tag, { color, size: 'small' }, () => String(record.status_code))
},
},
{
title: '耗时',
dataIndex: 'latency_ms',
width: 80,
render({ record }: { record: any }) {
if (!record.latency_ms) return '-'
return `${Math.round(record.latency_ms)}ms`
},
},
{
title: '错误信息',
dataIndex: 'error_message',
ellipsis: true,
tooltip: true,
},
{
title: '重试',
dataIndex: 'attempt',
width: 80,
render({ record }: { record: any }) {
const text = record.is_final ? `第${record.attempt + 1}次(终)` : `第${record.attempt + 1}次`
const color = record.is_final ? 'red' : 'orange'
return h(Tag, { color, size: 'small' }, () => text)
},
},
{
title: '请求ID',
dataIndex: 'request_id',
width: 140,
ellipsis: true,
tooltip: true,
},
]
async function fetchCategories() {
try {
const res = await request.get('/admin/monitor/channel-errors/categories')
categoryOptions.value = res.data?.data?.data || []
} catch {
categoryOptions.value = []
}
}
async function fetchStats() {
statsLoading.value = true
try {
const res = await request.get('/admin/monitor/channel-errors/stats', {
params: { hours: hoursRange.value },
})
stats.value = res.data?.data || null
} catch {
stats.value = null
} finally {
statsLoading.value = false
}
}
async function fetchData() {
loading.value = true
try {
const params: any = {
page: pagination.current,
page_size: pagination.pageSize,
}
if (filterChannelId.value) params.channel_id = filterChannelId.value
if (filterCategory.value) params.error_category = filterCategory.value
if (filterStatusCode.value) params.status_code = filterStatusCode.value
if (filterKeyword.value) params.keyword = filterKeyword.value
if (filterDateRange.value?.length === 2) {
params.start_date = filterDateRange.value[0]
params.end_date = filterDateRange.value[1]
}
const res = await request.get('/admin/monitor/channel-errors', { params })
data.value = res.data?.data?.list || []
total.value = res.data?.data?.total || 0
} catch {
data.value = []
total.value = 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.current = 1
fetchData()
fetchStats()
}
function handleReset() {
filterChannelId.value = undefined
filterCategory.value = ''
filterStatusCode.value = undefined
filterDateRange.value = []
filterKeyword.value = ''
pagination.current = 1
fetchData()
fetchStats()
}
function handlePageChange(page: number) {
pagination.current = page
fetchData()
}
function handlePageSizeChange(pageSize: number) {
pagination.pageSize = pageSize
pagination.current = 1
fetchData()
}
const statsCards = computed(() => {
if (!stats.value) return []
const rateLimitCount = stats.value.by_category?.find((c: any) => c.error_category === 'rate_limit')?.count || 0
const serverErrorCount = stats.value.by_category?.find((c: any) => c.error_category === 'server_error')?.count || 0
const topChannel = stats.value.top_channels?.[0]?.channel_name || '-'
return [
{ label: `近${hoursRange.value}h错误总数`, value: stats.value.total || 0, color: '#f53f3f' },
{ label: '频率限制', value: rateLimitCount, color: '#ff7d00' },
{ label: '服务端错误', value: serverErrorCount, color: '#f53f3f' },
{ label: '最严重渠道', value: topChannel, color: '#722ed1' },
]
})
onMounted(() => {
fetchCategories()
fetchData()
fetchStats()
})
</script>
<template>
<div class="page-container">
<PageHeader title="渠道错误监控" description="上游渠道错误事件记录与趋势分析" />
<!-- Stats Cards -->
<div class="stats-row">
<div v-for="(card, idx) in statsCards" :key="idx" class="stat-card">
<div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
<div class="stat-label">{{ card.label }}</div>
</div>
<div class="stat-card">
<div class="stat-label">时间范围</div>
<Select v-model="hoursRange" size="small" style="width: 100px" @change="fetchStats">
<Select.Option :value="1">1小时</Select.Option>
<Select.Option :value="6">6小时</Select.Option>
<Select.Option :value="24">24小时</Select.Option>
<Select.Option :value="168">7天</Select.Option>
</Select>
</div>
</div>
<!-- Filters -->
<ACard :bordered="false" class="filter-card">
<ASpace wrap>
<Input
v-model="filterKeyword"
placeholder="搜索渠道/模型/错误信息"
style="width: 220px"
allow-clear
@press-enter="handleSearch"
/>
<Select
v-model="filterCategory"
placeholder="错误分类"
allow-clear
style="width: 140px"
>
<Select.Option v-for="opt in categoryOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</Select.Option>
</Select>
<Input
:model-value="filterStatusCode != null ? String(filterStatusCode) : undefined"
placeholder="状态码"
style="width: 100px"
allow-clear
@input="(val: string) => filterStatusCode = val ? Number(val) : undefined"
/>
<DatePicker
:model-value="filterDateRange as any"
range
style="width: 240px"
format="YYYY-MM-DD"
@change="(val: any) => filterDateRange = val || []"
/>
<Button type="primary" @click="handleSearch">搜索</Button>
<Button @click="handleReset">重置</Button>
</ASpace>
</ACard>
<!-- Table -->
<ACard :bordered="false">
<ATable
:data="data"
:loading="loading"
:columns="columns"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total: total,
showTotal: true,
showPageSize: true,
}"
row-key="id"
:scroll="{ x: 1400 }"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</ACard>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
}
.stats-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.stat-card {
background: var(--color-bg-2);
border: 1px solid var(--color-border-2);
border-radius: 8px;
padding: 16px 24px;
min-width: 160px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: var(--color-text-3);
}
.filter-card {
margin-bottom: 16px;
}
</style>