| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527 |
- package admin
- import (
- "context"
- "log/slog"
- "strconv"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/domain"
- "github.com/Wei-Shaw/sub2api/internal/handler/dto"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/response"
- middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- )
- const (
- // monitorMaxPageSize 列表分页上限。
- monitorMaxPageSize = 100
- // monitorAPIKeyMaskPrefix 脱敏时保留的明文前缀长度。
- monitorAPIKeyMaskPrefix = 4
- // monitorAPIKeyMaskSuffix 脱敏后追加的占位字符串。
- monitorAPIKeyMaskSuffix = "***"
- )
- // ChannelMonitorHandler 渠道监控管理后台 handler。
- type ChannelMonitorHandler struct {
- monitorService *service.ChannelMonitorService
- }
- // NewChannelMonitorHandler 创建 handler。
- func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *ChannelMonitorHandler {
- return &ChannelMonitorHandler{monitorService: monitorService}
- }
- // --- Request / Response ---
- type channelMonitorCreateRequest struct {
- Name string `json:"name" binding:"required,max=100"`
- Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
- APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
- Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
- APIKey string `json:"api_key" binding:"omitempty,max=2000"`
- PrimaryModel string `json:"primary_model" binding:"max=200"`
- ExtraModels []string `json:"extra_models"`
- GroupName string `json:"group_name" binding:"max=100"`
- Enabled *bool `json:"enabled"`
- IntervalSeconds int `json:"interval_seconds" binding:"required,min=15,max=3600"`
- JitterSeconds int `json:"jitter_seconds" binding:"omitempty,min=0,max=3585"`
- TemplateID *int64 `json:"template_id"`
- ExtraHeaders map[string]string `json:"extra_headers"`
- BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
- BodyOverride map[string]any `json:"body_override"`
- // CheckMode: probe(默认)/ quota / quota_probe。quota 模式 endpoint/api_key
- // 可空(条件必填校验在 service 层按模式分支)。
- CheckMode string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
- // AccountID: 配额模式关联的账号 ID。
- AccountID *int64 `json:"account_id"`
- }
- type channelMonitorUpdateRequest struct {
- Name *string `json:"name" binding:"omitempty,max=100"`
- Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
- APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
- Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
- APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
- PrimaryModel *string `json:"primary_model" binding:"omitempty,max=200"`
- ExtraModels *[]string `json:"extra_models"`
- GroupName *string `json:"group_name" binding:"omitempty,max=100"`
- Enabled *bool `json:"enabled"`
- IntervalSeconds *int `json:"interval_seconds" binding:"omitempty,min=15,max=3600"`
- JitterSeconds *int `json:"jitter_seconds" binding:"omitempty,min=0,max=3585"`
- TemplateID *int64 `json:"template_id"`
- ClearTemplate bool `json:"clear_template"` // true 时把 template_id 置空,忽略 TemplateID
- ExtraHeaders *map[string]string `json:"extra_headers"`
- BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
- BodyOverride *map[string]any `json:"body_override"`
- // CheckMode/AccountID:nil = 不更新;AccountID 指向 0 = 清空关联。
- CheckMode *string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
- AccountID *int64 `json:"account_id"`
- }
- type channelMonitorResponse struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- Provider string `json:"provider"`
- APIMode string `json:"api_mode"`
- Endpoint string `json:"endpoint"`
- APIKeyMasked string `json:"api_key_masked"`
- APIKeyDecryptFailed bool `json:"api_key_decrypt_failed"`
- PrimaryModel string `json:"primary_model"`
- ExtraModels []string `json:"extra_models"`
- GroupName string `json:"group_name"`
- Enabled bool `json:"enabled"`
- IntervalSeconds int `json:"interval_seconds"`
- JitterSeconds int `json:"jitter_seconds"`
- LastCheckedAt *string `json:"last_checked_at"`
- CreatedBy int64 `json:"created_by"`
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
- PrimaryStatus string `json:"primary_status"`
- PrimaryLatencyMs *int `json:"primary_latency_ms"`
- Availability7d float64 `json:"availability_7d"`
- ExtraModelsStatus []dto.ChannelMonitorExtraModelStatus `json:"extra_models_status"`
- // 请求自定义快照:前端编辑 / 展示「高级设置」用
- TemplateID *int64 `json:"template_id"`
- ExtraHeaders map[string]string `json:"extra_headers"`
- BodyOverrideMode string `json:"body_override_mode"`
- BodyOverride map[string]any `json:"body_override"`
- // 配额模式:check_mode + 关联账号 + 主模型最近配额快照
- // (LatestQuota 由 List handler 批量聚合后填充;管理端不受 channel_monitor_show_quota 影响)。
- CheckMode string `json:"check_mode"`
- AccountID *int64 `json:"account_id"`
- LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"`
- }
- type channelMonitorCheckResultResponse struct {
- Model string `json:"model"`
- Status string `json:"status"`
- LatencyMs *int `json:"latency_ms"`
- PingLatencyMs *int `json:"ping_latency_ms"`
- Message string `json:"message"`
- CheckedAt string `json:"checked_at"`
- Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
- }
- type channelMonitorHistoryItemResponse struct {
- ID int64 `json:"id"`
- Model string `json:"model"`
- Status string `json:"status"`
- LatencyMs *int `json:"latency_ms"`
- PingLatencyMs *int `json:"ping_latency_ms"`
- Message string `json:"message"`
- CheckedAt string `json:"checked_at"`
- Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
- }
- // maskAPIKey 对 API Key 明文做脱敏:前 4 字符 + "***",长度 ≤ 4 时只显示 "***"。
- func maskAPIKey(plain string) string {
- if len(plain) <= monitorAPIKeyMaskPrefix {
- return monitorAPIKeyMaskSuffix
- }
- return plain[:monitorAPIKeyMaskPrefix] + monitorAPIKeyMaskSuffix
- }
- func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse {
- if m == nil {
- return nil
- }
- extras := m.ExtraModels
- if extras == nil {
- extras = []string{}
- }
- headers := m.ExtraHeaders
- if headers == nil {
- headers = map[string]string{}
- }
- resp := &channelMonitorResponse{
- ID: m.ID,
- Name: m.Name,
- Provider: m.Provider,
- APIMode: m.APIMode,
- Endpoint: m.Endpoint,
- APIKeyMasked: maskAPIKey(m.APIKey),
- APIKeyDecryptFailed: m.APIKeyDecryptFailed,
- PrimaryModel: m.PrimaryModel,
- ExtraModels: extras,
- GroupName: m.GroupName,
- Enabled: m.Enabled,
- IntervalSeconds: m.IntervalSeconds,
- JitterSeconds: m.JitterSeconds,
- CreatedBy: m.CreatedBy,
- CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
- UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339),
- TemplateID: m.TemplateID,
- ExtraHeaders: headers,
- BodyOverrideMode: m.BodyOverrideMode,
- BodyOverride: m.BodyOverride,
- CheckMode: m.CheckMode,
- AccountID: m.AccountID,
- // PrimaryStatus / PrimaryLatencyMs / Availability7d / LatestQuota
- // 由 List handler 在批量聚合后填充。
- }
- if m.LastCheckedAt != nil {
- s := m.LastCheckedAt.UTC().Format(time.RFC3339)
- resp.LastCheckedAt = &s
- }
- return resp
- }
- func checkResultToResponse(r *service.CheckResult) channelMonitorCheckResultResponse {
- return channelMonitorCheckResultResponse{
- Model: r.Model,
- Status: r.Status,
- LatencyMs: r.LatencyMs,
- PingLatencyMs: r.PingLatencyMs,
- Message: r.Message,
- CheckedAt: r.CheckedAt.UTC().Format(time.RFC3339),
- Quota: r.Quota,
- }
- }
- func historyEntryToResponse(e *service.ChannelMonitorHistoryEntry) channelMonitorHistoryItemResponse {
- return channelMonitorHistoryItemResponse{
- ID: e.ID,
- Model: e.Model,
- Status: e.Status,
- LatencyMs: e.LatencyMs,
- PingLatencyMs: e.PingLatencyMs,
- Message: e.Message,
- CheckedAt: e.CheckedAt.UTC().Format(time.RFC3339),
- Quota: e.Quota,
- }
- }
- // ParseChannelMonitorID 提取并校验路径参数 :id(admin 与 user handler 共享)。
- // 校验失败时已写入 4xx 响应,调用方只需 return。
- func ParseChannelMonitorID(c *gin.Context) (int64, bool) {
- id, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil || id <= 0 {
- response.ErrorFrom(c, infraerrors.BadRequest("INVALID_MONITOR_ID", "invalid monitor id"))
- return 0, false
- }
- return id, true
- }
- // parseListEnabled 解析 enabled query 参数:true/false 转为 *bool,空或非法则返回 nil。
- func parseListEnabled(raw string) *bool {
- switch strings.ToLower(strings.TrimSpace(raw)) {
- case "true", "1", "yes":
- v := true
- return &v
- case "false", "0", "no":
- v := false
- return &v
- default:
- return nil
- }
- }
- // --- Handlers ---
- // List GET /api/v1/admin/channel-monitors
- func (h *ChannelMonitorHandler) List(c *gin.Context) {
- page, pageSize := response.ParsePagination(c)
- if pageSize > monitorMaxPageSize {
- pageSize = monitorMaxPageSize
- }
- params := service.ChannelMonitorListParams{
- Page: page,
- PageSize: pageSize,
- Provider: strings.TrimSpace(c.Query("provider")),
- Enabled: parseListEnabled(c.Query("enabled")),
- Search: strings.TrimSpace(c.Query("search")),
- }
- items, total, err := h.monitorService.List(c.Request.Context(), params)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- summaries := h.batchSummaryFor(c, items)
- out := make([]*channelMonitorResponse, 0, len(items))
- for _, m := range items {
- out = append(out, buildListItemResponse(m, summaries[m.ID]))
- }
- response.Paginated(c, out, total, page, pageSize)
- }
- // batchSummaryFor 批量聚合 latest + 7d 可用率,避免每行 2 次 SQL(消除 N+1)。
- func (h *ChannelMonitorHandler) batchSummaryFor(c *gin.Context, items []*service.ChannelMonitor) map[int64]service.MonitorStatusSummary {
- ids := make([]int64, 0, len(items))
- primaryByID := make(map[int64]string, len(items))
- extrasByID := make(map[int64][]string, len(items))
- for _, m := range items {
- ids = append(ids, m.ID)
- primaryByID[m.ID] = m.PrimaryModel
- extrasByID[m.ID] = m.ExtraModels
- }
- return h.monitorService.BatchMonitorStatusSummary(c.Request.Context(), ids, primaryByID, extrasByID)
- }
- // buildListItemResponse 把 monitor + summary 装成 admin list 的响应行。
- func buildListItemResponse(m *service.ChannelMonitor, summary service.MonitorStatusSummary) *channelMonitorResponse {
- resp := channelMonitorToResponse(m)
- resp.PrimaryStatus = summary.PrimaryStatus
- resp.PrimaryLatencyMs = summary.PrimaryLatencyMs
- resp.Availability7d = summary.Availability7d
- resp.LatestQuota = summary.LatestQuota
- resp.ExtraModelsStatus = make([]dto.ChannelMonitorExtraModelStatus, 0, len(summary.ExtraModels))
- for _, e := range summary.ExtraModels {
- resp.ExtraModelsStatus = append(resp.ExtraModelsStatus, dto.ChannelMonitorExtraModelStatus{
- Model: e.Model,
- Status: e.Status,
- LatencyMs: e.LatencyMs,
- })
- }
- return resp
- }
- // Get GET /api/v1/admin/channel-monitors/:id
- func (h *ChannelMonitorHandler) Get(c *gin.Context) {
- id, ok := ParseChannelMonitorID(c)
- if !ok {
- return
- }
- m, err := h.monitorService.Get(c.Request.Context(), id)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, channelMonitorToResponse(m))
- }
- // Create POST /api/v1/admin/channel-monitors
- func (h *ChannelMonitorHandler) Create(c *gin.Context) {
- var req channelMonitorCreateRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error()))
- return
- }
- subject, _ := middleware2.GetAuthSubjectFromContext(c)
- enabled := true
- if req.Enabled != nil {
- enabled = *req.Enabled
- }
- m, err := h.monitorService.Create(c.Request.Context(), service.ChannelMonitorCreateParams{
- Name: req.Name,
- Provider: req.Provider,
- APIMode: req.APIMode,
- Endpoint: req.Endpoint,
- APIKey: req.APIKey,
- PrimaryModel: req.PrimaryModel,
- ExtraModels: req.ExtraModels,
- GroupName: req.GroupName,
- Enabled: enabled,
- IntervalSeconds: req.IntervalSeconds,
- JitterSeconds: req.JitterSeconds,
- CreatedBy: subject.UserID,
- TemplateID: req.TemplateID,
- ExtraHeaders: req.ExtraHeaders,
- BodyOverrideMode: req.BodyOverrideMode,
- BodyOverride: req.BodyOverride,
- CheckMode: req.CheckMode,
- AccountID: req.AccountID,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Created(c, channelMonitorToResponse(m))
- }
- // Duplicate POST /api/v1/admin/channel-monitors/:id/duplicate
- func (h *ChannelMonitorHandler) Duplicate(c *gin.Context) {
- id, ok := ParseChannelMonitorID(c)
- if !ok {
- return
- }
- subject, _ := middleware2.GetAuthSubjectFromContext(c)
- actorScope := adminActorScope(c)
- result, err := executeAdminIdempotent(
- c,
- "admin.channel_monitors.duplicate",
- struct {
- MonitorID int64 `json:"monitor_id"`
- }{MonitorID: id},
- service.DefaultWriteIdempotencyTTL(),
- func(ctx context.Context) (any, error) {
- monitor, err := h.monitorService.Duplicate(
- ctx,
- id,
- subject.UserID,
- actorScope,
- c.GetHeader("Idempotency-Key"),
- )
- if err != nil {
- return nil, err
- }
- return channelMonitorToResponse(monitor), nil
- },
- )
- if err != nil {
- reason := infraerrors.Reason(err)
- if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
- recovered, recoverErr := h.monitorService.RecoverDuplicate(
- c.Request.Context(),
- id,
- actorScope,
- c.GetHeader("Idempotency-Key"),
- )
- if recoverErr != nil {
- slog.Warn("channel_monitor_duplicate_recovery_failed", "monitor_id", id, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
- } else if recovered != nil {
- c.Header("X-Idempotency-Recovered", "true")
- response.Success(c, channelMonitorToResponse(recovered))
- return
- }
- }
- response.ErrorFrom(c, err)
- return
- }
- if result != nil && result.Replayed {
- c.Header("X-Idempotency-Replayed", "true")
- }
- response.Success(c, result.Data)
- }
- // Update PUT /api/v1/admin/channel-monitors/:id
- func (h *ChannelMonitorHandler) Update(c *gin.Context) {
- id, ok := ParseChannelMonitorID(c)
- if !ok {
- return
- }
- var req channelMonitorUpdateRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error()))
- return
- }
- m, err := h.monitorService.Update(c.Request.Context(), id, service.ChannelMonitorUpdateParams{
- Name: req.Name,
- Provider: req.Provider,
- APIMode: req.APIMode,
- Endpoint: req.Endpoint,
- APIKey: req.APIKey,
- PrimaryModel: req.PrimaryModel,
- ExtraModels: req.ExtraModels,
- GroupName: req.GroupName,
- Enabled: req.Enabled,
- IntervalSeconds: req.IntervalSeconds,
- JitterSeconds: req.JitterSeconds,
- TemplateID: req.TemplateID,
- ClearTemplate: req.ClearTemplate,
- ExtraHeaders: req.ExtraHeaders,
- BodyOverrideMode: req.BodyOverrideMode,
- BodyOverride: req.BodyOverride,
- CheckMode: req.CheckMode,
- AccountID: req.AccountID,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, channelMonitorToResponse(m))
- }
- // Delete DELETE /api/v1/admin/channel-monitors/:id
- func (h *ChannelMonitorHandler) Delete(c *gin.Context) {
- id, ok := ParseChannelMonitorID(c)
- if !ok {
- return
- }
- if err := h.monitorService.Delete(c.Request.Context(), id); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, nil)
- }
- // Run POST /api/v1/admin/channel-monitors/:id/run
- func (h *ChannelMonitorHandler) Run(c *gin.Context) {
- id, ok := ParseChannelMonitorID(c)
- if !ok {
- return
- }
- results, err := h.monitorService.RunCheck(c.Request.Context(), id)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- out := make([]channelMonitorCheckResultResponse, 0, len(results))
- for _, r := range results {
- out = append(out, checkResultToResponse(r))
- }
- response.Success(c, gin.H{"results": out})
- }
- // History GET /api/v1/admin/channel-monitors/:id/history
- func (h *ChannelMonitorHandler) History(c *gin.Context) {
- id, ok := ParseChannelMonitorID(c)
- if !ok {
- return
- }
- limit := parseHistoryLimit(c.Query("limit"))
- model := strings.TrimSpace(c.Query("model"))
- entries, err := h.monitorService.ListHistory(c.Request.Context(), id, model, limit)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- out := make([]channelMonitorHistoryItemResponse, 0, len(entries))
- for _, e := range entries {
- out = append(out, historyEntryToResponse(e))
- }
- response.Success(c, gin.H{"items": out})
- }
- // parseHistoryLimit 解析 history 接口的 limit query。
- // 使用 service 包的统一上下限常量,避免在 handler 重复定义同名魔法值。
- func parseHistoryLimit(raw string) int {
- if strings.TrimSpace(raw) == "" {
- return service.MonitorHistoryDefaultLimit
- }
- v, err := strconv.Atoi(raw)
- if err != nil || v <= 0 {
- return service.MonitorHistoryDefaultLimit
- }
- if v > service.MonitorHistoryMaxLimit {
- return service.MonitorHistoryMaxLimit
- }
- return v
- }
|