channel_monitor_handler.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. package admin
  2. import (
  3. "context"
  4. "log/slog"
  5. "strconv"
  6. "strings"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/domain"
  9. "github.com/Wei-Shaw/sub2api/internal/handler/dto"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  12. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  13. "github.com/Wei-Shaw/sub2api/internal/service"
  14. "github.com/gin-gonic/gin"
  15. )
  16. const (
  17. // monitorMaxPageSize 列表分页上限。
  18. monitorMaxPageSize = 100
  19. // monitorAPIKeyMaskPrefix 脱敏时保留的明文前缀长度。
  20. monitorAPIKeyMaskPrefix = 4
  21. // monitorAPIKeyMaskSuffix 脱敏后追加的占位字符串。
  22. monitorAPIKeyMaskSuffix = "***"
  23. )
  24. // ChannelMonitorHandler 渠道监控管理后台 handler。
  25. type ChannelMonitorHandler struct {
  26. monitorService *service.ChannelMonitorService
  27. }
  28. // NewChannelMonitorHandler 创建 handler。
  29. func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *ChannelMonitorHandler {
  30. return &ChannelMonitorHandler{monitorService: monitorService}
  31. }
  32. // --- Request / Response ---
  33. type channelMonitorCreateRequest struct {
  34. Name string `json:"name" binding:"required,max=100"`
  35. Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
  36. APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
  37. Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
  38. APIKey string `json:"api_key" binding:"omitempty,max=2000"`
  39. PrimaryModel string `json:"primary_model" binding:"max=200"`
  40. ExtraModels []string `json:"extra_models"`
  41. GroupName string `json:"group_name" binding:"max=100"`
  42. Enabled *bool `json:"enabled"`
  43. IntervalSeconds int `json:"interval_seconds" binding:"required,min=15,max=3600"`
  44. JitterSeconds int `json:"jitter_seconds" binding:"omitempty,min=0,max=3585"`
  45. TemplateID *int64 `json:"template_id"`
  46. ExtraHeaders map[string]string `json:"extra_headers"`
  47. BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
  48. BodyOverride map[string]any `json:"body_override"`
  49. // CheckMode: probe(默认)/ quota / quota_probe。quota 模式 endpoint/api_key
  50. // 可空(条件必填校验在 service 层按模式分支)。
  51. CheckMode string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
  52. // AccountID: 配额模式关联的账号 ID。
  53. AccountID *int64 `json:"account_id"`
  54. }
  55. type channelMonitorUpdateRequest struct {
  56. Name *string `json:"name" binding:"omitempty,max=100"`
  57. Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
  58. APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
  59. Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
  60. APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
  61. PrimaryModel *string `json:"primary_model" binding:"omitempty,max=200"`
  62. ExtraModels *[]string `json:"extra_models"`
  63. GroupName *string `json:"group_name" binding:"omitempty,max=100"`
  64. Enabled *bool `json:"enabled"`
  65. IntervalSeconds *int `json:"interval_seconds" binding:"omitempty,min=15,max=3600"`
  66. JitterSeconds *int `json:"jitter_seconds" binding:"omitempty,min=0,max=3585"`
  67. TemplateID *int64 `json:"template_id"`
  68. ClearTemplate bool `json:"clear_template"` // true 时把 template_id 置空,忽略 TemplateID
  69. ExtraHeaders *map[string]string `json:"extra_headers"`
  70. BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
  71. BodyOverride *map[string]any `json:"body_override"`
  72. // CheckMode/AccountID:nil = 不更新;AccountID 指向 0 = 清空关联。
  73. CheckMode *string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
  74. AccountID *int64 `json:"account_id"`
  75. }
  76. type channelMonitorResponse struct {
  77. ID int64 `json:"id"`
  78. Name string `json:"name"`
  79. Provider string `json:"provider"`
  80. APIMode string `json:"api_mode"`
  81. Endpoint string `json:"endpoint"`
  82. APIKeyMasked string `json:"api_key_masked"`
  83. APIKeyDecryptFailed bool `json:"api_key_decrypt_failed"`
  84. PrimaryModel string `json:"primary_model"`
  85. ExtraModels []string `json:"extra_models"`
  86. GroupName string `json:"group_name"`
  87. Enabled bool `json:"enabled"`
  88. IntervalSeconds int `json:"interval_seconds"`
  89. JitterSeconds int `json:"jitter_seconds"`
  90. LastCheckedAt *string `json:"last_checked_at"`
  91. CreatedBy int64 `json:"created_by"`
  92. CreatedAt string `json:"created_at"`
  93. UpdatedAt string `json:"updated_at"`
  94. PrimaryStatus string `json:"primary_status"`
  95. PrimaryLatencyMs *int `json:"primary_latency_ms"`
  96. Availability7d float64 `json:"availability_7d"`
  97. ExtraModelsStatus []dto.ChannelMonitorExtraModelStatus `json:"extra_models_status"`
  98. // 请求自定义快照:前端编辑 / 展示「高级设置」用
  99. TemplateID *int64 `json:"template_id"`
  100. ExtraHeaders map[string]string `json:"extra_headers"`
  101. BodyOverrideMode string `json:"body_override_mode"`
  102. BodyOverride map[string]any `json:"body_override"`
  103. // 配额模式:check_mode + 关联账号 + 主模型最近配额快照
  104. // (LatestQuota 由 List handler 批量聚合后填充;管理端不受 channel_monitor_show_quota 影响)。
  105. CheckMode string `json:"check_mode"`
  106. AccountID *int64 `json:"account_id"`
  107. LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"`
  108. }
  109. type channelMonitorCheckResultResponse struct {
  110. Model string `json:"model"`
  111. Status string `json:"status"`
  112. LatencyMs *int `json:"latency_ms"`
  113. PingLatencyMs *int `json:"ping_latency_ms"`
  114. Message string `json:"message"`
  115. CheckedAt string `json:"checked_at"`
  116. Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
  117. }
  118. type channelMonitorHistoryItemResponse struct {
  119. ID int64 `json:"id"`
  120. Model string `json:"model"`
  121. Status string `json:"status"`
  122. LatencyMs *int `json:"latency_ms"`
  123. PingLatencyMs *int `json:"ping_latency_ms"`
  124. Message string `json:"message"`
  125. CheckedAt string `json:"checked_at"`
  126. Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
  127. }
  128. // maskAPIKey 对 API Key 明文做脱敏:前 4 字符 + "***",长度 ≤ 4 时只显示 "***"。
  129. func maskAPIKey(plain string) string {
  130. if len(plain) <= monitorAPIKeyMaskPrefix {
  131. return monitorAPIKeyMaskSuffix
  132. }
  133. return plain[:monitorAPIKeyMaskPrefix] + monitorAPIKeyMaskSuffix
  134. }
  135. func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse {
  136. if m == nil {
  137. return nil
  138. }
  139. extras := m.ExtraModels
  140. if extras == nil {
  141. extras = []string{}
  142. }
  143. headers := m.ExtraHeaders
  144. if headers == nil {
  145. headers = map[string]string{}
  146. }
  147. resp := &channelMonitorResponse{
  148. ID: m.ID,
  149. Name: m.Name,
  150. Provider: m.Provider,
  151. APIMode: m.APIMode,
  152. Endpoint: m.Endpoint,
  153. APIKeyMasked: maskAPIKey(m.APIKey),
  154. APIKeyDecryptFailed: m.APIKeyDecryptFailed,
  155. PrimaryModel: m.PrimaryModel,
  156. ExtraModels: extras,
  157. GroupName: m.GroupName,
  158. Enabled: m.Enabled,
  159. IntervalSeconds: m.IntervalSeconds,
  160. JitterSeconds: m.JitterSeconds,
  161. CreatedBy: m.CreatedBy,
  162. CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
  163. UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339),
  164. TemplateID: m.TemplateID,
  165. ExtraHeaders: headers,
  166. BodyOverrideMode: m.BodyOverrideMode,
  167. BodyOverride: m.BodyOverride,
  168. CheckMode: m.CheckMode,
  169. AccountID: m.AccountID,
  170. // PrimaryStatus / PrimaryLatencyMs / Availability7d / LatestQuota
  171. // 由 List handler 在批量聚合后填充。
  172. }
  173. if m.LastCheckedAt != nil {
  174. s := m.LastCheckedAt.UTC().Format(time.RFC3339)
  175. resp.LastCheckedAt = &s
  176. }
  177. return resp
  178. }
  179. func checkResultToResponse(r *service.CheckResult) channelMonitorCheckResultResponse {
  180. return channelMonitorCheckResultResponse{
  181. Model: r.Model,
  182. Status: r.Status,
  183. LatencyMs: r.LatencyMs,
  184. PingLatencyMs: r.PingLatencyMs,
  185. Message: r.Message,
  186. CheckedAt: r.CheckedAt.UTC().Format(time.RFC3339),
  187. Quota: r.Quota,
  188. }
  189. }
  190. func historyEntryToResponse(e *service.ChannelMonitorHistoryEntry) channelMonitorHistoryItemResponse {
  191. return channelMonitorHistoryItemResponse{
  192. ID: e.ID,
  193. Model: e.Model,
  194. Status: e.Status,
  195. LatencyMs: e.LatencyMs,
  196. PingLatencyMs: e.PingLatencyMs,
  197. Message: e.Message,
  198. CheckedAt: e.CheckedAt.UTC().Format(time.RFC3339),
  199. Quota: e.Quota,
  200. }
  201. }
  202. // ParseChannelMonitorID 提取并校验路径参数 :id(admin 与 user handler 共享)。
  203. // 校验失败时已写入 4xx 响应,调用方只需 return。
  204. func ParseChannelMonitorID(c *gin.Context) (int64, bool) {
  205. id, err := strconv.ParseInt(c.Param("id"), 10, 64)
  206. if err != nil || id <= 0 {
  207. response.ErrorFrom(c, infraerrors.BadRequest("INVALID_MONITOR_ID", "invalid monitor id"))
  208. return 0, false
  209. }
  210. return id, true
  211. }
  212. // parseListEnabled 解析 enabled query 参数:true/false 转为 *bool,空或非法则返回 nil。
  213. func parseListEnabled(raw string) *bool {
  214. switch strings.ToLower(strings.TrimSpace(raw)) {
  215. case "true", "1", "yes":
  216. v := true
  217. return &v
  218. case "false", "0", "no":
  219. v := false
  220. return &v
  221. default:
  222. return nil
  223. }
  224. }
  225. // --- Handlers ---
  226. // List GET /api/v1/admin/channel-monitors
  227. func (h *ChannelMonitorHandler) List(c *gin.Context) {
  228. page, pageSize := response.ParsePagination(c)
  229. if pageSize > monitorMaxPageSize {
  230. pageSize = monitorMaxPageSize
  231. }
  232. params := service.ChannelMonitorListParams{
  233. Page: page,
  234. PageSize: pageSize,
  235. Provider: strings.TrimSpace(c.Query("provider")),
  236. Enabled: parseListEnabled(c.Query("enabled")),
  237. Search: strings.TrimSpace(c.Query("search")),
  238. }
  239. items, total, err := h.monitorService.List(c.Request.Context(), params)
  240. if err != nil {
  241. response.ErrorFrom(c, err)
  242. return
  243. }
  244. summaries := h.batchSummaryFor(c, items)
  245. out := make([]*channelMonitorResponse, 0, len(items))
  246. for _, m := range items {
  247. out = append(out, buildListItemResponse(m, summaries[m.ID]))
  248. }
  249. response.Paginated(c, out, total, page, pageSize)
  250. }
  251. // batchSummaryFor 批量聚合 latest + 7d 可用率,避免每行 2 次 SQL(消除 N+1)。
  252. func (h *ChannelMonitorHandler) batchSummaryFor(c *gin.Context, items []*service.ChannelMonitor) map[int64]service.MonitorStatusSummary {
  253. ids := make([]int64, 0, len(items))
  254. primaryByID := make(map[int64]string, len(items))
  255. extrasByID := make(map[int64][]string, len(items))
  256. for _, m := range items {
  257. ids = append(ids, m.ID)
  258. primaryByID[m.ID] = m.PrimaryModel
  259. extrasByID[m.ID] = m.ExtraModels
  260. }
  261. return h.monitorService.BatchMonitorStatusSummary(c.Request.Context(), ids, primaryByID, extrasByID)
  262. }
  263. // buildListItemResponse 把 monitor + summary 装成 admin list 的响应行。
  264. func buildListItemResponse(m *service.ChannelMonitor, summary service.MonitorStatusSummary) *channelMonitorResponse {
  265. resp := channelMonitorToResponse(m)
  266. resp.PrimaryStatus = summary.PrimaryStatus
  267. resp.PrimaryLatencyMs = summary.PrimaryLatencyMs
  268. resp.Availability7d = summary.Availability7d
  269. resp.LatestQuota = summary.LatestQuota
  270. resp.ExtraModelsStatus = make([]dto.ChannelMonitorExtraModelStatus, 0, len(summary.ExtraModels))
  271. for _, e := range summary.ExtraModels {
  272. resp.ExtraModelsStatus = append(resp.ExtraModelsStatus, dto.ChannelMonitorExtraModelStatus{
  273. Model: e.Model,
  274. Status: e.Status,
  275. LatencyMs: e.LatencyMs,
  276. })
  277. }
  278. return resp
  279. }
  280. // Get GET /api/v1/admin/channel-monitors/:id
  281. func (h *ChannelMonitorHandler) Get(c *gin.Context) {
  282. id, ok := ParseChannelMonitorID(c)
  283. if !ok {
  284. return
  285. }
  286. m, err := h.monitorService.Get(c.Request.Context(), id)
  287. if err != nil {
  288. response.ErrorFrom(c, err)
  289. return
  290. }
  291. response.Success(c, channelMonitorToResponse(m))
  292. }
  293. // Create POST /api/v1/admin/channel-monitors
  294. func (h *ChannelMonitorHandler) Create(c *gin.Context) {
  295. var req channelMonitorCreateRequest
  296. if err := c.ShouldBindJSON(&req); err != nil {
  297. response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error()))
  298. return
  299. }
  300. subject, _ := middleware2.GetAuthSubjectFromContext(c)
  301. enabled := true
  302. if req.Enabled != nil {
  303. enabled = *req.Enabled
  304. }
  305. m, err := h.monitorService.Create(c.Request.Context(), service.ChannelMonitorCreateParams{
  306. Name: req.Name,
  307. Provider: req.Provider,
  308. APIMode: req.APIMode,
  309. Endpoint: req.Endpoint,
  310. APIKey: req.APIKey,
  311. PrimaryModel: req.PrimaryModel,
  312. ExtraModels: req.ExtraModels,
  313. GroupName: req.GroupName,
  314. Enabled: enabled,
  315. IntervalSeconds: req.IntervalSeconds,
  316. JitterSeconds: req.JitterSeconds,
  317. CreatedBy: subject.UserID,
  318. TemplateID: req.TemplateID,
  319. ExtraHeaders: req.ExtraHeaders,
  320. BodyOverrideMode: req.BodyOverrideMode,
  321. BodyOverride: req.BodyOverride,
  322. CheckMode: req.CheckMode,
  323. AccountID: req.AccountID,
  324. })
  325. if err != nil {
  326. response.ErrorFrom(c, err)
  327. return
  328. }
  329. response.Created(c, channelMonitorToResponse(m))
  330. }
  331. // Duplicate POST /api/v1/admin/channel-monitors/:id/duplicate
  332. func (h *ChannelMonitorHandler) Duplicate(c *gin.Context) {
  333. id, ok := ParseChannelMonitorID(c)
  334. if !ok {
  335. return
  336. }
  337. subject, _ := middleware2.GetAuthSubjectFromContext(c)
  338. actorScope := adminActorScope(c)
  339. result, err := executeAdminIdempotent(
  340. c,
  341. "admin.channel_monitors.duplicate",
  342. struct {
  343. MonitorID int64 `json:"monitor_id"`
  344. }{MonitorID: id},
  345. service.DefaultWriteIdempotencyTTL(),
  346. func(ctx context.Context) (any, error) {
  347. monitor, err := h.monitorService.Duplicate(
  348. ctx,
  349. id,
  350. subject.UserID,
  351. actorScope,
  352. c.GetHeader("Idempotency-Key"),
  353. )
  354. if err != nil {
  355. return nil, err
  356. }
  357. return channelMonitorToResponse(monitor), nil
  358. },
  359. )
  360. if err != nil {
  361. reason := infraerrors.Reason(err)
  362. if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
  363. recovered, recoverErr := h.monitorService.RecoverDuplicate(
  364. c.Request.Context(),
  365. id,
  366. actorScope,
  367. c.GetHeader("Idempotency-Key"),
  368. )
  369. if recoverErr != nil {
  370. slog.Warn("channel_monitor_duplicate_recovery_failed", "monitor_id", id, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
  371. } else if recovered != nil {
  372. c.Header("X-Idempotency-Recovered", "true")
  373. response.Success(c, channelMonitorToResponse(recovered))
  374. return
  375. }
  376. }
  377. response.ErrorFrom(c, err)
  378. return
  379. }
  380. if result != nil && result.Replayed {
  381. c.Header("X-Idempotency-Replayed", "true")
  382. }
  383. response.Success(c, result.Data)
  384. }
  385. // Update PUT /api/v1/admin/channel-monitors/:id
  386. func (h *ChannelMonitorHandler) Update(c *gin.Context) {
  387. id, ok := ParseChannelMonitorID(c)
  388. if !ok {
  389. return
  390. }
  391. var req channelMonitorUpdateRequest
  392. if err := c.ShouldBindJSON(&req); err != nil {
  393. response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error()))
  394. return
  395. }
  396. m, err := h.monitorService.Update(c.Request.Context(), id, service.ChannelMonitorUpdateParams{
  397. Name: req.Name,
  398. Provider: req.Provider,
  399. APIMode: req.APIMode,
  400. Endpoint: req.Endpoint,
  401. APIKey: req.APIKey,
  402. PrimaryModel: req.PrimaryModel,
  403. ExtraModels: req.ExtraModels,
  404. GroupName: req.GroupName,
  405. Enabled: req.Enabled,
  406. IntervalSeconds: req.IntervalSeconds,
  407. JitterSeconds: req.JitterSeconds,
  408. TemplateID: req.TemplateID,
  409. ClearTemplate: req.ClearTemplate,
  410. ExtraHeaders: req.ExtraHeaders,
  411. BodyOverrideMode: req.BodyOverrideMode,
  412. BodyOverride: req.BodyOverride,
  413. CheckMode: req.CheckMode,
  414. AccountID: req.AccountID,
  415. })
  416. if err != nil {
  417. response.ErrorFrom(c, err)
  418. return
  419. }
  420. response.Success(c, channelMonitorToResponse(m))
  421. }
  422. // Delete DELETE /api/v1/admin/channel-monitors/:id
  423. func (h *ChannelMonitorHandler) Delete(c *gin.Context) {
  424. id, ok := ParseChannelMonitorID(c)
  425. if !ok {
  426. return
  427. }
  428. if err := h.monitorService.Delete(c.Request.Context(), id); err != nil {
  429. response.ErrorFrom(c, err)
  430. return
  431. }
  432. response.Success(c, nil)
  433. }
  434. // Run POST /api/v1/admin/channel-monitors/:id/run
  435. func (h *ChannelMonitorHandler) Run(c *gin.Context) {
  436. id, ok := ParseChannelMonitorID(c)
  437. if !ok {
  438. return
  439. }
  440. results, err := h.monitorService.RunCheck(c.Request.Context(), id)
  441. if err != nil {
  442. response.ErrorFrom(c, err)
  443. return
  444. }
  445. out := make([]channelMonitorCheckResultResponse, 0, len(results))
  446. for _, r := range results {
  447. out = append(out, checkResultToResponse(r))
  448. }
  449. response.Success(c, gin.H{"results": out})
  450. }
  451. // History GET /api/v1/admin/channel-monitors/:id/history
  452. func (h *ChannelMonitorHandler) History(c *gin.Context) {
  453. id, ok := ParseChannelMonitorID(c)
  454. if !ok {
  455. return
  456. }
  457. limit := parseHistoryLimit(c.Query("limit"))
  458. model := strings.TrimSpace(c.Query("model"))
  459. entries, err := h.monitorService.ListHistory(c.Request.Context(), id, model, limit)
  460. if err != nil {
  461. response.ErrorFrom(c, err)
  462. return
  463. }
  464. out := make([]channelMonitorHistoryItemResponse, 0, len(entries))
  465. for _, e := range entries {
  466. out = append(out, historyEntryToResponse(e))
  467. }
  468. response.Success(c, gin.H{"items": out})
  469. }
  470. // parseHistoryLimit 解析 history 接口的 limit query。
  471. // 使用 service 包的统一上下限常量,避免在 handler 重复定义同名魔法值。
  472. func parseHistoryLimit(raw string) int {
  473. if strings.TrimSpace(raw) == "" {
  474. return service.MonitorHistoryDefaultLimit
  475. }
  476. v, err := strconv.Atoi(raw)
  477. if err != nil || v <= 0 {
  478. return service.MonitorHistoryDefaultLimit
  479. }
  480. if v > service.MonitorHistoryMaxLimit {
  481. return service.MonitorHistoryMaxLimit
  482. }
  483. return v
  484. }