feat(monitor):添加模型渠道监控
This commit is contained in:
1 parent
33ab33ff29
commit
f0160c57d8
31 files changed
+1348
-9
No files matched your search
@@ -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 请求处理链路
|
||||
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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`。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,10 @@ var partitionedTables = []partitionedTable{
|
||||
table: "ops_system_metrics",
|
||||
partitionColumn: "collected_at",
|
||||
},
|
||||
{
|
||||
table: "chn_error_events",
|
||||
partitionColumn: "created_at",
|
||||
},
|
||||
}
|
||||
|
||||
// EnsurePartitions 检查并补齐所有分区表的当前月+未来 3 个月分区
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 时有值)
|
||||
}
|
||||
@@ -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 时有值)
|
||||
}
|
||||
@@ -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:"错误发生时间"` // 错误发生时间
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user