improve(admin):优化管理功能搜索条件

This commit is contained in:
qian committed 2026-08-04 22:50:57 +08:00
1 parent a73cf411a5
commit c7d15d82a4
18 files changed
+438 -119

No files matched your search

+1
View File
@@ -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)
+4 -2
View File
@@ -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 {
+18 -7
View File
@@ -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{}
+12 -4
View File
@@ -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{}
+5
View File
@@ -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 {
@@ -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)
}
+38
View File
@@ -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)
}
// ---------------------------------------------------------------------------
// 熔断探测令牌
// ---------------------------------------------------------------------------
+13
View File
@@ -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) {
+12 -4
View File
@@ -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 ")
+55
View File
@@ -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+"%")
+40 -8
View File
@@ -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 {
+22
View File
@@ -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"`
+30
View File
@@ -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 {
+3
View File
@@ -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 渠道错误事件列表
+26 -4
View File
@@ -28,6 +28,7 @@ const filterStatus = ref<string | undefined>(undefined)
const filterSearch = ref('')
const filterID = ref<number | undefined>(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,
}),
})
</script>
@@ -446,11 +467,12 @@ const { exporting, exportFile } = useExport({
<!-- Filters -->
<ACard :bordered="false" class="mb-4">
<ASpace>
<ASelect v-model="filterType" :options="[{ label: '全部类型', value: '' }, ...providerTypeOptions]" placeholder="供应商类型" allow-clear allow-search style="width: 180px" @change="handleFilter" />
<ASelect v-model="filterStatus" :options="statusOptions" placeholder="状态" allow-clear style="width: 120px" @change="handleFilter" />
<AInput v-model="filterSearch" placeholder="搜索渠道名..." allow-clear style="width: 200px" @keydown.enter="handleFilter" @clear="handleFilter" />
<AInputNumber v-model="filterID" placeholder="渠道 ID" :min="1" allow-clear style="width: 120px" @change="handleFilter" @clear="handleFilter" />
<ASelect v-model="filterType" :options="[{ label: '全部类型', value: '' }, ...providerTypeOptions]" placeholder="供应商类型" allow-clear allow-search style="width: 180px" @change="handleFilter" />
<AInput v-model="filterModel" placeholder="搜索模型名..." allow-clear style="width: 160px" @keydown.enter="handleFilter" @clear="handleFilter" />
<ASelect v-model="filterStatus" :options="statusOptions" placeholder="状态" allow-clear style="width: 120px" @change="handleFilter" />
<AInput v-model="filterBaseURL" placeholder="搜索地址..." allow-clear style="width: 220px" @keydown.enter="handleFilter" @clear="handleFilter" />
<AInput v-model="filterSearch" placeholder="搜索渠道名..." allow-clear style="width: 200px" @keydown.enter="handleFilter" @clear="handleFilter" />
<AButton @click="handleFilter">搜索</AButton>
</ASpace>
</ACard>
+51 -5
View File
@@ -22,6 +22,10 @@ const pagination = reactive({
const filterStatus = ref<string | null>(null)
const filterPlatform = ref<string | null>(null)
const filterTaskId = ref('')
const filterDateRange = ref<string[]>([])
const filterModel = ref('')
const filterTenantId = ref<number | undefined>(undefined)
const filterUserId = ref<number | undefined>(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(() => {
<!-- Filters -->
<ACard :bordered="false" class="mb-4">
<ASpace wrap>
<ARangePicker
v-model="filterDateRange"
show-time
style="width: 340px"
@change="handleFilter"
/>
<AInput
v-model="filterTaskId"
placeholder="任务ID"
@@ -236,6 +257,14 @@ onMounted(() => {
style="width: 200px"
@keydown.enter="handleFilter"
/>
<ASelect
v-model="filterPlatform"
:options="platformOptions"
placeholder="平台"
allow-clear
style="width: 130px"
@change="handleFilter"
/>
<ASelect
v-model="filterStatus"
:options="statusOptions"
@@ -244,13 +273,30 @@ onMounted(() => {
style="width: 130px"
@change="handleFilter"
/>
<ASelect
v-model="filterPlatform"
:options="platformOptions"
placeholder="平台"
<AInput
v-model="filterModel"
placeholder="模型"
allow-clear
style="width: 130px"
style="width: 160px"
@keydown.enter="handleFilter"
/>
<AInputNumber
v-model="filterTenantId"
placeholder="租户ID"
:min="1"
allow-clear
style="width: 120px"
@change="handleFilter"
@clear="handleFilter"
/>
<AInputNumber
v-model="filterUserId"
placeholder="用户ID"
:min="1"
allow-clear
style="width: 120px"
@change="handleFilter"
@clear="handleFilter"
/>
<AButton type="primary" @click="handleFilter">搜索</AButton>
<AButton @click="resetFilter">重置</AButton>
@@ -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()
})
</script>
@@ -277,6 +268,28 @@ onMounted(() => {
<a-card :bordered="false" class="mb-4">
<a-space wrap>
<a-range-picker
v-model="filter.date_range"
show-time
style="width: 340px"
@change="handleFilter"
/>
<a-input-number
v-model="filter.id"
placeholder="日志ID"
:min="1"
allow-clear
style="width: 120px"
@change="handleFilter"
@clear="handleFilter"
/>
<a-input
v-model="filter.request_id"
placeholder="Request ID"
allow-clear
style="width: 200px"
@keydown.enter="handleFilter"
/>
<a-select
v-model="filter.tenant_id"
:options="tenantOptions"
@@ -289,6 +302,15 @@ onMounted(() => {
@change="handleFilter"
@clear="handleFilter"
/>
<a-input-number
v-model="filter.user_id"
placeholder="用户ID"
:min="1"
allow-clear
style="width: 120px"
@change="handleFilter"
@clear="handleFilter"
/>
<a-input
v-model="filter.api_key_id"
placeholder="Key ID"
@@ -296,50 +318,6 @@ onMounted(() => {
style="width: 100px"
@keydown.enter="handleFilter"
/>
<a-input
v-model="filter.username"
placeholder="用户名"
allow-clear
style="width: 120px"
@keydown.enter="handleFilter"
/>
<a-input
v-model="filter.request_id"
placeholder="Request ID"
allow-clear
style="width: 200px"
@keydown.enter="handleFilter"
/>
<a-input
v-model="filter.task_id"
placeholder="Task ID"
allow-clear
style="width: 200px"
@keydown.enter="handleFilter"
/>
<a-select
v-model="filter.method"
placeholder="方法"
allow-clear
style="width: 100px"
>
<a-option value="GET">GET</a-option>
<a-option value="POST">POST</a-option>
<a-option value="PUT">PUT</a-option>
<a-option value="DELETE">DELETE</a-option>
</a-select>
<a-input
v-model="filter.status_code"
placeholder="状态码"
allow-clear
style="width: 100px"
@keydown.enter="handleFilter"
/>
<a-range-picker
v-model="filter.date_range"
style="width: 260px"
format="YYYY-MM-DD"
/>
<a-button type="primary" @click="handleFilter">搜索</a-button>
<a-button @click="handleReset">重置</a-button>
</a-space>
+62 -20
View File
@@ -21,11 +21,14 @@ const pagination = reactive({
pageSizeOptions: [10, 20, 50, 100],
})
const filterId = ref<number | undefined>(undefined)
const filterTenantId = ref<number | undefined>(undefined)
const filterUsername = ref('')
const filterUserId = ref<number | undefined>(undefined)
const filterModel = ref<string | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
const filterApiKeyId = ref<number | undefined>(undefined)
const filterRequestType = ref<string | undefined>(undefined)
const filterChannelId = ref<number | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
const filterDateRange = ref<string[]>([])
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({
<a-card :bordered="false" class="mb-4">
<a-space wrap>
<a-range-picker
v-model="filterDateRange"
show-time
style="width: 340px"
@change="handleFilter"
/>
<a-input-number
v-model="filterId"
placeholder="记录ID"
:min="1"
allow-clear
style="width: 120px"
@change="handleFilter"
@clear="handleFilter"
/>
<a-select
v-model="filterTenantId"
:options="tenantOptions"
placeholder="搜索租户"
placeholder="租户ID"
allow-search
allow-clear
:filter-option="false"
@@ -476,12 +503,14 @@ const { exporting, exportFile } = useExport({
@change="handleFilter"
@clear="handleFilter"
/>
<a-input
v-model="filterUsername"
placeholder="用户名"
<a-input-number
v-model="filterUserId"
placeholder="用户ID"
:min="1"
allow-clear
style="width: 120px"
@keydown.enter="handleFilter"
@change="handleFilter"
@clear="handleFilter"
/>
<a-select
v-model="filterModel"
@@ -495,13 +524,14 @@ const { exporting, exportFile } = useExport({
@change="handleFilter"
@clear="handleFilter"
/>
<a-select
v-model="filterStatus"
:options="statusOptions"
placeholder="状态"
<a-input-number
v-model="filterApiKeyId"
placeholder="API Key ID"
:min="1"
allow-clear
style="width: 120px"
@change="handleFilter"
@clear="handleFilter"
/>
<a-select
v-model="filterRequestType"
@@ -511,9 +541,21 @@ const { exporting, exportFile } = useExport({
style="width: 120px"
@change="handleFilter"
/>
<a-range-picker
v-model="filterDateRange"
style="width: 280px"
<a-input-number
v-model="filterChannelId"
placeholder="渠道ID"
:min="1"
allow-clear
style="width: 120px"
@change="handleFilter"
@clear="handleFilter"
/>
<a-select
v-model="filterStatus"
:options="statusOptions"
placeholder="状态"
allow-clear
style="width: 120px"
@change="handleFilter"
/>
<a-button type="primary" @click="handleFilter">搜索</a-button>