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
226 lines
7.8 KiB
Go
226 lines
7.8 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
// ChannelMonitorRequestTemplateRepository 模板数据访问接口。
|
||
type ChannelMonitorRequestTemplateRepository interface {
|
||
Create(ctx context.Context, t *ChannelMonitorRequestTemplate) error
|
||
GetByID(ctx context.Context, id int64) (*ChannelMonitorRequestTemplate, error)
|
||
Update(ctx context.Context, t *ChannelMonitorRequestTemplate) error
|
||
Delete(ctx context.Context, id int64) error
|
||
List(ctx context.Context, params ChannelMonitorRequestTemplateListParams) ([]*ChannelMonitorRequestTemplate, error)
|
||
// ApplyToMonitors 把模板当前的 extra_headers / body_override_mode / body_override
|
||
// 批量覆盖到所有 template_id = id 的监控上。返回被覆盖的监控数量。
|
||
ApplyToMonitors(ctx context.Context, id int64) (int64, error)
|
||
// CountAssociatedMonitors 统计 template_id = id 的监控数(用于 UI 展示「应用到 N 个配置」)。
|
||
CountAssociatedMonitors(ctx context.Context, id int64) (int64, error)
|
||
}
|
||
|
||
// ChannelMonitorRequestTemplateService 模板管理 service。
|
||
type ChannelMonitorRequestTemplateService struct {
|
||
repo ChannelMonitorRequestTemplateRepository
|
||
}
|
||
|
||
// NewChannelMonitorRequestTemplateService 创建模板 service。
|
||
func NewChannelMonitorRequestTemplateService(repo ChannelMonitorRequestTemplateRepository) *ChannelMonitorRequestTemplateService {
|
||
return &ChannelMonitorRequestTemplateService{repo: repo}
|
||
}
|
||
|
||
// ---------- CRUD ----------
|
||
|
||
// List 按 provider 过滤(空串 = 全部),不分页(模板量级小)。
|
||
func (s *ChannelMonitorRequestTemplateService) List(ctx context.Context, params ChannelMonitorRequestTemplateListParams) ([]*ChannelMonitorRequestTemplate, error) {
|
||
if params.Provider != "" {
|
||
if err := validateProvider(params.Provider); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return s.repo.List(ctx, params)
|
||
}
|
||
|
||
// Get 返回单个模板。
|
||
func (s *ChannelMonitorRequestTemplateService) Get(ctx context.Context, id int64) (*ChannelMonitorRequestTemplate, error) {
|
||
return s.repo.GetByID(ctx, id)
|
||
}
|
||
|
||
// Create 创建模板(会校验 headers 黑名单和 body 模式匹配)。
|
||
func (s *ChannelMonitorRequestTemplateService) Create(ctx context.Context, p ChannelMonitorRequestTemplateCreateParams) (*ChannelMonitorRequestTemplate, error) {
|
||
if err := validateTemplateCreateParams(p); err != nil {
|
||
return nil, err
|
||
}
|
||
t := &ChannelMonitorRequestTemplate{
|
||
Name: strings.TrimSpace(p.Name),
|
||
Provider: p.Provider,
|
||
Description: strings.TrimSpace(p.Description),
|
||
ExtraHeaders: emptyHeadersIfNil(p.ExtraHeaders),
|
||
BodyOverrideMode: defaultBodyMode(p.BodyOverrideMode),
|
||
BodyOverride: p.BodyOverride,
|
||
}
|
||
if err := s.repo.Create(ctx, t); err != nil {
|
||
return nil, fmt.Errorf("create template: %w", err)
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
// Update 更新模板(provider 不可改)。
|
||
func (s *ChannelMonitorRequestTemplateService) Update(ctx context.Context, id int64, p ChannelMonitorRequestTemplateUpdateParams) (*ChannelMonitorRequestTemplate, error) {
|
||
existing, err := s.repo.GetByID(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := applyTemplateUpdate(existing, p); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := s.repo.Update(ctx, existing); err != nil {
|
||
return nil, fmt.Errorf("update template: %w", err)
|
||
}
|
||
return existing, nil
|
||
}
|
||
|
||
// Delete 删除模板。关联监控的 template_id 会被 SET NULL,监控保留快照继续跑。
|
||
func (s *ChannelMonitorRequestTemplateService) Delete(ctx context.Context, id int64) error {
|
||
if err := s.repo.Delete(ctx, id); err != nil {
|
||
return fmt.Errorf("delete template: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ApplyToMonitors 把模板当前配置一键应用到所有关联监控。
|
||
// 返回被影响的监控数。
|
||
func (s *ChannelMonitorRequestTemplateService) ApplyToMonitors(ctx context.Context, id int64) (int64, error) {
|
||
if _, err := s.repo.GetByID(ctx, id); err != nil {
|
||
return 0, err
|
||
}
|
||
affected, err := s.repo.ApplyToMonitors(ctx, id)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("apply template to monitors: %w", err)
|
||
}
|
||
return affected, nil
|
||
}
|
||
|
||
// CountAssociatedMonitors 返回关联监控数。
|
||
func (s *ChannelMonitorRequestTemplateService) CountAssociatedMonitors(ctx context.Context, id int64) (int64, error) {
|
||
return s.repo.CountAssociatedMonitors(ctx, id)
|
||
}
|
||
|
||
// ---------- 校验 & 工具 ----------
|
||
|
||
// validateTemplateCreateParams 聚合 create 入参校验,避免函数超过 30 行。
|
||
func validateTemplateCreateParams(p ChannelMonitorRequestTemplateCreateParams) error {
|
||
if strings.TrimSpace(p.Name) == "" {
|
||
return ErrChannelMonitorTemplateMissingName
|
||
}
|
||
if err := validateProvider(p.Provider); err != nil {
|
||
return ErrChannelMonitorTemplateInvalidProvider
|
||
}
|
||
if err := validateBodyModeParams(p.BodyOverrideMode, p.BodyOverride); err != nil {
|
||
return err
|
||
}
|
||
if err := validateExtraHeaders(p.ExtraHeaders); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// applyTemplateUpdate 把 update params 中非 nil 字段应用到 existing 上。
|
||
func applyTemplateUpdate(existing *ChannelMonitorRequestTemplate, p ChannelMonitorRequestTemplateUpdateParams) error {
|
||
if p.Name != nil {
|
||
name := strings.TrimSpace(*p.Name)
|
||
if name == "" {
|
||
return ErrChannelMonitorTemplateMissingName
|
||
}
|
||
existing.Name = name
|
||
}
|
||
if p.Description != nil {
|
||
existing.Description = strings.TrimSpace(*p.Description)
|
||
}
|
||
if p.ExtraHeaders != nil {
|
||
if err := validateExtraHeaders(*p.ExtraHeaders); err != nil {
|
||
return err
|
||
}
|
||
existing.ExtraHeaders = emptyHeadersIfNil(*p.ExtraHeaders)
|
||
}
|
||
// BodyOverrideMode / BodyOverride 联合校验:任一变化都用「更新后的值」做校验。
|
||
newMode := existing.BodyOverrideMode
|
||
newBody := existing.BodyOverride
|
||
if p.BodyOverrideMode != nil {
|
||
newMode = *p.BodyOverrideMode
|
||
}
|
||
if p.BodyOverride != nil {
|
||
newBody = *p.BodyOverride
|
||
}
|
||
if err := validateBodyModeParams(newMode, newBody); err != nil {
|
||
return err
|
||
}
|
||
existing.BodyOverrideMode = defaultBodyMode(newMode)
|
||
existing.BodyOverride = newBody
|
||
return nil
|
||
}
|
||
|
||
// validateBodyModeParams 校验 body_override_mode 合法,且 merge/replace 模式下 body_override 非空。
|
||
func validateBodyModeParams(mode string, body map[string]any) error {
|
||
switch mode {
|
||
case "", MonitorBodyOverrideModeOff:
|
||
return nil
|
||
case MonitorBodyOverrideModeMerge, MonitorBodyOverrideModeReplace:
|
||
if len(body) == 0 {
|
||
return ErrChannelMonitorTemplateBodyRequired
|
||
}
|
||
return nil
|
||
default:
|
||
return ErrChannelMonitorTemplateInvalidBodyMode
|
||
}
|
||
}
|
||
|
||
// headerNameRegex 合法 header 名:RFC 7230 token(ASCII 可见字符减特殊符号)。
|
||
var headerNameRegex = regexp.MustCompile(`^[A-Za-z0-9!#$%&'*+\-.^_` + "`" + `|~]+$`)
|
||
|
||
// forbiddenHeaderNames hop-by-hop + HTTP 客户端自管的 header;禁止用户覆盖,
|
||
// 否则会让 Go http.Client 行为异常(双重 Content-Length、连接复用错乱等)。
|
||
var forbiddenHeaderNames = map[string]bool{
|
||
"host": true,
|
||
"content-length": true,
|
||
"content-encoding": true,
|
||
"transfer-encoding": true,
|
||
"connection": true,
|
||
}
|
||
|
||
// IsForbiddenHeaderName 对外暴露,checker 运行时也会再过滤一次做兜底。
|
||
func IsForbiddenHeaderName(name string) bool {
|
||
return forbiddenHeaderNames[strings.ToLower(strings.TrimSpace(name))]
|
||
}
|
||
|
||
// validateExtraHeaders 校验 header 名字格式 + 黑名单。保存时就拒绝非法 header,早失败。
|
||
func validateExtraHeaders(h map[string]string) error {
|
||
for k := range h {
|
||
if !headerNameRegex.MatchString(k) {
|
||
return ErrChannelMonitorTemplateHeaderInvalidName
|
||
}
|
||
if IsForbiddenHeaderName(k) {
|
||
return ErrChannelMonitorTemplateHeaderForbidden
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// emptyHeadersIfNil 把 nil map 归一成空 map(repo 层写库时 JSONB 需要非 nil)。
|
||
func emptyHeadersIfNil(h map[string]string) map[string]string {
|
||
if h == nil {
|
||
return map[string]string{}
|
||
}
|
||
return h
|
||
}
|
||
|
||
// defaultBodyMode 空串归一为 off。
|
||
func defaultBodyMode(mode string) string {
|
||
if mode == "" {
|
||
return MonitorBodyOverrideModeOff
|
||
}
|
||
return mode
|
||
}
|