diff --git a/api/admin/admin.go b/api/admin/admin.go index cbc1274..d1cb074 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -63,6 +63,7 @@ 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) + ChannelResetHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (res *v1.ChannelResetHealthRes, 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) diff --git a/api/admin/v1/audit.go b/api/admin/v1/audit.go index 3b1d085..2647980 100644 --- a/api/admin/v1/audit.go +++ b/api/admin/v1/audit.go @@ -62,7 +62,9 @@ type RequestAuditLogListReq struct { g.Meta `path:"/audit/request-logs" method:"get" mime:"json" tags:"管理后台-审计" summary:"请求审计日志"` Page int `json:"page" d:"1"` PageSize int `json:"page_size" d:"20"` + ID int64 `json:"id" dc:"日志ID"` TenantID int `json:"tenant_id" dc:"租户ID"` + UserID int64 `json:"user_id" dc:"用户ID"` ApiKeyID int `json:"api_key_id" dc:"API Key ID"` Username string `json:"username" dc:"用户名(模糊匹配)"` RequestId string `json:"request_id" dc:"Request ID(精确匹配)"` @@ -70,8 +72,8 @@ type RequestAuditLogListReq struct { Method string `json:"method" dc:"HTTP 方法"` Path string `json:"path" dc:"请求路径"` StatusCode int `json:"status_code" dc:"状态码"` - StartDate string `json:"start_date" dc:"开始日期"` - EndDate string `json:"end_date" dc:"结束日期"` + StartDate string `json:"start_date" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` + EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` } type RequestAuditLogListRes struct { diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index 6ad4c42..c0c2cba 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -12,6 +12,7 @@ type ChannelListReq struct { Search string `json:"search" dc:"搜索关键词(渠道名称/备注)"` ID int64 `json:"id" dc:"按渠道 ID 精确筛选"` Model string `json:"model" dc:"按支持的模型名筛选(模糊匹配平台模型名/上游模型名)"` + BaseURL string `json:"base_url" dc:"按 API 地址筛选(模糊匹配)"` } // ChannelListRes 渠道列表响应 @@ -248,13 +249,14 @@ type HealthTrendPoint struct { // ChannelExportReq 导出渠道列表请求 type ChannelExportReq struct { - g.Meta `path:"/channels/export" method:"get" mime:"json" tags:"管理后台-渠道" summary:"导出渠道列表"` - Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` - Type int `json:"type" in:"query" dc:"供应商类型筛选"` - Status string `json:"status" in:"query" dc:"状态筛选:active/disabled/testing"` - Search string `json:"search" in:"query" dc:"搜索关键词(渠道名称/备注)"` - ID int64 `json:"id" in:"query" dc:"按渠道 ID 精确筛选"` - Model string `json:"model" in:"query" dc:"按支持的模型名筛选(模糊匹配平台模型名/上游模型名)"` + g.Meta `path:"/channels/export" method:"get" mime:"json" tags:"管理后台-渠道" summary:"导出渠道列表"` + Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` + Type int `json:"type" in:"query" dc:"供应商类型筛选"` + Status string `json:"status" in:"query" dc:"状态筛选:active/disabled/testing"` + Search string `json:"search" in:"query" dc:"搜索关键词(渠道名称/备注)"` + ID int64 `json:"id" in:"query" dc:"按渠道 ID 精确筛选"` + Model string `json:"model" in:"query" dc:"按支持的模型名筛选(模糊匹配平台模型名/上游模型名)"` + BaseURL string `json:"base_url" in:"query" dc:"按 API 地址筛选(模糊匹配)"` } type ChannelExportRes struct{} @@ -271,3 +273,12 @@ type ChannelCloneReq struct { type ChannelCloneRes struct { ID int64 `json:"id"` } + +// ChannelResetHealthReq 重置渠道健康度请求 +type ChannelResetHealthReq struct { + g.Meta `path:"/channels/{id}/reset-health" method:"post" mime:"json" tags:"管理后台-渠道" summary:"重置渠道健康度"` + ID int64 `json:"id" in:"path" v:"required" dc:"渠道ID"` +} + +// ChannelResetHealthRes 重置渠道健康度响应 +type ChannelResetHealthRes struct{} diff --git a/api/admin/v1/dashboard.go b/api/admin/v1/dashboard.go index 48e4f63..e35a291 100644 --- a/api/admin/v1/dashboard.go +++ b/api/admin/v1/dashboard.go @@ -122,13 +122,17 @@ type AdminUsageLogListReq struct { g.Meta `path:"/usage-logs" method:"get" mime:"json" tags:"管理后台-用量" summary:"用量日志列表"` Page int `json:"page" d:"1" dc:"页码"` PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"` + ID int64 `json:"id" dc:"用量记录ID"` TenantID int64 `json:"tenant_id" dc:"租户ID"` + UserID int64 `json:"user_id" dc:"用户ID"` Username string `json:"username" dc:"用户名(模糊匹配)"` + ApiKeyID int64 `json:"api_key_id" dc:"API Key ID"` + ChannelID int64 `json:"channel_id" dc:"渠道ID"` Model string `json:"model" dc:"模型名称"` Status string `json:"status" dc:"状态"` RequestType int `json:"request_type" dc:"请求类型: 1=同步, 2=流式, 3=异步, 4=WebSocket"` - StartDate string `json:"start_date" dc:"开始日期"` - EndDate string `json:"end_date" dc:"结束日期"` + StartDate string `json:"start_date" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` + EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` } type AdminUsageLogItem struct { @@ -373,13 +377,17 @@ type AdminWalletSetWarningThresholdRes struct{} type AdminUsageLogExportReq struct { g.Meta `path:"/usage-logs/export" method:"get" mime:"json" tags:"管理后台-用量" summary:"导出用量日志"` Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` + ID int64 `json:"id" in:"query" dc:"用量记录ID"` TenantID int64 `json:"tenant_id" in:"query" dc:"租户ID"` + UserID int64 `json:"user_id" in:"query" dc:"用户ID"` Username string `json:"username" in:"query" dc:"用户名(模糊匹配)"` + ApiKeyID int64 `json:"api_key_id" in:"query" dc:"API Key ID"` + ChannelID int64 `json:"channel_id" in:"query" dc:"渠道ID"` Model string `json:"model" in:"query" dc:"模型名称"` Status string `json:"status" in:"query" dc:"状态"` RequestType int `json:"request_type" in:"query" dc:"请求类型: 1=同步, 2=流式, 3=异步, 4=WebSocket"` - StartDate string `json:"start_date" in:"query" dc:"开始日期"` - EndDate string `json:"end_date" in:"query" dc:"结束日期"` + StartDate string `json:"start_date" in:"query" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` + EndDate string `json:"end_date" in:"query" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` } type AdminUsageLogExportRes struct{} diff --git a/api/admin/v1/task_management.go b/api/admin/v1/task_management.go index c2c2299..3e412e3 100644 --- a/api/admin/v1/task_management.go +++ b/api/admin/v1/task_management.go @@ -11,6 +11,11 @@ type TaskListReq struct { Status string `json:"status" in:"query" dc:"筛选状态"` Platform string `json:"platform" in:"query" dc:"筛选平台(sora/kling/midjourney/suno)"` PublicTaskID string `json:"public_task_id" in:"query" dc:"任务ID(精确匹配)"` + ModelName string `json:"model_name" in:"query" dc:"筛选模型名(精确匹配)"` + TenantID int64 `json:"tenant_id" in:"query" dc:"筛选租户ID"` + UserID int64 `json:"user_id" in:"query" dc:"筛选用户ID"` + StartDate string `json:"start_date" in:"query" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` + EndDate string `json:"end_date" in:"query" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` } type ModelTaskItem struct { diff --git a/internal/controller/admin/admin_v1_channel.go b/internal/controller/admin/admin_v1_channel.go index 45a9b3e..b6539e8 100644 --- a/internal/controller/admin/admin_v1_channel.go +++ b/internal/controller/admin/admin_v1_channel.go @@ -49,3 +49,6 @@ func (c *ControllerV1) ChannelExport(ctx context.Context, req *v1.ChannelExportR func (c *ControllerV1) ChannelClone(ctx context.Context, req *v1.ChannelCloneReq) (res *v1.ChannelCloneRes, err error) { return service.Admin().CloneChannel(ctx, req) } +func (c *ControllerV1) ChannelResetHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (res *v1.ChannelResetHealthRes, err error) { + return service.Admin().ResetChannelHealth(ctx, req) +} diff --git a/internal/dispatchadapter/state_redis.go b/internal/dispatchadapter/state_redis.go index 6d6b206..9394ca3 100644 --- a/internal/dispatchadapter/state_redis.go +++ b/internal/dispatchadapter/state_redis.go @@ -326,6 +326,44 @@ func (s *RedisState) MarkChannelRecovered(ctx context.Context, channelID int64) } } +// --------------------------------------------------------------------------- +// 健康度重置 +// --------------------------------------------------------------------------- + +// resetHealthSuccEwma 重置健康度时写入的成功率 EWMA:对应健康分≈80(0.9²×100=81)。 +const resetHealthSuccEwma = 0.9 + +// luaHealthReset 健康 EWMA 重置(管理后台"重置健康度"):成功率恢复为健康值、延迟清零。 +// ARGV: succ, nowMs, keyTtlMs +const luaHealthReset = ` +local succ = tonumber(ARGV[1]) +if succ <= 0 then succ = 1 end +redis.call('HSET', KEYS[1], 'succ_ewma', succ, 'lat_ewma', 0, 'updated_ms', ARGV[2]) +redis.call('PEXPIRE', KEYS[1], ARGV[3]) +return 1` + +// ResetHealth 重置渠道健康(管理后台"重置健康度"):渠道级 + 各模型级熔断复位为 CLOSED +// 并开启爬坡窗口(recovered_ms),各模型成功率 EWMA 恢复为健康值,渠道立即恢复被调度选择的能力。 +// 失败仅记 Debug 日志(管理后台手动操作,重试成本低)。 +func (s *RedisState) ResetHealth(ctx context.Context, channelID int64, models []string) { + chStr := strconv.FormatInt(channelID, 10) + now := time.Now().UnixMilli() + + s.resetBreakerState(ctx, keyBreaker+chStr, now) + for _, model := range models { + s.resetBreakerState(ctx, keyBreaker+chStr+":"+model, now) + if _, err := g.Redis().Do(ctx, "EVAL", luaHealthReset, 1, + keyHealth+chStr+":"+model, resetHealthSuccEwma, now, stateKeyTTLMs); err != nil { + g.Log().Debugf(ctx, "[Dispatch] 健康重置失败: channel=%d model=%s err=%v", channelID, model, err) + } + } +} + +// resetBreakerState 熔断复位为 CLOSED 并记录 recovered_ms(开启爬坡窗口)。 +func (s *RedisState) resetBreakerState(ctx context.Context, key string, nowMs int64) { + _, _ = g.Redis().Do(ctx, "EVAL", luaBreakerManualReset, 1, key, nowMs, stateKeyTTLMs) +} + // --------------------------------------------------------------------------- // 熔断探测令牌 // --------------------------------------------------------------------------- diff --git a/internal/dispatchadapter/wire.go b/internal/dispatchadapter/wire.go index e54763a..c6a2a7b 100644 --- a/internal/dispatchadapter/wire.go +++ b/internal/dispatchadapter/wire.go @@ -91,6 +91,19 @@ func MarkChannelRecovered(ctx context.Context, channelID int64) { } } +// ResetChannelHealth 重置渠道健康度(管理后台"重置健康度"按钮):熔断复位 + 成功率恢复, +// 渠道立即恢复被调度选择的能力。models 从目录快照取(该渠道服务的模型列表)。 +func ResetChannelHealth(ctx context.Context, channelID int64) { + if redisState == nil { + return + } + var models []string + if catalog != nil { + models = catalog.ChannelModels()[channelID] + } + redisState.ResetHealth(ctx, channelID, models) +} + // InvalidateChannel 渠道禁用/删除时的联动清理:清绑定 + 跨实例目录失效。 // 管理后台渠道写操作(更新/删除/Key 变更/能力变更)后调用。 func InvalidateChannel(ctx context.Context, channelID int64) { diff --git a/internal/logic/admin/audit.go b/internal/logic/admin/audit.go index b3be7a8..8a6b312 100644 --- a/internal/logic/admin/audit.go +++ b/internal/logic/admin/audit.go @@ -286,10 +286,10 @@ func MaskSensitiveData(data string) string { // ListRequestAuditLogs 分页查询请求审计日志(不返回 request_body/response_body 以优化性能) // 审计数据从审计库查询,关联信息(用户名、租户名等)从主库批量查询后在应用层合并。 func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditLogListReq) (*v1.RequestAuditLogListRes, error) { - if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { return nil, err } - if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil { + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { return nil, err } @@ -298,10 +298,18 @@ func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditL var conditions []string var args []any + if req.ID > 0 { + conditions = append(conditions, "id = ?") + args = append(args, req.ID) + } if req.TenantID > 0 { conditions = append(conditions, "tenant_id = ?") args = append(args, int64(req.TenantID)) } + if req.UserID > 0 { + conditions = append(conditions, "user_id = ?") + args = append(args, req.UserID) + } if req.ApiKeyID > 0 { conditions = append(conditions, "api_key_id = ?") args = append(args, int64(req.ApiKeyID)) @@ -354,11 +362,11 @@ func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditL } if req.StartDate != "" { conditions = append(conditions, "created_at >= ?") - args = append(args, req.StartDate+" 00:00:00") + args = append(args, common.StartOfRange(req.StartDate)) } if req.EndDate != "" { conditions = append(conditions, "created_at <= ?") - args = append(args, req.EndDate+" 23:59:59") + args = append(args, common.EndOfRange(req.EndDate)) } where := strings.Join(conditions, " AND ") diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index 30b63e3..d4800f5 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -61,6 +61,9 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1. if req.ID > 0 { query = query.Where("chn_channels.id", req.ID) } + if req.BaseURL != "" { + query = query.Where("chn_channels.base_url LIKE ?", "%"+req.BaseURL+"%") + } if req.Model != "" { // 按支持的模型名筛选:渠道需具备匹配的模型能力(chn_abilities)。 // 用 EXISTS 相关子查询而非 JOIN,避免一个渠道命中多个模型时产生重复行、破坏分页与总数。 @@ -706,6 +709,55 @@ func (s *sAdmin) GetChannelHealthTrend(ctx context.Context, req *v1.ChannelHealt return &v1.ChannelHealthTrendRes{Points: points}, nil } +// ResetChannelHealth 重置渠道健康度(渠道可能已修复、重新可用):落库健康分 80(展示层), +// 并复位熔断 + 成功率 EWMA(调度层),使渠道立即恢复被调度选择的能力。 +func (s *sAdmin) ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (*v1.ChannelResetHealthRes, error) { + // 渠道必须存在 + count, err := dao.ChnChannels.Ctx(ctx).Where("id", req.ID).Count() + if err != nil { + return nil, err + } + if count == 0 { + return nil, common.NewBusinessError(404, "渠道不存在") + } + + // 展示层:健康分落库为 80(调度不读此表,仅供仪表盘/审计展示;下次维护快照会按策略重算) + affected, err := dao.ChnHealthScores.Ctx(ctx). + Where("channel_id", req.ID). + Data(do.ChnHealthScores{ + SuccessRate: 90.00, + LatencyMs: 0, + StabilityScore: 100.00, + ConsecutiveFailures: 0, + HealthScore: 80.00, + CalculatedAt: gtime.Now(), + }). + UpdateAndGetAffected() + if err != nil { + return nil, err + } + // 健康分记录缺失时兜底插入(正常情况下创建渠道时已由 InitHealthScore 初始化) + if affected == 0 { + _, err = dao.ChnHealthScores.Ctx(ctx).Insert(do.ChnHealthScores{ + ChannelId: req.ID, + SuccessRate: 90.00, + LatencyMs: 0, + StabilityScore: 100.00, + ConsecutiveFailures: 0, + HealthScore: 80.00, + CalculatedAt: gtime.Now(), + }) + if err != nil { + return nil, err + } + } + + // 调度层:复位渠道级/模型级熔断 + 成功率恢复,渠道立即恢复被选择能力 + dispatchadapter.ResetChannelHealth(ctx, req.ID) + + return nil, nil +} + // ExportChannels exports channel list to CSV or Excel. func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (*v1.ChannelExportRes, error) { channelFields := "chn_channels.id, chn_channels.name, chn_channels.type, chn_channels.status, chn_channels.priority, chn_channels.weight, chn_channels.created_at, h.health_score" @@ -742,6 +794,9 @@ func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) ( if req.ID > 0 { query = query.Where("chn_channels.id", req.ID) } + if req.BaseURL != "" { + query = query.Where("chn_channels.base_url LIKE ?", "%"+req.BaseURL+"%") + } if req.Model != "" { // 与 ListChannels 保持一致:EXISTS 相关子查询避免 JOIN 重复行 query = query.Where("EXISTS (SELECT 1 FROM chn_abilities WHERE chn_abilities.channel_id = chn_channels.id AND (chn_abilities.model_name LIKE ? OR chn_abilities.upstream_model LIKE ?))", "%"+req.Model+"%", "%"+req.Model+"%") diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index f3b431f..533c400 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -351,10 +351,10 @@ func (s *sAdmin) GetModelHourlyCost(ctx context.Context, req *v1.AdminDashboardM // GetAllUsageLogs 获取所有租户的用量日志(管理后台) func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error) { - if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { return nil, err } - if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil { + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { return nil, err } @@ -363,14 +363,30 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR var conditions []string var args []any + if req.ID > 0 { + conditions = append(conditions, "u.id = ?") + args = append(args, req.ID) + } if req.TenantID > 0 { conditions = append(conditions, "u.tenant_id = ?") args = append(args, req.TenantID) } + if req.UserID > 0 { + conditions = append(conditions, "u.user_id = ?") + args = append(args, req.UserID) + } if req.Username != "" { conditions = append(conditions, "t.username LIKE ?") args = append(args, "%"+req.Username+"%") } + if req.ApiKeyID > 0 { + conditions = append(conditions, "u.api_key_id = ?") + args = append(args, req.ApiKeyID) + } + if req.ChannelID > 0 { + conditions = append(conditions, "u.channel_id = ?") + args = append(args, req.ChannelID) + } if req.Model != "" { conditions = append(conditions, "u.model_name = ?") args = append(args, req.Model) @@ -385,11 +401,11 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR } if req.StartDate != "" { conditions = append(conditions, "u.created_at >= ?") - args = append(args, req.StartDate+" 00:00:00") + args = append(args, common.StartOfRange(req.StartDate)) } if req.EndDate != "" { conditions = append(conditions, "u.created_at <= ?") - args = append(args, req.EndDate+" 23:59:59") + args = append(args, common.EndOfRange(req.EndDate)) } where := "" @@ -1120,10 +1136,10 @@ func (s *sAdmin) GetDashboardRecentAlerts(ctx context.Context, req *v1.AdminDash // ExportUsageLogs exports usage logs to CSV or Excel. func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExportReq) (*v1.AdminUsageLogExportRes, error) { - if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { return nil, err } - if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil { + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { return nil, err } @@ -1149,14 +1165,30 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor buildUsageWhere := func() (string, []any) { var conditions []string var args []any + if req.ID > 0 { + conditions = append(conditions, "u.id = ?") + args = append(args, req.ID) + } if req.TenantID > 0 { conditions = append(conditions, "u.tenant_id = ?") args = append(args, req.TenantID) } + if req.UserID > 0 { + conditions = append(conditions, "u.user_id = ?") + args = append(args, req.UserID) + } if req.Username != "" { conditions = append(conditions, "t.username LIKE ?") args = append(args, "%"+req.Username+"%") } + if req.ApiKeyID > 0 { + conditions = append(conditions, "u.api_key_id = ?") + args = append(args, req.ApiKeyID) + } + if req.ChannelID > 0 { + conditions = append(conditions, "u.channel_id = ?") + args = append(args, req.ChannelID) + } if req.Model != "" { conditions = append(conditions, "u.model_name = ?") args = append(args, req.Model) @@ -1171,11 +1203,11 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor } if req.StartDate != "" { conditions = append(conditions, "u.created_at >= ?") - args = append(args, req.StartDate+" 00:00:00") + args = append(args, common.StartOfRange(req.StartDate)) } if req.EndDate != "" { conditions = append(conditions, "u.created_at <= ?") - args = append(args, req.EndDate+" 23:59:59") + args = append(args, common.EndOfRange(req.EndDate)) } where := "" if len(conditions) > 0 { diff --git a/internal/logic/admin/task_management.go b/internal/logic/admin/task_management.go index 9d9e991..39f9d00 100644 --- a/internal/logic/admin/task_management.go +++ b/internal/logic/admin/task_management.go @@ -17,6 +17,13 @@ import ( // TaskList 大模型异步任务列表 func (s *sAdmin) TaskList(ctx context.Context, req *v1.TaskListReq) (*v1.TaskListRes, error) { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { + return nil, err + } + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { + return nil, err + } + m := dao.TskModelTasks.Ctx(ctx).OrderDesc("id") if req.Status != "" { m = m.Where("status", req.Status) @@ -27,6 +34,21 @@ func (s *sAdmin) TaskList(ctx context.Context, req *v1.TaskListReq) (*v1.TaskLis if req.PublicTaskID != "" { m = m.Where("public_task_id", req.PublicTaskID) } + if req.ModelName != "" { + m = m.Where("model_name", req.ModelName) + } + if req.TenantID > 0 { + m = m.Where("tenant_id", req.TenantID) + } + if req.UserID > 0 { + m = m.Where("user_id", req.UserID) + } + if req.StartDate != "" { + m = m.Where("created_at >= ?", common.StartOfRange(req.StartDate)) + } + if req.EndDate != "" { + m = m.Where("created_at <= ?", common.EndOfRange(req.EndDate)) + } var tasks []struct { Id int64 `json:"id"` diff --git a/internal/logic/common/validation.go b/internal/logic/common/validation.go index a3913da..3c43059 100644 --- a/internal/logic/common/validation.go +++ b/internal/logic/common/validation.go @@ -81,6 +81,36 @@ func ValidateDateParam(date string, fieldName string) error { return nil } +// ValidateDateTimeParam 校验时间筛选参数,兼容 YYYY-MM-DD 与 YYYY-MM-DD HH:mm:ss +// (前端时间范围选择器精确到分钟时传完整时间戳,仅选日期时传日期)。 +func ValidateDateTimeParam(value string, fieldName string) error { + if value == "" { + return nil + } + for _, layout := range []string{"2006-01-02", "2006-01-02 15:04:05"} { + if _, err := time.Parse(layout, value); err == nil { + return nil + } + } + return gerror.NewCode(gcode.New(consts.CodeBadRequest, "", nil), "%s格式无效,应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss", fieldName) +} + +// StartOfRange 返回时间范围过滤起点:仅日期(YYYY-MM-DD)时补齐当天 00:00:00,带时间则原样使用 +func StartOfRange(value string) string { + if len(value) == 10 { + return value + " 00:00:00" + } + return value +} + +// EndOfRange 返回时间范围过滤终点:仅日期(YYYY-MM-DD)时补齐当天 23:59:59,带时间则原样使用 +func EndOfRange(value string) string { + if len(value) == 10 { + return value + " 23:59:59" + } + return value +} + // ValidateForbiddenWords 校验名称是否包含系统禁用词 // 从系统配置 register_forbidden_words 读取逗号分隔的禁用词列表,对 value 做大小写不敏感的包含检查 func ValidateForbiddenWords(ctx context.Context, value, fieldName string) error { diff --git a/internal/service/admin.go b/internal/service/admin.go index 99a9300..f39a4a1 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -119,6 +119,9 @@ type ( GetProviderDefaultURLs(ctx context.Context, _ *v1.ProviderDefaultURLReq) (*v1.ProviderDefaultURLRes, error) // GetChannelHealthTrend 获取渠道健康趋势数据 GetChannelHealthTrend(ctx context.Context, req *v1.ChannelHealthTrendReq) (*v1.ChannelHealthTrendRes, error) + // ResetChannelHealth 重置渠道健康度(渠道可能已修复、重新可用):落库健康分 80(展示层), + // 并复位熔断 + 成功率 EWMA(调度层),使渠道立即恢复被调度选择的能力。 + ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (*v1.ChannelResetHealthRes, error) // ExportChannels exports channel list to CSV or Excel. ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (*v1.ChannelExportRes, error) // ChannelErrorEventList 渠道错误事件列表 diff --git a/web/admin/src/views/ai/ChannelsPage.vue b/web/admin/src/views/ai/ChannelsPage.vue index 61cbd79..14e0b81 100644 --- a/web/admin/src/views/ai/ChannelsPage.vue +++ b/web/admin/src/views/ai/ChannelsPage.vue @@ -28,6 +28,7 @@ const filterStatus = ref(undefined) const filterSearch = ref('') const filterID = ref(undefined) const filterModel = ref('') +const filterBaseURL = ref('') const statusOptions = [ { label: '全部状态', value: '' }, @@ -122,7 +123,7 @@ const columns: TableColumnData[] = [ }, { title: '创建时间', dataIndex: 'created_at', width: 170 }, { - title: '操作', dataIndex: 'actions', width: 210, fixed: 'right', + title: '操作', dataIndex: 'actions', width: 300, fixed: 'right', render({ record }) { const isActive = record.status === 'active' const action = isActive ? '禁用' : '启用' @@ -137,6 +138,15 @@ const columns: TableColumnData[] = [ status: isActive ? 'normal' : 'success', }, () => action)) : null, + hasPermission('channel:edit') + ? h(Popconfirm, { + content: '确定重置该渠道健康度?熔断将复位、成功率恢复,渠道立即恢复被调度选择。', + onOk: () => resetHealth(record), + }, () => h(Button, { + size: 'small', + status: 'warning', + }, () => '重置健康度')) + : null, h(Button, { size: 'small', type: 'primary', onClick: () => openDetail(record) }, () => '详情'), hasPermission('channel:delete') ? h(Popconfirm, { content: '确定删除该渠道?', onOk: () => deleteChannel(record) }, () => h(Button, { size: 'small', status: 'danger' }, () => '删除')) @@ -156,6 +166,7 @@ async function fetchData() { if (filterSearch.value) params.search = filterSearch.value if (filterID.value) params.id = filterID.value if (filterModel.value) params.model = filterModel.value + if (filterBaseURL.value) params.base_url = filterBaseURL.value const res: any = await request.get('/admin/channels', { params }) data.value = res.data?.data?.list || res.data?.list || [] // 记录本次加载时已持久化的权重/层级,供行内编辑失败回滚 @@ -234,6 +245,15 @@ async function deleteChannel(row: any) { catch { /* interceptor handles error toast */ } } +// 重置渠道健康度:熔断复位 + 成功率恢复,渠道立即恢复被调度选择的能力。 +async function resetHealth(row: any) { + try { + await request.post(`/admin/channels/${row.id}/reset-health`) + message.success('已重置健康度') + fetchData() + } catch { /* interceptor handles error toast */ } +} + // 快捷启用/禁用渠道:复用 PUT /admin/channels/{id}。 // 后端 UpdateChannel 会无条件写入 priority/weight,故必须带上当前值,否则会被重置为 0。 // status 为 active → 禁用;其它(disabled/testing)→ 启用为 active。 @@ -385,6 +405,7 @@ const { exporting, exportFile } = useExport({ search: filterSearch.value, id: filterID.value, model: filterModel.value, + base_url: filterBaseURL.value, }), }) @@ -446,11 +467,12 @@ const { exporting, exportFile } = useExport({ - - - + + + + 搜索 diff --git a/web/admin/src/views/ai/TaskLogsPage.vue b/web/admin/src/views/ai/TaskLogsPage.vue index 84d1fb9..831c100 100644 --- a/web/admin/src/views/ai/TaskLogsPage.vue +++ b/web/admin/src/views/ai/TaskLogsPage.vue @@ -22,6 +22,10 @@ const pagination = reactive({ const filterStatus = ref(null) const filterPlatform = ref(null) const filterTaskId = ref('') +const filterDateRange = ref([]) +const filterModel = ref('') +const filterTenantId = ref(undefined) +const filterUserId = ref(undefined) const statusOptions = [ { label: '全部状态', value: '' }, @@ -156,6 +160,13 @@ async function fetchData() { if (filterStatus.value) params.status = filterStatus.value if (filterPlatform.value) params.platform = filterPlatform.value if (filterTaskId.value) params.public_task_id = filterTaskId.value + if (filterDateRange.value && filterDateRange.value.length === 2) { + params.start_date = filterDateRange.value[0] + params.end_date = filterDateRange.value[1] + } + if (filterModel.value) params.model_name = filterModel.value + if (filterTenantId.value) params.tenant_id = filterTenantId.value + if (filterUserId.value) params.user_id = filterUserId.value const res: any = await request.get('/admin/tasks', { params }) const raw = res.data?.data data.value = (raw?.list || []).filter(Boolean) @@ -177,6 +188,10 @@ function resetFilter() { filterStatus.value = null filterPlatform.value = null filterTaskId.value = '' + filterDateRange.value = [] + filterModel.value = '' + filterTenantId.value = undefined + filterUserId.value = undefined pagination.current = 1 fetchData() } @@ -229,6 +244,12 @@ onMounted(() => { + { style="width: 200px" @keydown.enter="handleFilter" /> + { style="width: 130px" @change="handleFilter" /> - + + 搜索 重置 diff --git a/web/admin/src/views/security/RequestAuditLogsPage.vue b/web/admin/src/views/security/RequestAuditLogsPage.vue index 1555bd6..f35fb37 100644 --- a/web/admin/src/views/security/RequestAuditLogsPage.vue +++ b/web/admin/src/views/security/RequestAuditLogsPage.vue @@ -22,13 +22,11 @@ const pagination = reactive({ pageSizeOptions: [10, 20, 50], }) const filter = reactive({ - tenant_id: null as number | null, - api_key_id: '', - username: '', + id: null as number | null, request_id: '', - task_id: '', - method: '', - status_code: '', + tenant_id: null as number | null, + user_id: null as number | null, + api_key_id: '', date_range: [] as string[], }) @@ -197,13 +195,11 @@ async function fetchData() { page: pagination.current, page_size: pagination.pageSize, } - if (filter.tenant_id) params.tenant_id = filter.tenant_id - if (filter.api_key_id) params.api_key_id = parseInt(filter.api_key_id) - if (filter.username) params.username = filter.username - if (filter.method) params.method = filter.method + if (filter.id) params.id = filter.id if (filter.request_id) params.request_id = filter.request_id - if (filter.task_id) params.task_id = filter.task_id - if (filter.status_code) params.status_code = parseInt(filter.status_code) + if (filter.tenant_id) params.tenant_id = filter.tenant_id + if (filter.user_id) params.user_id = filter.user_id + if (filter.api_key_id) params.api_key_id = parseInt(filter.api_key_id) if (filter.date_range.length === 2) { params.start_date = filter.date_range[0] params.end_date = filter.date_range[1] @@ -246,13 +242,11 @@ function handleFilter() { } function handleReset() { - filter.tenant_id = null - filter.api_key_id = '' - filter.username = '' + filter.id = null filter.request_id = '' - filter.task_id = '' - filter.method = '' - filter.status_code = '' + filter.tenant_id = null + filter.user_id = null + filter.api_key_id = '' filter.date_range = [] pagination.current = 1 fetchData() @@ -264,9 +258,6 @@ onMounted(() => { if (route.query.request_id) { filter.request_id = String(route.query.request_id) } - if (route.query.task_id) { - filter.task_id = String(route.query.task_id) - } fetchData() }) @@ -277,6 +268,28 @@ onMounted(() => { + + + { @change="handleFilter" @clear="handleFilter" /> + { style="width: 100px" @keydown.enter="handleFilter" /> - - - - - GET - POST - PUT - DELETE - - - 搜索 重置 diff --git a/web/admin/src/views/tenant/UsageLogsPage.vue b/web/admin/src/views/tenant/UsageLogsPage.vue index 3406378..24c2aac 100644 --- a/web/admin/src/views/tenant/UsageLogsPage.vue +++ b/web/admin/src/views/tenant/UsageLogsPage.vue @@ -21,11 +21,14 @@ const pagination = reactive({ pageSizeOptions: [10, 20, 50, 100], }) +const filterId = ref(undefined) const filterTenantId = ref(undefined) -const filterUsername = ref('') +const filterUserId = ref(undefined) const filterModel = ref(undefined) -const filterStatus = ref(undefined) +const filterApiKeyId = ref(undefined) const filterRequestType = ref(undefined) +const filterChannelId = ref(undefined) +const filterStatus = ref(undefined) const filterDateRange = ref([]) const tenantOptions = ref<{ label: string; value: number }[]>([]) @@ -389,11 +392,14 @@ async function fetchData() { page: pagination.current, page_size: pagination.pageSize, } + if (filterId.value) params.id = filterId.value if (filterTenantId.value) params.tenant_id = filterTenantId.value - if (filterUsername.value) params.username = filterUsername.value + if (filterUserId.value) params.user_id = filterUserId.value if (filterModel.value) params.model = filterModel.value - if (filterStatus.value) params.status = filterStatus.value + if (filterApiKeyId.value) params.api_key_id = filterApiKeyId.value if (filterRequestType.value) params.request_type = filterRequestType.value + if (filterChannelId.value) params.channel_id = filterChannelId.value + if (filterStatus.value) params.status = filterStatus.value if (filterDateRange.value && filterDateRange.value.length === 2) { params.start_date = filterDateRange.value[0] params.end_date = filterDateRange.value[1] @@ -417,11 +423,14 @@ function handleFilter() { } function handleReset() { + filterId.value = undefined filterTenantId.value = undefined - filterUsername.value = '' + filterUserId.value = undefined filterModel.value = undefined - filterStatus.value = undefined + filterApiKeyId.value = undefined filterRequestType.value = undefined + filterChannelId.value = undefined + filterStatus.value = undefined filterDateRange.value = [] pagination.current = 1 fetchData() @@ -436,11 +445,14 @@ onMounted(() => { const { exporting, exportFile } = useExport({ url: '/admin/usage-logs/export', getFilters: () => ({ + id: filterId.value, tenant_id: filterTenantId.value, - username: filterUsername.value, + user_id: filterUserId.value, model: filterModel.value, - status: filterStatus.value, + api_key_id: filterApiKeyId.value, request_type: filterRequestType.value, + channel_id: filterChannelId.value, + status: filterStatus.value, start_date: filterDateRange.value?.[0], end_date: filterDateRange.value?.[1], }), @@ -464,10 +476,25 @@ const { exporting, exportFile } = useExport({ + + - - - + 搜索