feat(channel-monitor): request templates with snapshot apply + headers/body override
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
This commit is contained in:
@@ -44,7 +44,15 @@ func (r *channelMonitorRepository) Create(ctx context.Context, m *service.Channe
|
||||
SetGroupName(m.GroupName).
|
||||
SetEnabled(m.Enabled).
|
||||
SetIntervalSeconds(m.IntervalSeconds).
|
||||
SetCreatedBy(m.CreatedBy)
|
||||
SetCreatedBy(m.CreatedBy).
|
||||
SetExtraHeaders(emptyHeadersIfNilRepo(m.ExtraHeaders)).
|
||||
SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode))
|
||||
if m.TemplateID != nil {
|
||||
builder = builder.SetTemplateID(*m.TemplateID)
|
||||
}
|
||||
if m.BodyOverride != nil {
|
||||
builder = builder.SetBodyOverride(m.BodyOverride)
|
||||
}
|
||||
|
||||
created, err := builder.Save(ctx)
|
||||
if err != nil {
|
||||
@@ -77,7 +85,19 @@ func (r *channelMonitorRepository) Update(ctx context.Context, m *service.Channe
|
||||
SetExtraModels(emptySliceIfNil(m.ExtraModels)).
|
||||
SetGroupName(m.GroupName).
|
||||
SetEnabled(m.Enabled).
|
||||
SetIntervalSeconds(m.IntervalSeconds)
|
||||
SetIntervalSeconds(m.IntervalSeconds).
|
||||
SetExtraHeaders(emptyHeadersIfNilRepo(m.ExtraHeaders)).
|
||||
SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode))
|
||||
if m.TemplateID != nil {
|
||||
updater = updater.SetTemplateID(*m.TemplateID)
|
||||
} else {
|
||||
updater = updater.ClearTemplateID()
|
||||
}
|
||||
if m.BodyOverride != nil {
|
||||
updater = updater.SetBodyOverride(m.BodyOverride)
|
||||
} else {
|
||||
updater = updater.ClearBodyOverride()
|
||||
}
|
||||
|
||||
updated, err := updater.Save(ctx)
|
||||
if err != nil {
|
||||
@@ -716,22 +736,51 @@ func entToServiceMonitor(row *dbent.ChannelMonitor) *service.ChannelMonitor {
|
||||
if extras == nil {
|
||||
extras = []string{}
|
||||
}
|
||||
return &service.ChannelMonitor{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Provider: string(row.Provider),
|
||||
Endpoint: row.Endpoint,
|
||||
APIKey: row.APIKeyEncrypted, // 仍为密文,service 层负责解密
|
||||
PrimaryModel: row.PrimaryModel,
|
||||
ExtraModels: extras,
|
||||
GroupName: row.GroupName,
|
||||
Enabled: row.Enabled,
|
||||
IntervalSeconds: row.IntervalSeconds,
|
||||
LastCheckedAt: row.LastCheckedAt,
|
||||
CreatedBy: row.CreatedBy,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
headers := row.ExtraHeaders
|
||||
if headers == nil {
|
||||
headers = map[string]string{}
|
||||
}
|
||||
out := &service.ChannelMonitor{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Provider: string(row.Provider),
|
||||
Endpoint: row.Endpoint,
|
||||
APIKey: row.APIKeyEncrypted, // 仍为密文,service 层负责解密
|
||||
PrimaryModel: row.PrimaryModel,
|
||||
ExtraModels: extras,
|
||||
GroupName: row.GroupName,
|
||||
Enabled: row.Enabled,
|
||||
IntervalSeconds: row.IntervalSeconds,
|
||||
LastCheckedAt: row.LastCheckedAt,
|
||||
CreatedBy: row.CreatedBy,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ExtraHeaders: headers,
|
||||
BodyOverrideMode: row.BodyOverrideMode,
|
||||
BodyOverride: row.BodyOverride,
|
||||
}
|
||||
if row.TemplateID != nil {
|
||||
id := *row.TemplateID
|
||||
out.TemplateID = &id
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// emptyHeadersIfNilRepo 与 service.emptyHeadersIfNil 功能一致,
|
||||
// repo 独立一份避免 import 循环。
|
||||
func emptyHeadersIfNilRepo(h map[string]string) map[string]string {
|
||||
if h == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// defaultBodyModeRepo 空串归一为 off(同上不循环)。
|
||||
func defaultBodyModeRepo(mode string) string {
|
||||
if mode == "" {
|
||||
return "off"
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func emptySliceIfNil(in []string) []string {
|
||||
|
||||
168
backend/internal/repository/channel_monitor_template_repo.go
Normal file
168
backend/internal/repository/channel_monitor_template_repo.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
|
||||
"github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// channelMonitorRequestTemplateRepository 实现 service.ChannelMonitorRequestTemplateRepository。
|
||||
// 与 channelMonitorRepository 分开一个文件,职责清晰。
|
||||
type channelMonitorRequestTemplateRepository struct {
|
||||
client *dbent.Client
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewChannelMonitorRequestTemplateRepository 创建模板仓储实例。
|
||||
func NewChannelMonitorRequestTemplateRepository(client *dbent.Client, db *sql.DB) service.ChannelMonitorRequestTemplateRepository {
|
||||
return &channelMonitorRequestTemplateRepository{client: client, db: db}
|
||||
}
|
||||
|
||||
// ---------- CRUD ----------
|
||||
|
||||
func (r *channelMonitorRequestTemplateRepository) Create(ctx context.Context, t *service.ChannelMonitorRequestTemplate) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
builder := client.ChannelMonitorRequestTemplate.Create().
|
||||
SetName(t.Name).
|
||||
SetProvider(channelmonitorrequesttemplate.Provider(t.Provider)).
|
||||
SetDescription(t.Description).
|
||||
SetExtraHeaders(emptyHeadersIfNilRepo(t.ExtraHeaders)).
|
||||
SetBodyOverrideMode(defaultBodyModeRepo(t.BodyOverrideMode))
|
||||
if t.BodyOverride != nil {
|
||||
builder = builder.SetBodyOverride(t.BodyOverride)
|
||||
}
|
||||
|
||||
created, err := builder.Save(ctx)
|
||||
if err != nil {
|
||||
return translatePersistenceError(err, service.ErrChannelMonitorTemplateNotFound, nil)
|
||||
}
|
||||
t.ID = created.ID
|
||||
t.CreatedAt = created.CreatedAt
|
||||
t.UpdatedAt = created.UpdatedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *channelMonitorRequestTemplateRepository) GetByID(ctx context.Context, id int64) (*service.ChannelMonitorRequestTemplate, error) {
|
||||
row, err := r.client.ChannelMonitorRequestTemplate.Query().
|
||||
Where(channelmonitorrequesttemplate.IDEQ(id)).
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
return nil, translatePersistenceError(err, service.ErrChannelMonitorTemplateNotFound, nil)
|
||||
}
|
||||
return entToServiceTemplate(row), nil
|
||||
}
|
||||
|
||||
func (r *channelMonitorRequestTemplateRepository) Update(ctx context.Context, t *service.ChannelMonitorRequestTemplate) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
updater := client.ChannelMonitorRequestTemplate.UpdateOneID(t.ID).
|
||||
SetName(t.Name).
|
||||
SetDescription(t.Description).
|
||||
SetExtraHeaders(emptyHeadersIfNilRepo(t.ExtraHeaders)).
|
||||
SetBodyOverrideMode(defaultBodyModeRepo(t.BodyOverrideMode))
|
||||
if t.BodyOverride != nil {
|
||||
updater = updater.SetBodyOverride(t.BodyOverride)
|
||||
} else {
|
||||
updater = updater.ClearBodyOverride()
|
||||
}
|
||||
updated, err := updater.Save(ctx)
|
||||
if err != nil {
|
||||
return translatePersistenceError(err, service.ErrChannelMonitorTemplateNotFound, nil)
|
||||
}
|
||||
t.UpdatedAt = updated.UpdatedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *channelMonitorRequestTemplateRepository) Delete(ctx context.Context, id int64) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
if err := client.ChannelMonitorRequestTemplate.DeleteOneID(id).Exec(ctx); err != nil {
|
||||
return translatePersistenceError(err, service.ErrChannelMonitorTemplateNotFound, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *channelMonitorRequestTemplateRepository) List(ctx context.Context, params service.ChannelMonitorRequestTemplateListParams) ([]*service.ChannelMonitorRequestTemplate, error) {
|
||||
q := r.client.ChannelMonitorRequestTemplate.Query()
|
||||
if params.Provider != "" {
|
||||
q = q.Where(channelmonitorrequesttemplate.ProviderEQ(channelmonitorrequesttemplate.Provider(params.Provider)))
|
||||
}
|
||||
rows, err := q.
|
||||
Order(dbent.Asc(channelmonitorrequesttemplate.FieldProvider), dbent.Asc(channelmonitorrequesttemplate.FieldName)).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list monitor templates: %w", err)
|
||||
}
|
||||
out := make([]*service.ChannelMonitorRequestTemplate, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, entToServiceTemplate(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ApplyToMonitors 把模板当前配置批量覆盖到 template_id = id 的监控上。
|
||||
//
|
||||
// 用一条 UPDATE 完成:extra_headers / body_override_mode / body_override 都覆盖。
|
||||
// 走 ent 的 UpdateMany 保证走 ent hooks;走原生 SQL 也可以但 ent jsonb 序列化更省心。
|
||||
func (r *channelMonitorRequestTemplateRepository) ApplyToMonitors(ctx context.Context, id int64) (int64, error) {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
tpl, err := client.ChannelMonitorRequestTemplate.Query().
|
||||
Where(channelmonitorrequesttemplate.IDEQ(id)).
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
return 0, translatePersistenceError(err, service.ErrChannelMonitorTemplateNotFound, nil)
|
||||
}
|
||||
|
||||
updater := client.ChannelMonitor.Update().
|
||||
Where(channelmonitor.TemplateIDEQ(id)).
|
||||
SetExtraHeaders(emptyHeadersIfNilRepo(tpl.ExtraHeaders)).
|
||||
SetBodyOverrideMode(defaultBodyModeRepo(tpl.BodyOverrideMode))
|
||||
if tpl.BodyOverride != nil {
|
||||
updater = updater.SetBodyOverride(tpl.BodyOverride)
|
||||
} else {
|
||||
updater = updater.ClearBodyOverride()
|
||||
}
|
||||
|
||||
affected, err := updater.Save(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("apply template to monitors: %w", err)
|
||||
}
|
||||
return int64(affected), nil
|
||||
}
|
||||
|
||||
// CountAssociatedMonitors 统计关联监控数(UI 展示「N 个配置」用)。
|
||||
func (r *channelMonitorRequestTemplateRepository) CountAssociatedMonitors(ctx context.Context, id int64) (int64, error) {
|
||||
count, err := r.client.ChannelMonitor.Query().
|
||||
Where(channelmonitor.TemplateIDEQ(id)).
|
||||
Count(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count monitors for template %d: %w", id, err)
|
||||
}
|
||||
return int64(count), nil
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func entToServiceTemplate(row *dbent.ChannelMonitorRequestTemplate) *service.ChannelMonitorRequestTemplate {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
headers := row.ExtraHeaders
|
||||
if headers == nil {
|
||||
headers = map[string]string{}
|
||||
}
|
||||
return &service.ChannelMonitorRequestTemplate{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Provider: string(row.Provider),
|
||||
Description: row.Description,
|
||||
ExtraHeaders: headers,
|
||||
BodyOverrideMode: row.BodyOverrideMode,
|
||||
BodyOverride: row.BodyOverride,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewTLSFingerprintProfileRepository,
|
||||
NewChannelRepository,
|
||||
NewChannelMonitorRepository,
|
||||
NewChannelMonitorRequestTemplateRepository,
|
||||
|
||||
// Cache implementations
|
||||
NewGatewayCache,
|
||||
|
||||
Reference in New Issue
Block a user