Problem:
Upstream channels can reject monitor probes based on client fingerprint
(e.g. "only Claude Code clients allowed"). The monitor had no way to
customize the outgoing request to bypass such restrictions.
Solution:
Introduce reusable request templates that carry extra_headers plus an
optional body override; monitors reference a template and receive a
snapshot copy on apply. Template edits do NOT auto-propagate — users
must click "apply to associated monitors" to refresh snapshots, so a
bad template edit cannot instantly break all production monitors.
Data model (migration 112):
- channel_monitor_request_templates: id, name, provider, description,
extra_headers jsonb, body_override_mode ('off'|'merge'|'replace'),
body_override jsonb. Unique (provider, name).
- channel_monitors: +template_id (FK, ON DELETE SET NULL), +extra_headers,
+body_override_mode, +body_override (the three runtime snapshot fields).
Checker (channel_monitor_checker.go):
- callProvider + runCheckForModel accept a CheckOptions carrying the
snapshot fields. mergeHeaders applies user headers on top of adapter
defaults (forbidden list: Host / Content-Length / Transfer-Encoding /
Connection / Content-Encoding).
- buildRequestBody:
off -> adapter default body
merge -> shallow-merge over default; per-provider deny list
(model/messages/contents) protects the challenge contract
replace -> user body verbatim
- Replace mode skips challenge validation; instead HTTP 2xx + non-empty
extracted response text = operational, empty = failed.
- 4 new unit tests cover all three modes + replace/empty-response case.
Admin API:
- /admin/channel-monitor-templates CRUD + /:id/apply (overwrite snapshot
on all template_id=id monitors, returns affected count).
- channel_monitor request/response DTOs gain the 4 new fields.
Frontend:
- channelMonitorTemplate.ts API client.
- MonitorAdvancedRequestConfig.vue shared component for headers textarea
+ body mode radio + body JSON editor; used by both template and monitor
forms.
- MonitorTemplateManagerDialog.vue: provider tabs, list/create/edit/
delete/apply, live "associated monitors" count per row.
- MonitorFiltersBar: new 模板管理 button next to 新增监控.
- MonitorFormDialog: collapsible 高级 section with template dropdown
(filtered by form.provider, clears on provider change) + embedded
AdvancedRequestConfig. Picking a template copies its fields into the
form (snapshot semantics mirrored on the client).
- i18n zh/en entries for all new copy.
chore: bump version to 0.1.114.32
428 lines
15 KiB
Go
428 lines
15 KiB
Go
package admin
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"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"`
|
||
Endpoint string `json:"endpoint" binding:"required,max=500"`
|
||
APIKey string `json:"api_key" binding:"required,max=2000"`
|
||
PrimaryModel string `json:"primary_model" binding:"required,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"`
|
||
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"`
|
||
}
|
||
|
||
type channelMonitorUpdateRequest struct {
|
||
Name *string `json:"name" binding:"omitempty,max=100"`
|
||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini"`
|
||
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"`
|
||
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"`
|
||
}
|
||
|
||
type channelMonitorResponse struct {
|
||
ID int64 `json:"id"`
|
||
Name string `json:"name"`
|
||
Provider string `json:"provider"`
|
||
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"`
|
||
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"`
|
||
}
|
||
|
||
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"`
|
||
}
|
||
|
||
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"`
|
||
}
|
||
|
||
// 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,
|
||
Endpoint: m.Endpoint,
|
||
APIKeyMasked: maskAPIKey(m.APIKey),
|
||
APIKeyDecryptFailed: m.APIKeyDecryptFailed,
|
||
PrimaryModel: m.PrimaryModel,
|
||
ExtraModels: extras,
|
||
GroupName: m.GroupName,
|
||
Enabled: m.Enabled,
|
||
IntervalSeconds: m.IntervalSeconds,
|
||
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,
|
||
// PrimaryStatus / PrimaryLatencyMs / Availability7d 由 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),
|
||
}
|
||
}
|
||
|
||
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),
|
||
}
|
||
}
|
||
|
||
// 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.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,
|
||
Endpoint: req.Endpoint,
|
||
APIKey: req.APIKey,
|
||
PrimaryModel: req.PrimaryModel,
|
||
ExtraModels: req.ExtraModels,
|
||
GroupName: req.GroupName,
|
||
Enabled: enabled,
|
||
IntervalSeconds: req.IntervalSeconds,
|
||
CreatedBy: subject.UserID,
|
||
TemplateID: req.TemplateID,
|
||
ExtraHeaders: req.ExtraHeaders,
|
||
BodyOverrideMode: req.BodyOverrideMode,
|
||
BodyOverride: req.BodyOverride,
|
||
})
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
response.Created(c, channelMonitorToResponse(m))
|
||
}
|
||
|
||
// 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,
|
||
Endpoint: req.Endpoint,
|
||
APIKey: req.APIKey,
|
||
PrimaryModel: req.PrimaryModel,
|
||
ExtraModels: req.ExtraModels,
|
||
GroupName: req.GroupName,
|
||
Enabled: req.Enabled,
|
||
IntervalSeconds: req.IntervalSeconds,
|
||
TemplateID: req.TemplateID,
|
||
ClearTemplate: req.ClearTemplate,
|
||
ExtraHeaders: req.ExtraHeaders,
|
||
BodyOverrideMode: req.BodyOverrideMode,
|
||
BodyOverride: req.BodyOverride,
|
||
})
|
||
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
|
||
}
|