Commit a2964259 authored by erio's avatar erio
Browse files

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
parent 0c48f08f
......@@ -22,6 +22,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitordailyrollup"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate"
"github.com/Wei-Shaw/sub2api/ent/errorpassthroughrule"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/idempotencyrecord"
......@@ -58,39 +59,40 @@ const (
OpUpdateOne = ent.OpUpdateOne
 
// Node types.
TypeAPIKey = "APIKey"
TypeAccount = "Account"
TypeAccountGroup = "AccountGroup"
TypeAnnouncement = "Announcement"
TypeAnnouncementRead = "AnnouncementRead"
TypeAuthIdentity = "AuthIdentity"
TypeAuthIdentityChannel = "AuthIdentityChannel"
TypeChannelMonitor = "ChannelMonitor"
TypeChannelMonitorDailyRollup = "ChannelMonitorDailyRollup"
TypeChannelMonitorHistory = "ChannelMonitorHistory"
TypeErrorPassthroughRule = "ErrorPassthroughRule"
TypeGroup = "Group"
TypeIdempotencyRecord = "IdempotencyRecord"
TypeIdentityAdoptionDecision = "IdentityAdoptionDecision"
TypePaymentAuditLog = "PaymentAuditLog"
TypePaymentOrder = "PaymentOrder"
TypePaymentProviderInstance = "PaymentProviderInstance"
TypePendingAuthSession = "PendingAuthSession"
TypePromoCode = "PromoCode"
TypePromoCodeUsage = "PromoCodeUsage"
TypeProxy = "Proxy"
TypeRedeemCode = "RedeemCode"
TypeSecuritySecret = "SecuritySecret"
TypeSetting = "Setting"
TypeSubscriptionPlan = "SubscriptionPlan"
TypeTLSFingerprintProfile = "TLSFingerprintProfile"
TypeUsageCleanupTask = "UsageCleanupTask"
TypeUsageLog = "UsageLog"
TypeUser = "User"
TypeUserAllowedGroup = "UserAllowedGroup"
TypeUserAttributeDefinition = "UserAttributeDefinition"
TypeUserAttributeValue = "UserAttributeValue"
TypeUserSubscription = "UserSubscription"
TypeAPIKey = "APIKey"
TypeAccount = "Account"
TypeAccountGroup = "AccountGroup"
TypeAnnouncement = "Announcement"
TypeAnnouncementRead = "AnnouncementRead"
TypeAuthIdentity = "AuthIdentity"
TypeAuthIdentityChannel = "AuthIdentityChannel"
TypeChannelMonitor = "ChannelMonitor"
TypeChannelMonitorDailyRollup = "ChannelMonitorDailyRollup"
TypeChannelMonitorHistory = "ChannelMonitorHistory"
TypeChannelMonitorRequestTemplate = "ChannelMonitorRequestTemplate"
TypeErrorPassthroughRule = "ErrorPassthroughRule"
TypeGroup = "Group"
TypeIdempotencyRecord = "IdempotencyRecord"
TypeIdentityAdoptionDecision = "IdentityAdoptionDecision"
TypePaymentAuditLog = "PaymentAuditLog"
TypePaymentOrder = "PaymentOrder"
TypePaymentProviderInstance = "PaymentProviderInstance"
TypePendingAuthSession = "PendingAuthSession"
TypePromoCode = "PromoCode"
TypePromoCodeUsage = "PromoCodeUsage"
TypeProxy = "Proxy"
TypeRedeemCode = "RedeemCode"
TypeSecuritySecret = "SecuritySecret"
TypeSetting = "Setting"
TypeSubscriptionPlan = "SubscriptionPlan"
TypeTLSFingerprintProfile = "TLSFingerprintProfile"
TypeUsageCleanupTask = "UsageCleanupTask"
TypeUsageLog = "UsageLog"
TypeUser = "User"
TypeUserAllowedGroup = "UserAllowedGroup"
TypeUserAttributeDefinition = "UserAttributeDefinition"
TypeUserAttributeValue = "UserAttributeValue"
TypeUserSubscription = "UserSubscription"
)
 
// APIKeyMutation represents an operation that mutates the APIKey nodes in the graph.
......@@ -8743,35 +8745,40 @@ func (m *AuthIdentityChannelMutation) ResetEdge(name string) error {
// ChannelMonitorMutation represents an operation that mutates the ChannelMonitor nodes in the graph.
type ChannelMonitorMutation struct {
config
op Op
typ string
id *int64
created_at *time.Time
updated_at *time.Time
name *string
provider *channelmonitor.Provider
endpoint *string
api_key_encrypted *string
primary_model *string
extra_models *[]string
appendextra_models []string
group_name *string
enabled *bool
interval_seconds *int
addinterval_seconds *int
last_checked_at *time.Time
created_by *int64
addcreated_by *int64
clearedFields map[string]struct{}
history map[int64]struct{}
removedhistory map[int64]struct{}
clearedhistory bool
daily_rollups map[int64]struct{}
removeddaily_rollups map[int64]struct{}
cleareddaily_rollups bool
done bool
oldValue func(context.Context) (*ChannelMonitor, error)
predicates []predicate.ChannelMonitor
op Op
typ string
id *int64
created_at *time.Time
updated_at *time.Time
name *string
provider *channelmonitor.Provider
endpoint *string
api_key_encrypted *string
primary_model *string
extra_models *[]string
appendextra_models []string
group_name *string
enabled *bool
interval_seconds *int
addinterval_seconds *int
last_checked_at *time.Time
created_by *int64
addcreated_by *int64
extra_headers *map[string]string
body_override_mode *string
body_override *map[string]interface{}
clearedFields map[string]struct{}
history map[int64]struct{}
removedhistory map[int64]struct{}
clearedhistory bool
daily_rollups map[int64]struct{}
removeddaily_rollups map[int64]struct{}
cleareddaily_rollups bool
request_template *int64
clearedrequest_template bool
done bool
oldValue func(context.Context) (*ChannelMonitor, error)
predicates []predicate.ChannelMonitor
}
 
var _ ent.Mutation = (*ChannelMonitorMutation)(nil)
......@@ -9421,6 +9428,176 @@ func (m *ChannelMonitorMutation) ResetCreatedBy() {
m.addcreated_by = nil
}
 
// SetTemplateID sets the "template_id" field.
func (m *ChannelMonitorMutation) SetTemplateID(i int64) {
m.request_template = &i
}
// TemplateID returns the value of the "template_id" field in the mutation.
func (m *ChannelMonitorMutation) TemplateID() (r int64, exists bool) {
v := m.request_template
if v == nil {
return
}
return *v, true
}
// OldTemplateID returns the old "template_id" field's value of the ChannelMonitor entity.
// If the ChannelMonitor object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorMutation) OldTemplateID(ctx context.Context) (v *int64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldTemplateID is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldTemplateID requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldTemplateID: %w", err)
}
return oldValue.TemplateID, nil
}
// ClearTemplateID clears the value of the "template_id" field.
func (m *ChannelMonitorMutation) ClearTemplateID() {
m.request_template = nil
m.clearedFields[channelmonitor.FieldTemplateID] = struct{}{}
}
// TemplateIDCleared returns if the "template_id" field was cleared in this mutation.
func (m *ChannelMonitorMutation) TemplateIDCleared() bool {
_, ok := m.clearedFields[channelmonitor.FieldTemplateID]
return ok
}
// ResetTemplateID resets all changes to the "template_id" field.
func (m *ChannelMonitorMutation) ResetTemplateID() {
m.request_template = nil
delete(m.clearedFields, channelmonitor.FieldTemplateID)
}
// SetExtraHeaders sets the "extra_headers" field.
func (m *ChannelMonitorMutation) SetExtraHeaders(value map[string]string) {
m.extra_headers = &value
}
// ExtraHeaders returns the value of the "extra_headers" field in the mutation.
func (m *ChannelMonitorMutation) ExtraHeaders() (r map[string]string, exists bool) {
v := m.extra_headers
if v == nil {
return
}
return *v, true
}
// OldExtraHeaders returns the old "extra_headers" field's value of the ChannelMonitor entity.
// If the ChannelMonitor object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorMutation) OldExtraHeaders(ctx context.Context) (v map[string]string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldExtraHeaders is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldExtraHeaders requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldExtraHeaders: %w", err)
}
return oldValue.ExtraHeaders, nil
}
// ResetExtraHeaders resets all changes to the "extra_headers" field.
func (m *ChannelMonitorMutation) ResetExtraHeaders() {
m.extra_headers = nil
}
// SetBodyOverrideMode sets the "body_override_mode" field.
func (m *ChannelMonitorMutation) SetBodyOverrideMode(s string) {
m.body_override_mode = &s
}
// BodyOverrideMode returns the value of the "body_override_mode" field in the mutation.
func (m *ChannelMonitorMutation) BodyOverrideMode() (r string, exists bool) {
v := m.body_override_mode
if v == nil {
return
}
return *v, true
}
// OldBodyOverrideMode returns the old "body_override_mode" field's value of the ChannelMonitor entity.
// If the ChannelMonitor object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorMutation) OldBodyOverrideMode(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldBodyOverrideMode is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldBodyOverrideMode requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldBodyOverrideMode: %w", err)
}
return oldValue.BodyOverrideMode, nil
}
// ResetBodyOverrideMode resets all changes to the "body_override_mode" field.
func (m *ChannelMonitorMutation) ResetBodyOverrideMode() {
m.body_override_mode = nil
}
// SetBodyOverride sets the "body_override" field.
func (m *ChannelMonitorMutation) SetBodyOverride(value map[string]interface{}) {
m.body_override = &value
}
// BodyOverride returns the value of the "body_override" field in the mutation.
func (m *ChannelMonitorMutation) BodyOverride() (r map[string]interface{}, exists bool) {
v := m.body_override
if v == nil {
return
}
return *v, true
}
// OldBodyOverride returns the old "body_override" field's value of the ChannelMonitor entity.
// If the ChannelMonitor object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorMutation) OldBodyOverride(ctx context.Context) (v map[string]interface{}, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldBodyOverride is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldBodyOverride requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldBodyOverride: %w", err)
}
return oldValue.BodyOverride, nil
}
// ClearBodyOverride clears the value of the "body_override" field.
func (m *ChannelMonitorMutation) ClearBodyOverride() {
m.body_override = nil
m.clearedFields[channelmonitor.FieldBodyOverride] = struct{}{}
}
// BodyOverrideCleared returns if the "body_override" field was cleared in this mutation.
func (m *ChannelMonitorMutation) BodyOverrideCleared() bool {
_, ok := m.clearedFields[channelmonitor.FieldBodyOverride]
return ok
}
// ResetBodyOverride resets all changes to the "body_override" field.
func (m *ChannelMonitorMutation) ResetBodyOverride() {
m.body_override = nil
delete(m.clearedFields, channelmonitor.FieldBodyOverride)
}
// AddHistoryIDs adds the "history" edge to the ChannelMonitorHistory entity by ids.
func (m *ChannelMonitorMutation) AddHistoryIDs(ids ...int64) {
if m.history == nil {
......@@ -9529,6 +9706,46 @@ func (m *ChannelMonitorMutation) ResetDailyRollups() {
m.removeddaily_rollups = nil
}
 
// SetRequestTemplateID sets the "request_template" edge to the ChannelMonitorRequestTemplate entity by id.
func (m *ChannelMonitorMutation) SetRequestTemplateID(id int64) {
m.request_template = &id
}
// ClearRequestTemplate clears the "request_template" edge to the ChannelMonitorRequestTemplate entity.
func (m *ChannelMonitorMutation) ClearRequestTemplate() {
m.clearedrequest_template = true
m.clearedFields[channelmonitor.FieldTemplateID] = struct{}{}
}
// RequestTemplateCleared reports if the "request_template" edge to the ChannelMonitorRequestTemplate entity was cleared.
func (m *ChannelMonitorMutation) RequestTemplateCleared() bool {
return m.TemplateIDCleared() || m.clearedrequest_template
}
// RequestTemplateID returns the "request_template" edge ID in the mutation.
func (m *ChannelMonitorMutation) RequestTemplateID() (id int64, exists bool) {
if m.request_template != nil {
return *m.request_template, true
}
return
}
// RequestTemplateIDs returns the "request_template" edge IDs in the mutation.
// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use
// RequestTemplateID instead. It exists only for internal usage by the builders.
func (m *ChannelMonitorMutation) RequestTemplateIDs() (ids []int64) {
if id := m.request_template; id != nil {
ids = append(ids, *id)
}
return
}
// ResetRequestTemplate resets all changes to the "request_template" edge.
func (m *ChannelMonitorMutation) ResetRequestTemplate() {
m.request_template = nil
m.clearedrequest_template = false
}
// Where appends a list predicates to the ChannelMonitorMutation builder.
func (m *ChannelMonitorMutation) Where(ps ...predicate.ChannelMonitor) {
m.predicates = append(m.predicates, ps...)
......@@ -9563,7 +9780,7 @@ func (m *ChannelMonitorMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *ChannelMonitorMutation) Fields() []string {
fields := make([]string, 0, 13)
fields := make([]string, 0, 17)
if m.created_at != nil {
fields = append(fields, channelmonitor.FieldCreatedAt)
}
......@@ -9603,6 +9820,18 @@ func (m *ChannelMonitorMutation) Fields() []string {
if m.created_by != nil {
fields = append(fields, channelmonitor.FieldCreatedBy)
}
if m.request_template != nil {
fields = append(fields, channelmonitor.FieldTemplateID)
}
if m.extra_headers != nil {
fields = append(fields, channelmonitor.FieldExtraHeaders)
}
if m.body_override_mode != nil {
fields = append(fields, channelmonitor.FieldBodyOverrideMode)
}
if m.body_override != nil {
fields = append(fields, channelmonitor.FieldBodyOverride)
}
return fields
}
 
......@@ -9637,6 +9866,14 @@ func (m *ChannelMonitorMutation) Field(name string) (ent.Value, bool) {
return m.LastCheckedAt()
case channelmonitor.FieldCreatedBy:
return m.CreatedBy()
case channelmonitor.FieldTemplateID:
return m.TemplateID()
case channelmonitor.FieldExtraHeaders:
return m.ExtraHeaders()
case channelmonitor.FieldBodyOverrideMode:
return m.BodyOverrideMode()
case channelmonitor.FieldBodyOverride:
return m.BodyOverride()
}
return nil, false
}
......@@ -9672,6 +9909,14 @@ func (m *ChannelMonitorMutation) OldField(ctx context.Context, name string) (ent
return m.OldLastCheckedAt(ctx)
case channelmonitor.FieldCreatedBy:
return m.OldCreatedBy(ctx)
case channelmonitor.FieldTemplateID:
return m.OldTemplateID(ctx)
case channelmonitor.FieldExtraHeaders:
return m.OldExtraHeaders(ctx)
case channelmonitor.FieldBodyOverrideMode:
return m.OldBodyOverrideMode(ctx)
case channelmonitor.FieldBodyOverride:
return m.OldBodyOverride(ctx)
}
return nil, fmt.Errorf("unknown ChannelMonitor field %s", name)
}
......@@ -9772,6 +10017,34 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error {
}
m.SetCreatedBy(v)
return nil
case channelmonitor.FieldTemplateID:
v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetTemplateID(v)
return nil
case channelmonitor.FieldExtraHeaders:
v, ok := value.(map[string]string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetExtraHeaders(v)
return nil
case channelmonitor.FieldBodyOverrideMode:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetBodyOverrideMode(v)
return nil
case channelmonitor.FieldBodyOverride:
v, ok := value.(map[string]interface{})
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetBodyOverride(v)
return nil
}
return fmt.Errorf("unknown ChannelMonitor field %s", name)
}
......@@ -9835,6 +10108,12 @@ func (m *ChannelMonitorMutation) ClearedFields() []string {
if m.FieldCleared(channelmonitor.FieldLastCheckedAt) {
fields = append(fields, channelmonitor.FieldLastCheckedAt)
}
if m.FieldCleared(channelmonitor.FieldTemplateID) {
fields = append(fields, channelmonitor.FieldTemplateID)
}
if m.FieldCleared(channelmonitor.FieldBodyOverride) {
fields = append(fields, channelmonitor.FieldBodyOverride)
}
return fields
}
 
......@@ -9855,6 +10134,12 @@ func (m *ChannelMonitorMutation) ClearField(name string) error {
case channelmonitor.FieldLastCheckedAt:
m.ClearLastCheckedAt()
return nil
case channelmonitor.FieldTemplateID:
m.ClearTemplateID()
return nil
case channelmonitor.FieldBodyOverride:
m.ClearBodyOverride()
return nil
}
return fmt.Errorf("unknown ChannelMonitor nullable field %s", name)
}
......@@ -9902,19 +10187,34 @@ func (m *ChannelMonitorMutation) ResetField(name string) error {
case channelmonitor.FieldCreatedBy:
m.ResetCreatedBy()
return nil
case channelmonitor.FieldTemplateID:
m.ResetTemplateID()
return nil
case channelmonitor.FieldExtraHeaders:
m.ResetExtraHeaders()
return nil
case channelmonitor.FieldBodyOverrideMode:
m.ResetBodyOverrideMode()
return nil
case channelmonitor.FieldBodyOverride:
m.ResetBodyOverride()
return nil
}
return fmt.Errorf("unknown ChannelMonitor field %s", name)
}
 
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *ChannelMonitorMutation) AddedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.history != nil {
edges = append(edges, channelmonitor.EdgeHistory)
}
if m.daily_rollups != nil {
edges = append(edges, channelmonitor.EdgeDailyRollups)
}
if m.request_template != nil {
edges = append(edges, channelmonitor.EdgeRequestTemplate)
}
return edges
}
 
......@@ -9934,13 +10234,17 @@ func (m *ChannelMonitorMutation) AddedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case channelmonitor.EdgeRequestTemplate:
if id := m.request_template; id != nil {
return []ent.Value{*id}
}
}
return nil
}
 
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *ChannelMonitorMutation) RemovedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.removedhistory != nil {
edges = append(edges, channelmonitor.EdgeHistory)
}
......@@ -9972,13 +10276,16 @@ func (m *ChannelMonitorMutation) RemovedIDs(name string) []ent.Value {
 
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *ChannelMonitorMutation) ClearedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.clearedhistory {
edges = append(edges, channelmonitor.EdgeHistory)
}
if m.cleareddaily_rollups {
edges = append(edges, channelmonitor.EdgeDailyRollups)
}
if m.clearedrequest_template {
edges = append(edges, channelmonitor.EdgeRequestTemplate)
}
return edges
}
 
......@@ -9990,6 +10297,8 @@ func (m *ChannelMonitorMutation) EdgeCleared(name string) bool {
return m.clearedhistory
case channelmonitor.EdgeDailyRollups:
return m.cleareddaily_rollups
case channelmonitor.EdgeRequestTemplate:
return m.clearedrequest_template
}
return false
}
......@@ -9998,6 +10307,9 @@ func (m *ChannelMonitorMutation) EdgeCleared(name string) bool {
// if that edge is not defined in the schema.
func (m *ChannelMonitorMutation) ClearEdge(name string) error {
switch name {
case channelmonitor.EdgeRequestTemplate:
m.ClearRequestTemplate()
return nil
}
return fmt.Errorf("unknown ChannelMonitor unique edge %s", name)
}
......@@ -10012,6 +10324,9 @@ func (m *ChannelMonitorMutation) ResetEdge(name string) error {
case channelmonitor.EdgeDailyRollups:
m.ResetDailyRollups()
return nil
case channelmonitor.EdgeRequestTemplate:
m.ResetRequestTemplate()
return nil
}
return fmt.Errorf("unknown ChannelMonitor edge %s", name)
}
......@@ -12266,6 +12581,844 @@ func (m *ChannelMonitorHistoryMutation) ResetEdge(name string) error {
return fmt.Errorf("unknown ChannelMonitorHistory edge %s", name)
}
 
// ChannelMonitorRequestTemplateMutation represents an operation that mutates the ChannelMonitorRequestTemplate nodes in the graph.
type ChannelMonitorRequestTemplateMutation struct {
config
op Op
typ string
id *int64
created_at *time.Time
updated_at *time.Time
name *string
provider *channelmonitorrequesttemplate.Provider
description *string
extra_headers *map[string]string
body_override_mode *string
body_override *map[string]interface{}
clearedFields map[string]struct{}
monitors map[int64]struct{}
removedmonitors map[int64]struct{}
clearedmonitors bool
done bool
oldValue func(context.Context) (*ChannelMonitorRequestTemplate, error)
predicates []predicate.ChannelMonitorRequestTemplate
}
var _ ent.Mutation = (*ChannelMonitorRequestTemplateMutation)(nil)
// channelmonitorrequesttemplateOption allows management of the mutation configuration using functional options.
type channelmonitorrequesttemplateOption func(*ChannelMonitorRequestTemplateMutation)
// newChannelMonitorRequestTemplateMutation creates new mutation for the ChannelMonitorRequestTemplate entity.
func newChannelMonitorRequestTemplateMutation(c config, op Op, opts ...channelmonitorrequesttemplateOption) *ChannelMonitorRequestTemplateMutation {
m := &ChannelMonitorRequestTemplateMutation{
config: c,
op: op,
typ: TypeChannelMonitorRequestTemplate,
clearedFields: make(map[string]struct{}),
}
for _, opt := range opts {
opt(m)
}
return m
}
// withChannelMonitorRequestTemplateID sets the ID field of the mutation.
func withChannelMonitorRequestTemplateID(id int64) channelmonitorrequesttemplateOption {
return func(m *ChannelMonitorRequestTemplateMutation) {
var (
err error
once sync.Once
value *ChannelMonitorRequestTemplate
)
m.oldValue = func(ctx context.Context) (*ChannelMonitorRequestTemplate, error) {
once.Do(func() {
if m.done {
err = errors.New("querying old values post mutation is not allowed")
} else {
value, err = m.Client().ChannelMonitorRequestTemplate.Get(ctx, id)
}
})
return value, err
}
m.id = &id
}
}
// withChannelMonitorRequestTemplate sets the old ChannelMonitorRequestTemplate of the mutation.
func withChannelMonitorRequestTemplate(node *ChannelMonitorRequestTemplate) channelmonitorrequesttemplateOption {
return func(m *ChannelMonitorRequestTemplateMutation) {
m.oldValue = func(context.Context) (*ChannelMonitorRequestTemplate, error) {
return node, nil
}
m.id = &node.ID
}
}
// Client returns a new `ent.Client` from the mutation. If the mutation was
// executed in a transaction (ent.Tx), a transactional client is returned.
func (m ChannelMonitorRequestTemplateMutation) Client() *Client {
client := &Client{config: m.config}
client.init()
return client
}
// Tx returns an `ent.Tx` for mutations that were executed in transactions;
// it returns an error otherwise.
func (m ChannelMonitorRequestTemplateMutation) Tx() (*Tx, error) {
if _, ok := m.driver.(*txDriver); !ok {
return nil, errors.New("ent: mutation is not running in a transaction")
}
tx := &Tx{config: m.config}
tx.init()
return tx, nil
}
// ID returns the ID value in the mutation. Note that the ID is only available
// if it was provided to the builder or after it was returned from the database.
func (m *ChannelMonitorRequestTemplateMutation) ID() (id int64, exists bool) {
if m.id == nil {
return
}
return *m.id, true
}
// IDs queries the database and returns the entity ids that match the mutation's predicate.
// That means, if the mutation is applied within a transaction with an isolation level such
// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated
// or updated by the mutation.
func (m *ChannelMonitorRequestTemplateMutation) IDs(ctx context.Context) ([]int64, error) {
switch {
case m.op.Is(OpUpdateOne | OpDeleteOne):
id, exists := m.ID()
if exists {
return []int64{id}, nil
}
fallthrough
case m.op.Is(OpUpdate | OpDelete):
return m.Client().ChannelMonitorRequestTemplate.Query().Where(m.predicates...).IDs(ctx)
default:
return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op)
}
}
// SetCreatedAt sets the "created_at" field.
func (m *ChannelMonitorRequestTemplateMutation) SetCreatedAt(t time.Time) {
m.created_at = &t
}
// CreatedAt returns the value of the "created_at" field in the mutation.
func (m *ChannelMonitorRequestTemplateMutation) CreatedAt() (r time.Time, exists bool) {
v := m.created_at
if v == nil {
return
}
return *v, true
}
// OldCreatedAt returns the old "created_at" field's value of the ChannelMonitorRequestTemplate entity.
// If the ChannelMonitorRequestTemplate object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorRequestTemplateMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldCreatedAt requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err)
}
return oldValue.CreatedAt, nil
}
// ResetCreatedAt resets all changes to the "created_at" field.
func (m *ChannelMonitorRequestTemplateMutation) ResetCreatedAt() {
m.created_at = nil
}
// SetUpdatedAt sets the "updated_at" field.
func (m *ChannelMonitorRequestTemplateMutation) SetUpdatedAt(t time.Time) {
m.updated_at = &t
}
// UpdatedAt returns the value of the "updated_at" field in the mutation.
func (m *ChannelMonitorRequestTemplateMutation) UpdatedAt() (r time.Time, exists bool) {
v := m.updated_at
if v == nil {
return
}
return *v, true
}
// OldUpdatedAt returns the old "updated_at" field's value of the ChannelMonitorRequestTemplate entity.
// If the ChannelMonitorRequestTemplate object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorRequestTemplateMutation) OldUpdatedAt(ctx context.Context) (v time.Time, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldUpdatedAt is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldUpdatedAt requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldUpdatedAt: %w", err)
}
return oldValue.UpdatedAt, nil
}
// ResetUpdatedAt resets all changes to the "updated_at" field.
func (m *ChannelMonitorRequestTemplateMutation) ResetUpdatedAt() {
m.updated_at = nil
}
// SetName sets the "name" field.
func (m *ChannelMonitorRequestTemplateMutation) SetName(s string) {
m.name = &s
}
// Name returns the value of the "name" field in the mutation.
func (m *ChannelMonitorRequestTemplateMutation) Name() (r string, exists bool) {
v := m.name
if v == nil {
return
}
return *v, true
}
// OldName returns the old "name" field's value of the ChannelMonitorRequestTemplate entity.
// If the ChannelMonitorRequestTemplate object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorRequestTemplateMutation) OldName(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldName is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldName requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldName: %w", err)
}
return oldValue.Name, nil
}
// ResetName resets all changes to the "name" field.
func (m *ChannelMonitorRequestTemplateMutation) ResetName() {
m.name = nil
}
// SetProvider sets the "provider" field.
func (m *ChannelMonitorRequestTemplateMutation) SetProvider(c channelmonitorrequesttemplate.Provider) {
m.provider = &c
}
// Provider returns the value of the "provider" field in the mutation.
func (m *ChannelMonitorRequestTemplateMutation) Provider() (r channelmonitorrequesttemplate.Provider, exists bool) {
v := m.provider
if v == nil {
return
}
return *v, true
}
// OldProvider returns the old "provider" field's value of the ChannelMonitorRequestTemplate entity.
// If the ChannelMonitorRequestTemplate object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorRequestTemplateMutation) OldProvider(ctx context.Context) (v channelmonitorrequesttemplate.Provider, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldProvider is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldProvider requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldProvider: %w", err)
}
return oldValue.Provider, nil
}
// ResetProvider resets all changes to the "provider" field.
func (m *ChannelMonitorRequestTemplateMutation) ResetProvider() {
m.provider = nil
}
// SetDescription sets the "description" field.
func (m *ChannelMonitorRequestTemplateMutation) SetDescription(s string) {
m.description = &s
}
// Description returns the value of the "description" field in the mutation.
func (m *ChannelMonitorRequestTemplateMutation) Description() (r string, exists bool) {
v := m.description
if v == nil {
return
}
return *v, true
}
// OldDescription returns the old "description" field's value of the ChannelMonitorRequestTemplate entity.
// If the ChannelMonitorRequestTemplate object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorRequestTemplateMutation) OldDescription(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldDescription is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldDescription requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldDescription: %w", err)
}
return oldValue.Description, nil
}
// ClearDescription clears the value of the "description" field.
func (m *ChannelMonitorRequestTemplateMutation) ClearDescription() {
m.description = nil
m.clearedFields[channelmonitorrequesttemplate.FieldDescription] = struct{}{}
}
// DescriptionCleared returns if the "description" field was cleared in this mutation.
func (m *ChannelMonitorRequestTemplateMutation) DescriptionCleared() bool {
_, ok := m.clearedFields[channelmonitorrequesttemplate.FieldDescription]
return ok
}
// ResetDescription resets all changes to the "description" field.
func (m *ChannelMonitorRequestTemplateMutation) ResetDescription() {
m.description = nil
delete(m.clearedFields, channelmonitorrequesttemplate.FieldDescription)
}
// SetExtraHeaders sets the "extra_headers" field.
func (m *ChannelMonitorRequestTemplateMutation) SetExtraHeaders(value map[string]string) {
m.extra_headers = &value
}
// ExtraHeaders returns the value of the "extra_headers" field in the mutation.
func (m *ChannelMonitorRequestTemplateMutation) ExtraHeaders() (r map[string]string, exists bool) {
v := m.extra_headers
if v == nil {
return
}
return *v, true
}
// OldExtraHeaders returns the old "extra_headers" field's value of the ChannelMonitorRequestTemplate entity.
// If the ChannelMonitorRequestTemplate object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorRequestTemplateMutation) OldExtraHeaders(ctx context.Context) (v map[string]string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldExtraHeaders is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldExtraHeaders requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldExtraHeaders: %w", err)
}
return oldValue.ExtraHeaders, nil
}
// ResetExtraHeaders resets all changes to the "extra_headers" field.
func (m *ChannelMonitorRequestTemplateMutation) ResetExtraHeaders() {
m.extra_headers = nil
}
// SetBodyOverrideMode sets the "body_override_mode" field.
func (m *ChannelMonitorRequestTemplateMutation) SetBodyOverrideMode(s string) {
m.body_override_mode = &s
}
// BodyOverrideMode returns the value of the "body_override_mode" field in the mutation.
func (m *ChannelMonitorRequestTemplateMutation) BodyOverrideMode() (r string, exists bool) {
v := m.body_override_mode
if v == nil {
return
}
return *v, true
}
// OldBodyOverrideMode returns the old "body_override_mode" field's value of the ChannelMonitorRequestTemplate entity.
// If the ChannelMonitorRequestTemplate object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorRequestTemplateMutation) OldBodyOverrideMode(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldBodyOverrideMode is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldBodyOverrideMode requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldBodyOverrideMode: %w", err)
}
return oldValue.BodyOverrideMode, nil
}
// ResetBodyOverrideMode resets all changes to the "body_override_mode" field.
func (m *ChannelMonitorRequestTemplateMutation) ResetBodyOverrideMode() {
m.body_override_mode = nil
}
// SetBodyOverride sets the "body_override" field.
func (m *ChannelMonitorRequestTemplateMutation) SetBodyOverride(value map[string]interface{}) {
m.body_override = &value
}
// BodyOverride returns the value of the "body_override" field in the mutation.
func (m *ChannelMonitorRequestTemplateMutation) BodyOverride() (r map[string]interface{}, exists bool) {
v := m.body_override
if v == nil {
return
}
return *v, true
}
// OldBodyOverride returns the old "body_override" field's value of the ChannelMonitorRequestTemplate entity.
// If the ChannelMonitorRequestTemplate object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorRequestTemplateMutation) OldBodyOverride(ctx context.Context) (v map[string]interface{}, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldBodyOverride is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldBodyOverride requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldBodyOverride: %w", err)
}
return oldValue.BodyOverride, nil
}
// ClearBodyOverride clears the value of the "body_override" field.
func (m *ChannelMonitorRequestTemplateMutation) ClearBodyOverride() {
m.body_override = nil
m.clearedFields[channelmonitorrequesttemplate.FieldBodyOverride] = struct{}{}
}
// BodyOverrideCleared returns if the "body_override" field was cleared in this mutation.
func (m *ChannelMonitorRequestTemplateMutation) BodyOverrideCleared() bool {
_, ok := m.clearedFields[channelmonitorrequesttemplate.FieldBodyOverride]
return ok
}
// ResetBodyOverride resets all changes to the "body_override" field.
func (m *ChannelMonitorRequestTemplateMutation) ResetBodyOverride() {
m.body_override = nil
delete(m.clearedFields, channelmonitorrequesttemplate.FieldBodyOverride)
}
// AddMonitorIDs adds the "monitors" edge to the ChannelMonitor entity by ids.
func (m *ChannelMonitorRequestTemplateMutation) AddMonitorIDs(ids ...int64) {
if m.monitors == nil {
m.monitors = make(map[int64]struct{})
}
for i := range ids {
m.monitors[ids[i]] = struct{}{}
}
}
// ClearMonitors clears the "monitors" edge to the ChannelMonitor entity.
func (m *ChannelMonitorRequestTemplateMutation) ClearMonitors() {
m.clearedmonitors = true
}
// MonitorsCleared reports if the "monitors" edge to the ChannelMonitor entity was cleared.
func (m *ChannelMonitorRequestTemplateMutation) MonitorsCleared() bool {
return m.clearedmonitors
}
// RemoveMonitorIDs removes the "monitors" edge to the ChannelMonitor entity by IDs.
func (m *ChannelMonitorRequestTemplateMutation) RemoveMonitorIDs(ids ...int64) {
if m.removedmonitors == nil {
m.removedmonitors = make(map[int64]struct{})
}
for i := range ids {
delete(m.monitors, ids[i])
m.removedmonitors[ids[i]] = struct{}{}
}
}
// RemovedMonitors returns the removed IDs of the "monitors" edge to the ChannelMonitor entity.
func (m *ChannelMonitorRequestTemplateMutation) RemovedMonitorsIDs() (ids []int64) {
for id := range m.removedmonitors {
ids = append(ids, id)
}
return
}
// MonitorsIDs returns the "monitors" edge IDs in the mutation.
func (m *ChannelMonitorRequestTemplateMutation) MonitorsIDs() (ids []int64) {
for id := range m.monitors {
ids = append(ids, id)
}
return
}
// ResetMonitors resets all changes to the "monitors" edge.
func (m *ChannelMonitorRequestTemplateMutation) ResetMonitors() {
m.monitors = nil
m.clearedmonitors = false
m.removedmonitors = nil
}
// Where appends a list predicates to the ChannelMonitorRequestTemplateMutation builder.
func (m *ChannelMonitorRequestTemplateMutation) Where(ps ...predicate.ChannelMonitorRequestTemplate) {
m.predicates = append(m.predicates, ps...)
}
// WhereP appends storage-level predicates to the ChannelMonitorRequestTemplateMutation builder. Using this method,
// users can use type-assertion to append predicates that do not depend on any generated package.
func (m *ChannelMonitorRequestTemplateMutation) WhereP(ps ...func(*sql.Selector)) {
p := make([]predicate.ChannelMonitorRequestTemplate, len(ps))
for i := range ps {
p[i] = ps[i]
}
m.Where(p...)
}
// Op returns the operation name.
func (m *ChannelMonitorRequestTemplateMutation) Op() Op {
return m.op
}
// SetOp allows setting the mutation operation.
func (m *ChannelMonitorRequestTemplateMutation) SetOp(op Op) {
m.op = op
}
// Type returns the node type of this mutation (ChannelMonitorRequestTemplate).
func (m *ChannelMonitorRequestTemplateMutation) Type() string {
return m.typ
}
// Fields returns all fields that were changed during this mutation. Note that in
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *ChannelMonitorRequestTemplateMutation) Fields() []string {
fields := make([]string, 0, 8)
if m.created_at != nil {
fields = append(fields, channelmonitorrequesttemplate.FieldCreatedAt)
}
if m.updated_at != nil {
fields = append(fields, channelmonitorrequesttemplate.FieldUpdatedAt)
}
if m.name != nil {
fields = append(fields, channelmonitorrequesttemplate.FieldName)
}
if m.provider != nil {
fields = append(fields, channelmonitorrequesttemplate.FieldProvider)
}
if m.description != nil {
fields = append(fields, channelmonitorrequesttemplate.FieldDescription)
}
if m.extra_headers != nil {
fields = append(fields, channelmonitorrequesttemplate.FieldExtraHeaders)
}
if m.body_override_mode != nil {
fields = append(fields, channelmonitorrequesttemplate.FieldBodyOverrideMode)
}
if m.body_override != nil {
fields = append(fields, channelmonitorrequesttemplate.FieldBodyOverride)
}
return fields
}
// Field returns the value of a field with the given name. The second boolean
// return value indicates that this field was not set, or was not defined in the
// schema.
func (m *ChannelMonitorRequestTemplateMutation) Field(name string) (ent.Value, bool) {
switch name {
case channelmonitorrequesttemplate.FieldCreatedAt:
return m.CreatedAt()
case channelmonitorrequesttemplate.FieldUpdatedAt:
return m.UpdatedAt()
case channelmonitorrequesttemplate.FieldName:
return m.Name()
case channelmonitorrequesttemplate.FieldProvider:
return m.Provider()
case channelmonitorrequesttemplate.FieldDescription:
return m.Description()
case channelmonitorrequesttemplate.FieldExtraHeaders:
return m.ExtraHeaders()
case channelmonitorrequesttemplate.FieldBodyOverrideMode:
return m.BodyOverrideMode()
case channelmonitorrequesttemplate.FieldBodyOverride:
return m.BodyOverride()
}
return nil, false
}
// OldField returns the old value of the field from the database. An error is
// returned if the mutation operation is not UpdateOne, or the query to the
// database failed.
func (m *ChannelMonitorRequestTemplateMutation) OldField(ctx context.Context, name string) (ent.Value, error) {
switch name {
case channelmonitorrequesttemplate.FieldCreatedAt:
return m.OldCreatedAt(ctx)
case channelmonitorrequesttemplate.FieldUpdatedAt:
return m.OldUpdatedAt(ctx)
case channelmonitorrequesttemplate.FieldName:
return m.OldName(ctx)
case channelmonitorrequesttemplate.FieldProvider:
return m.OldProvider(ctx)
case channelmonitorrequesttemplate.FieldDescription:
return m.OldDescription(ctx)
case channelmonitorrequesttemplate.FieldExtraHeaders:
return m.OldExtraHeaders(ctx)
case channelmonitorrequesttemplate.FieldBodyOverrideMode:
return m.OldBodyOverrideMode(ctx)
case channelmonitorrequesttemplate.FieldBodyOverride:
return m.OldBodyOverride(ctx)
}
return nil, fmt.Errorf("unknown ChannelMonitorRequestTemplate field %s", name)
}
// SetField sets the value of a field with the given name. It returns an error if
// the field is not defined in the schema, or if the type mismatched the field
// type.
func (m *ChannelMonitorRequestTemplateMutation) SetField(name string, value ent.Value) error {
switch name {
case channelmonitorrequesttemplate.FieldCreatedAt:
v, ok := value.(time.Time)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetCreatedAt(v)
return nil
case channelmonitorrequesttemplate.FieldUpdatedAt:
v, ok := value.(time.Time)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetUpdatedAt(v)
return nil
case channelmonitorrequesttemplate.FieldName:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetName(v)
return nil
case channelmonitorrequesttemplate.FieldProvider:
v, ok := value.(channelmonitorrequesttemplate.Provider)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetProvider(v)
return nil
case channelmonitorrequesttemplate.FieldDescription:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetDescription(v)
return nil
case channelmonitorrequesttemplate.FieldExtraHeaders:
v, ok := value.(map[string]string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetExtraHeaders(v)
return nil
case channelmonitorrequesttemplate.FieldBodyOverrideMode:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetBodyOverrideMode(v)
return nil
case channelmonitorrequesttemplate.FieldBodyOverride:
v, ok := value.(map[string]interface{})
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetBodyOverride(v)
return nil
}
return fmt.Errorf("unknown ChannelMonitorRequestTemplate field %s", name)
}
// AddedFields returns all numeric fields that were incremented/decremented during
// this mutation.
func (m *ChannelMonitorRequestTemplateMutation) AddedFields() []string {
return nil
}
// AddedField returns the numeric value that was incremented/decremented on a field
// with the given name. The second boolean return value indicates that this field
// was not set, or was not defined in the schema.
func (m *ChannelMonitorRequestTemplateMutation) AddedField(name string) (ent.Value, bool) {
return nil, false
}
// AddField adds the value to the field with the given name. It returns an error if
// the field is not defined in the schema, or if the type mismatched the field
// type.
func (m *ChannelMonitorRequestTemplateMutation) AddField(name string, value ent.Value) error {
switch name {
}
return fmt.Errorf("unknown ChannelMonitorRequestTemplate numeric field %s", name)
}
// ClearedFields returns all nullable fields that were cleared during this
// mutation.
func (m *ChannelMonitorRequestTemplateMutation) ClearedFields() []string {
var fields []string
if m.FieldCleared(channelmonitorrequesttemplate.FieldDescription) {
fields = append(fields, channelmonitorrequesttemplate.FieldDescription)
}
if m.FieldCleared(channelmonitorrequesttemplate.FieldBodyOverride) {
fields = append(fields, channelmonitorrequesttemplate.FieldBodyOverride)
}
return fields
}
// FieldCleared returns a boolean indicating if a field with the given name was
// cleared in this mutation.
func (m *ChannelMonitorRequestTemplateMutation) FieldCleared(name string) bool {
_, ok := m.clearedFields[name]
return ok
}
// ClearField clears the value of the field with the given name. It returns an
// error if the field is not defined in the schema.
func (m *ChannelMonitorRequestTemplateMutation) ClearField(name string) error {
switch name {
case channelmonitorrequesttemplate.FieldDescription:
m.ClearDescription()
return nil
case channelmonitorrequesttemplate.FieldBodyOverride:
m.ClearBodyOverride()
return nil
}
return fmt.Errorf("unknown ChannelMonitorRequestTemplate nullable field %s", name)
}
// ResetField resets all changes in the mutation for the field with the given name.
// It returns an error if the field is not defined in the schema.
func (m *ChannelMonitorRequestTemplateMutation) ResetField(name string) error {
switch name {
case channelmonitorrequesttemplate.FieldCreatedAt:
m.ResetCreatedAt()
return nil
case channelmonitorrequesttemplate.FieldUpdatedAt:
m.ResetUpdatedAt()
return nil
case channelmonitorrequesttemplate.FieldName:
m.ResetName()
return nil
case channelmonitorrequesttemplate.FieldProvider:
m.ResetProvider()
return nil
case channelmonitorrequesttemplate.FieldDescription:
m.ResetDescription()
return nil
case channelmonitorrequesttemplate.FieldExtraHeaders:
m.ResetExtraHeaders()
return nil
case channelmonitorrequesttemplate.FieldBodyOverrideMode:
m.ResetBodyOverrideMode()
return nil
case channelmonitorrequesttemplate.FieldBodyOverride:
m.ResetBodyOverride()
return nil
}
return fmt.Errorf("unknown ChannelMonitorRequestTemplate field %s", name)
}
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *ChannelMonitorRequestTemplateMutation) AddedEdges() []string {
edges := make([]string, 0, 1)
if m.monitors != nil {
edges = append(edges, channelmonitorrequesttemplate.EdgeMonitors)
}
return edges
}
// AddedIDs returns all IDs (to other nodes) that were added for the given edge
// name in this mutation.
func (m *ChannelMonitorRequestTemplateMutation) AddedIDs(name string) []ent.Value {
switch name {
case channelmonitorrequesttemplate.EdgeMonitors:
ids := make([]ent.Value, 0, len(m.monitors))
for id := range m.monitors {
ids = append(ids, id)
}
return ids
}
return nil
}
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *ChannelMonitorRequestTemplateMutation) RemovedEdges() []string {
edges := make([]string, 0, 1)
if m.removedmonitors != nil {
edges = append(edges, channelmonitorrequesttemplate.EdgeMonitors)
}
return edges
}
// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with
// the given name in this mutation.
func (m *ChannelMonitorRequestTemplateMutation) RemovedIDs(name string) []ent.Value {
switch name {
case channelmonitorrequesttemplate.EdgeMonitors:
ids := make([]ent.Value, 0, len(m.removedmonitors))
for id := range m.removedmonitors {
ids = append(ids, id)
}
return ids
}
return nil
}
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *ChannelMonitorRequestTemplateMutation) ClearedEdges() []string {
edges := make([]string, 0, 1)
if m.clearedmonitors {
edges = append(edges, channelmonitorrequesttemplate.EdgeMonitors)
}
return edges
}
// EdgeCleared returns a boolean which indicates if the edge with the given name
// was cleared in this mutation.
func (m *ChannelMonitorRequestTemplateMutation) EdgeCleared(name string) bool {
switch name {
case channelmonitorrequesttemplate.EdgeMonitors:
return m.clearedmonitors
}
return false
}
// ClearEdge clears the value of the edge with the given name. It returns an error
// if that edge is not defined in the schema.
func (m *ChannelMonitorRequestTemplateMutation) ClearEdge(name string) error {
switch name {
}
return fmt.Errorf("unknown ChannelMonitorRequestTemplate unique edge %s", name)
}
// ResetEdge resets all changes to the edge with the given name in this mutation.
// It returns an error if the edge is not defined in the schema.
func (m *ChannelMonitorRequestTemplateMutation) ResetEdge(name string) error {
switch name {
case channelmonitorrequesttemplate.EdgeMonitors:
m.ResetMonitors()
return nil
}
return fmt.Errorf("unknown ChannelMonitorRequestTemplate edge %s", name)
}
// ErrorPassthroughRuleMutation represents an operation that mutates the ErrorPassthroughRule nodes in the graph.
type ErrorPassthroughRuleMutation struct {
config
......@@ -36,6 +36,9 @@ type ChannelMonitorDailyRollup func(*sql.Selector)
// ChannelMonitorHistory is the predicate function for channelmonitorhistory builders.
type ChannelMonitorHistory func(*sql.Selector)
// ChannelMonitorRequestTemplate is the predicate function for channelmonitorrequesttemplate builders.
type ChannelMonitorRequestTemplate func(*sql.Selector)
// ErrorPassthroughRule is the predicate function for errorpassthroughrule builders.
type ErrorPassthroughRule func(*sql.Selector)
......
......@@ -15,6 +15,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitordailyrollup"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate"
"github.com/Wei-Shaw/sub2api/ent/errorpassthroughrule"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/idempotencyrecord"
......@@ -521,6 +522,16 @@ func init() {
channelmonitorDescIntervalSeconds := channelmonitorFields[8].Descriptor()
// channelmonitor.IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save.
channelmonitor.IntervalSecondsValidator = channelmonitorDescIntervalSeconds.Validators[0].(func(int) error)
// channelmonitorDescExtraHeaders is the schema descriptor for extra_headers field.
channelmonitorDescExtraHeaders := channelmonitorFields[12].Descriptor()
// channelmonitor.DefaultExtraHeaders holds the default value on creation for the extra_headers field.
channelmonitor.DefaultExtraHeaders = channelmonitorDescExtraHeaders.Default.(map[string]string)
// channelmonitorDescBodyOverrideMode is the schema descriptor for body_override_mode field.
channelmonitorDescBodyOverrideMode := channelmonitorFields[13].Descriptor()
// channelmonitor.DefaultBodyOverrideMode holds the default value on creation for the body_override_mode field.
channelmonitor.DefaultBodyOverrideMode = channelmonitorDescBodyOverrideMode.Default.(string)
// channelmonitor.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save.
channelmonitor.BodyOverrideModeValidator = channelmonitorDescBodyOverrideMode.Validators[0].(func(string) error)
channelmonitordailyrollupFields := schema.ChannelMonitorDailyRollup{}.Fields()
_ = channelmonitordailyrollupFields
// channelmonitordailyrollupDescModel is the schema descriptor for model field.
......@@ -617,6 +628,55 @@ func init() {
channelmonitorhistoryDescCheckedAt := channelmonitorhistoryFields[6].Descriptor()
// channelmonitorhistory.DefaultCheckedAt holds the default value on creation for the checked_at field.
channelmonitorhistory.DefaultCheckedAt = channelmonitorhistoryDescCheckedAt.Default.(func() time.Time)
channelmonitorrequesttemplateMixin := schema.ChannelMonitorRequestTemplate{}.Mixin()
channelmonitorrequesttemplateMixinFields0 := channelmonitorrequesttemplateMixin[0].Fields()
_ = channelmonitorrequesttemplateMixinFields0
channelmonitorrequesttemplateFields := schema.ChannelMonitorRequestTemplate{}.Fields()
_ = channelmonitorrequesttemplateFields
// channelmonitorrequesttemplateDescCreatedAt is the schema descriptor for created_at field.
channelmonitorrequesttemplateDescCreatedAt := channelmonitorrequesttemplateMixinFields0[0].Descriptor()
// channelmonitorrequesttemplate.DefaultCreatedAt holds the default value on creation for the created_at field.
channelmonitorrequesttemplate.DefaultCreatedAt = channelmonitorrequesttemplateDescCreatedAt.Default.(func() time.Time)
// channelmonitorrequesttemplateDescUpdatedAt is the schema descriptor for updated_at field.
channelmonitorrequesttemplateDescUpdatedAt := channelmonitorrequesttemplateMixinFields0[1].Descriptor()
// channelmonitorrequesttemplate.DefaultUpdatedAt holds the default value on creation for the updated_at field.
channelmonitorrequesttemplate.DefaultUpdatedAt = channelmonitorrequesttemplateDescUpdatedAt.Default.(func() time.Time)
// channelmonitorrequesttemplate.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
channelmonitorrequesttemplate.UpdateDefaultUpdatedAt = channelmonitorrequesttemplateDescUpdatedAt.UpdateDefault.(func() time.Time)
// channelmonitorrequesttemplateDescName is the schema descriptor for name field.
channelmonitorrequesttemplateDescName := channelmonitorrequesttemplateFields[0].Descriptor()
// channelmonitorrequesttemplate.NameValidator is a validator for the "name" field. It is called by the builders before save.
channelmonitorrequesttemplate.NameValidator = func() func(string) error {
validators := channelmonitorrequesttemplateDescName.Validators
fns := [...]func(string) error{
validators[0].(func(string) error),
validators[1].(func(string) error),
}
return func(name string) error {
for _, fn := range fns {
if err := fn(name); err != nil {
return err
}
}
return nil
}
}()
// channelmonitorrequesttemplateDescDescription is the schema descriptor for description field.
channelmonitorrequesttemplateDescDescription := channelmonitorrequesttemplateFields[2].Descriptor()
// channelmonitorrequesttemplate.DefaultDescription holds the default value on creation for the description field.
channelmonitorrequesttemplate.DefaultDescription = channelmonitorrequesttemplateDescDescription.Default.(string)
// channelmonitorrequesttemplate.DescriptionValidator is a validator for the "description" field. It is called by the builders before save.
channelmonitorrequesttemplate.DescriptionValidator = channelmonitorrequesttemplateDescDescription.Validators[0].(func(string) error)
// channelmonitorrequesttemplateDescExtraHeaders is the schema descriptor for extra_headers field.
channelmonitorrequesttemplateDescExtraHeaders := channelmonitorrequesttemplateFields[3].Descriptor()
// channelmonitorrequesttemplate.DefaultExtraHeaders holds the default value on creation for the extra_headers field.
channelmonitorrequesttemplate.DefaultExtraHeaders = channelmonitorrequesttemplateDescExtraHeaders.Default.(map[string]string)
// channelmonitorrequesttemplateDescBodyOverrideMode is the schema descriptor for body_override_mode field.
channelmonitorrequesttemplateDescBodyOverrideMode := channelmonitorrequesttemplateFields[4].Descriptor()
// channelmonitorrequesttemplate.DefaultBodyOverrideMode holds the default value on creation for the body_override_mode field.
channelmonitorrequesttemplate.DefaultBodyOverrideMode = channelmonitorrequesttemplateDescBodyOverrideMode.Default.(string)
// channelmonitorrequesttemplate.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save.
channelmonitorrequesttemplate.BodyOverrideModeValidator = channelmonitorrequesttemplateDescBodyOverrideMode.Validators[0].(func(string) error)
errorpassthroughruleMixin := schema.ErrorPassthroughRule{}.Mixin()
errorpassthroughruleMixinFields0 := errorpassthroughruleMixin[0].Fields()
_ = errorpassthroughruleMixinFields0
......
......@@ -62,6 +62,26 @@ func (ChannelMonitor) Fields() []ent.Field {
Optional().
Nillable(),
field.Int64("created_by"),
// ---- 自定义请求快照字段(来自模板 / 手动编辑) ----
// template_id: 关联的请求模板 ID(仅用于 UI 分组 + 一键应用)。
// 实际运行时 checker 只读下面 3 个快照字段,**不再回查模板表**。
// 模板被删除时此字段会被 SET NULL(见 Edges 的 OnDelete 注解)。
field.Int64("template_id").
Optional().
Nillable(),
// extra_headers: 自定义 HTTP 头快照(来自模板 or 用户手填)。
// 运行时 merge 进 adapter 默认 headers。
field.JSON("extra_headers", map[string]string{}).
Default(map[string]string{}),
// body_override_mode: 同 ChannelMonitorRequestTemplate.body_override_mode
field.String("body_override_mode").
Default("off").
MaxLen(10),
// body_override: 同 ChannelMonitorRequestTemplate.body_override
field.JSON("body_override", map[string]any{}).
Optional(),
}
}
......@@ -71,6 +91,12 @@ func (ChannelMonitor) Edges() []ent.Edge {
Annotations(entsql.OnDelete(entsql.Cascade)),
edge.To("daily_rollups", ChannelMonitorDailyRollup.Type).
Annotations(entsql.OnDelete(entsql.Cascade)),
// 关联请求模板:模板被删除时 template_id 自动置空,
// 监控本身保留(继续用快照字段跑)。
edge.To("request_template", ChannelMonitorRequestTemplate.Type).
Field("template_id").
Unique().
Annotations(entsql.OnDelete(entsql.SetNull)),
}
}
......@@ -79,5 +105,6 @@ func (ChannelMonitor) Indexes() []ent.Index {
index.Fields("enabled", "last_checked_at"),
index.Fields("provider"),
index.Fields("group_name"),
index.Fields("template_id"),
}
}
package schema
import (
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
)
// ChannelMonitorRequestTemplate 请求模板:一组可复用的 headers + 可选 body 覆盖配置。
//
// 语义为快照:模板被"应用"到监控时,extra_headers / body_override_mode / body_override
// 会被**拷贝**到 channel_monitors 同名字段;后续模板变动不会自动影响已应用的监控——
// 必须用户主动在模板编辑 Dialog 里点「应用到关联监控」才会覆盖快照。
// 这样模板改错不会瞬间打挂所有已经跑起来的监控。
type ChannelMonitorRequestTemplate struct {
ent.Schema
}
func (ChannelMonitorRequestTemplate) Annotations() []schema.Annotation {
return []schema.Annotation{
entsql.Annotation{Table: "channel_monitor_request_templates"},
}
}
func (ChannelMonitorRequestTemplate) Mixin() []ent.Mixin {
return []ent.Mixin{
mixins.TimeMixin{},
}
}
func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
return []ent.Field{
field.String("name").
NotEmpty().
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini"),
field.String("description").
Optional().
Default("").
MaxLen(500),
// extra_headers: 用户自定义 HTTP 头(如 User-Agent 伪装)。
// 运行时 merge 进 adapter 默认 headers,用户值优先;
// hop-by-hop 黑名单(Host/Content-Length/...)由 checker 过滤。
field.JSON("extra_headers", map[string]string{}).
Default(map[string]string{}),
// body_override_mode: 'off' | 'merge' | 'replace'
// off - 用 adapter 默认 body(忽略 body_override)
// merge - adapter 默认 body 与 body_override 浅合并(body_override 优先,
// model/messages/contents 等关键字段在 checker 里走黑名单跳过)
// replace - 直接用 body_override 作为完整 body;此时跳过 challenge 校验,
// 改为 HTTP 2xx + 响应文本非空即视为可用
field.String("body_override_mode").
Default("off").
MaxLen(10),
// body_override: JSON 对象,根据 body_override_mode 使用。
// 用 map[string]any 以便前端传任意结构(含嵌套)。
field.JSON("body_override", map[string]any{}).
Optional(),
}
}
func (ChannelMonitorRequestTemplate) Edges() []ent.Edge {
return []ent.Edge{
edge.From("monitors", ChannelMonitor.Type).
Ref("request_template"),
}
}
func (ChannelMonitorRequestTemplate) Indexes() []ent.Index {
return []ent.Index{
// 同一 provider 内 name 唯一:允许 Anthropic + OpenAI 重名 "伪装官方客户端"。
index.Fields("provider", "name").Unique(),
}
}
......@@ -34,6 +34,8 @@ type Tx struct {
ChannelMonitorDailyRollup *ChannelMonitorDailyRollupClient
// ChannelMonitorHistory is the client for interacting with the ChannelMonitorHistory builders.
ChannelMonitorHistory *ChannelMonitorHistoryClient
// ChannelMonitorRequestTemplate is the client for interacting with the ChannelMonitorRequestTemplate builders.
ChannelMonitorRequestTemplate *ChannelMonitorRequestTemplateClient
// ErrorPassthroughRule is the client for interacting with the ErrorPassthroughRule builders.
ErrorPassthroughRule *ErrorPassthroughRuleClient
// Group is the client for interacting with the Group builders.
......@@ -221,6 +223,7 @@ func (tx *Tx) init() {
tx.ChannelMonitor = NewChannelMonitorClient(tx.config)
tx.ChannelMonitorDailyRollup = NewChannelMonitorDailyRollupClient(tx.config)
tx.ChannelMonitorHistory = NewChannelMonitorHistoryClient(tx.config)
tx.ChannelMonitorRequestTemplate = NewChannelMonitorRequestTemplateClient(tx.config)
tx.ErrorPassthroughRule = NewErrorPassthroughRuleClient(tx.config)
tx.Group = NewGroupClient(tx.config)
tx.IdempotencyRecord = NewIdempotencyRecordClient(tx.config)
......
......@@ -36,27 +36,36 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
// --- 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"`
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"`
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 {
......@@ -79,6 +88,11 @@ type channelMonitorResponse struct {
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 {
......@@ -116,6 +130,10 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse
if extras == nil {
extras = []string{}
}
headers := m.ExtraHeaders
if headers == nil {
headers = map[string]string{}
}
resp := &channelMonitorResponse{
ID: m.ID,
Name: m.Name,
......@@ -131,6 +149,10 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse
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 {
......@@ -279,16 +301,20 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
}
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,
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)
......@@ -310,15 +336,20 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) {
}
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,
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)
......
package admin
import (
"strconv"
"strings"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// ChannelMonitorRequestTemplateHandler 请求模板管理后台 handler。
type ChannelMonitorRequestTemplateHandler struct {
templateService *service.ChannelMonitorRequestTemplateService
}
// NewChannelMonitorRequestTemplateHandler 创建 handler。
func NewChannelMonitorRequestTemplateHandler(templateService *service.ChannelMonitorRequestTemplateService) *ChannelMonitorRequestTemplateHandler {
return &ChannelMonitorRequestTemplateHandler{templateService: templateService}
}
// --- DTO ---
type channelMonitorTemplateCreateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini"`
Description string `json:"description" binding:"max=500"`
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 channelMonitorTemplateUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,max=100"`
Description *string `json:"description" binding:"omitempty,max=500"`
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 channelMonitorTemplateResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Description string `json:"description"`
ExtraHeaders map[string]string `json:"extra_headers"`
BodyOverrideMode string `json:"body_override_mode"`
BodyOverride map[string]any `json:"body_override"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
AssociatedMonitors int64 `json:"associated_monitors"`
}
func (h *ChannelMonitorRequestTemplateHandler) toResponse(c *gin.Context, t *service.ChannelMonitorRequestTemplate) *channelMonitorTemplateResponse {
if t == nil {
return nil
}
headers := t.ExtraHeaders
if headers == nil {
headers = map[string]string{}
}
count, _ := h.templateService.CountAssociatedMonitors(c.Request.Context(), t.ID)
return &channelMonitorTemplateResponse{
ID: t.ID,
Name: t.Name,
Provider: t.Provider,
Description: t.Description,
ExtraHeaders: headers,
BodyOverrideMode: t.BodyOverrideMode,
BodyOverride: t.BodyOverride,
CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.UTC().Format(time.RFC3339),
AssociatedMonitors: count,
}
}
// parseTemplateID 提取并校验 :id。
func parseTemplateID(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_TEMPLATE_ID", "invalid template id"))
return 0, false
}
return id, true
}
// --- Handlers ---
// List GET /api/v1/admin/channel-monitor-templates?provider=anthropic
func (h *ChannelMonitorRequestTemplateHandler) List(c *gin.Context) {
items, err := h.templateService.List(c.Request.Context(), service.ChannelMonitorRequestTemplateListParams{
Provider: strings.TrimSpace(c.Query("provider")),
})
if err != nil {
response.ErrorFrom(c, err)
return
}
out := make([]*channelMonitorTemplateResponse, 0, len(items))
for _, t := range items {
out = append(out, h.toResponse(c, t))
}
response.Success(c, gin.H{"items": out})
}
// Get GET /api/v1/admin/channel-monitor-templates/:id
func (h *ChannelMonitorRequestTemplateHandler) Get(c *gin.Context) {
id, ok := parseTemplateID(c)
if !ok {
return
}
t, err := h.templateService.Get(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, h.toResponse(c, t))
}
// Create POST /api/v1/admin/channel-monitor-templates
func (h *ChannelMonitorRequestTemplateHandler) Create(c *gin.Context) {
var req channelMonitorTemplateCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error()))
return
}
t, err := h.templateService.Create(c.Request.Context(), service.ChannelMonitorRequestTemplateCreateParams{
Name: req.Name,
Provider: req.Provider,
Description: req.Description,
ExtraHeaders: req.ExtraHeaders,
BodyOverrideMode: req.BodyOverrideMode,
BodyOverride: req.BodyOverride,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Created(c, h.toResponse(c, t))
}
// Update PUT /api/v1/admin/channel-monitor-templates/:id
func (h *ChannelMonitorRequestTemplateHandler) Update(c *gin.Context) {
id, ok := parseTemplateID(c)
if !ok {
return
}
var req channelMonitorTemplateUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error()))
return
}
t, err := h.templateService.Update(c.Request.Context(), id, service.ChannelMonitorRequestTemplateUpdateParams{
Name: req.Name,
Description: req.Description,
ExtraHeaders: req.ExtraHeaders,
BodyOverrideMode: req.BodyOverrideMode,
BodyOverride: req.BodyOverride,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, h.toResponse(c, t))
}
// Delete DELETE /api/v1/admin/channel-monitor-templates/:id
func (h *ChannelMonitorRequestTemplateHandler) Delete(c *gin.Context) {
id, ok := parseTemplateID(c)
if !ok {
return
}
if err := h.templateService.Delete(c.Request.Context(), id); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, nil)
}
// Apply POST /api/v1/admin/channel-monitor-templates/:id/apply
// 一键把模板当前配置覆盖到所有关联监控上。
func (h *ChannelMonitorRequestTemplateHandler) Apply(c *gin.Context) {
id, ok := parseTemplateID(c)
if !ok {
return
}
affected, err := h.templateService.ApplyToMonitors(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"affected": affected})
}
......@@ -6,33 +6,34 @@ import (
// AdminHandlers contains all admin-related HTTP handlers
type AdminHandlers struct {
Dashboard *admin.DashboardHandler
User *admin.UserHandler
Group *admin.GroupHandler
Account *admin.AccountHandler
Announcement *admin.AnnouncementHandler
DataManagement *admin.DataManagementHandler
Backup *admin.BackupHandler
OAuth *admin.OAuthHandler
OpenAIOAuth *admin.OpenAIOAuthHandler
GeminiOAuth *admin.GeminiOAuthHandler
AntigravityOAuth *admin.AntigravityOAuthHandler
Proxy *admin.ProxyHandler
Redeem *admin.RedeemHandler
Promo *admin.PromoHandler
Setting *admin.SettingHandler
Ops *admin.OpsHandler
System *admin.SystemHandler
Subscription *admin.SubscriptionHandler
Usage *admin.UsageHandler
UserAttribute *admin.UserAttributeHandler
ErrorPassthrough *admin.ErrorPassthroughHandler
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
Channel *admin.ChannelHandler
ChannelMonitor *admin.ChannelMonitorHandler
Payment *admin.PaymentHandler
Dashboard *admin.DashboardHandler
User *admin.UserHandler
Group *admin.GroupHandler
Account *admin.AccountHandler
Announcement *admin.AnnouncementHandler
DataManagement *admin.DataManagementHandler
Backup *admin.BackupHandler
OAuth *admin.OAuthHandler
OpenAIOAuth *admin.OpenAIOAuthHandler
GeminiOAuth *admin.GeminiOAuthHandler
AntigravityOAuth *admin.AntigravityOAuthHandler
Proxy *admin.ProxyHandler
Redeem *admin.RedeemHandler
Promo *admin.PromoHandler
Setting *admin.SettingHandler
Ops *admin.OpsHandler
System *admin.SystemHandler
Subscription *admin.SubscriptionHandler
Usage *admin.UsageHandler
UserAttribute *admin.UserAttributeHandler
ErrorPassthrough *admin.ErrorPassthroughHandler
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
Channel *admin.ChannelHandler
ChannelMonitor *admin.ChannelMonitorHandler
ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler
Payment *admin.PaymentHandler
}
// Handlers contains all HTTP handlers
......
......@@ -35,36 +35,38 @@ func ProvideAdminHandlers(
scheduledTestHandler *admin.ScheduledTestHandler,
channelHandler *admin.ChannelHandler,
channelMonitorHandler *admin.ChannelMonitorHandler,
channelMonitorTemplateHandler *admin.ChannelMonitorRequestTemplateHandler,
paymentHandler *admin.PaymentHandler,
) *AdminHandlers {
return &AdminHandlers{
Dashboard: dashboardHandler,
User: userHandler,
Group: groupHandler,
Account: accountHandler,
Announcement: announcementHandler,
DataManagement: dataManagementHandler,
Backup: backupHandler,
OAuth: oauthHandler,
OpenAIOAuth: openaiOAuthHandler,
GeminiOAuth: geminiOAuthHandler,
AntigravityOAuth: antigravityOAuthHandler,
Proxy: proxyHandler,
Redeem: redeemHandler,
Promo: promoHandler,
Setting: settingHandler,
Ops: opsHandler,
System: systemHandler,
Subscription: subscriptionHandler,
Usage: usageHandler,
UserAttribute: userAttributeHandler,
ErrorPassthrough: errorPassthroughHandler,
TLSFingerprintProfile: tlsFingerprintProfileHandler,
APIKey: apiKeyHandler,
ScheduledTest: scheduledTestHandler,
Channel: channelHandler,
ChannelMonitor: channelMonitorHandler,
Payment: paymentHandler,
Dashboard: dashboardHandler,
User: userHandler,
Group: groupHandler,
Account: accountHandler,
Announcement: announcementHandler,
DataManagement: dataManagementHandler,
Backup: backupHandler,
OAuth: oauthHandler,
OpenAIOAuth: openaiOAuthHandler,
GeminiOAuth: geminiOAuthHandler,
AntigravityOAuth: antigravityOAuthHandler,
Proxy: proxyHandler,
Redeem: redeemHandler,
Promo: promoHandler,
Setting: settingHandler,
Ops: opsHandler,
System: systemHandler,
Subscription: subscriptionHandler,
Usage: usageHandler,
UserAttribute: userAttributeHandler,
ErrorPassthrough: errorPassthroughHandler,
TLSFingerprintProfile: tlsFingerprintProfileHandler,
APIKey: apiKeyHandler,
ScheduledTest: scheduledTestHandler,
Channel: channelHandler,
ChannelMonitor: channelMonitorHandler,
ChannelMonitorTemplate: channelMonitorTemplateHandler,
Payment: paymentHandler,
}
}
......@@ -162,6 +164,7 @@ var ProviderSet = wire.NewSet(
admin.NewScheduledTestHandler,
admin.NewChannelHandler,
admin.NewChannelMonitorHandler,
admin.NewChannelMonitorRequestTemplateHandler,
admin.NewPaymentHandler,
// AdminHandlers and Handlers constructors
......
......@@ -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 {
......
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,
......
......@@ -579,4 +579,14 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
monitors.POST("/:id/run", h.Admin.ChannelMonitor.Run)
monitors.GET("/:id/history", h.Admin.ChannelMonitor.History)
}
templates := admin.Group("/channel-monitor-templates")
{
templates.GET("", h.Admin.ChannelMonitorTemplate.List)
templates.POST("", h.Admin.ChannelMonitorTemplate.Create)
templates.GET("/:id", h.Admin.ChannelMonitorTemplate.Get)
templates.PUT("/:id", h.Admin.ChannelMonitorTemplate.Update)
templates.DELETE("/:id", h.Admin.ChannelMonitorTemplate.Delete)
templates.POST("/:id/apply", h.Admin.ChannelMonitorTemplate.Apply)
}
}
......@@ -37,9 +37,23 @@ func newSSRFSafeHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{Timeout: timeout, Transport: tr}
}
// CheckOptions 承载一次检测的自定义入参。
// 所有字段都是可选(零值即等价于"用默认行为")。
type CheckOptions struct {
// ExtraHeaders 用户自定义 HTTP 头(merge 到 adapter 默认 headers,用户优先)。
ExtraHeaders map[string]string
// BodyOverrideMode: off | merge | replace
BodyOverrideMode string
// BodyOverride 在 merge 模式下做浅合并(key 命中黑名单时静默丢弃),
// 在 replace 模式下直接当作完整 body。
BodyOverride map[string]any
}
// runCheckForModel 对单个 (provider, model) 做一次完整检测。
// 不返回 error:所有失败都包装进 CheckResult.Status=error/failed。
func runCheckForModel(ctx context.Context, provider, endpoint, apiKey, model string) *CheckResult {
//
// opts 承载模板 / 监控快照带来的自定义配置。nil 等同于 "off + 无 extra headers"。
func runCheckForModel(ctx context.Context, provider, endpoint, apiKey, model string, opts *CheckOptions) *CheckResult {
res := &CheckResult{
Model: model,
Status: MonitorStatusError,
......@@ -47,9 +61,10 @@ func runCheckForModel(ctx context.Context, provider, endpoint, apiKey, model str
}
challenge := generateChallenge()
mode := bodyOverrideMode(opts)
start := time.Now()
respText, rawBody, statusCode, err := callProvider(ctx, provider, endpoint, apiKey, model, challenge.Prompt)
respText, rawBody, statusCode, err := callProvider(ctx, provider, endpoint, apiKey, model, challenge.Prompt, opts)
latency := time.Since(start)
latencyMs := int(latency / time.Millisecond)
res.LatencyMs = &latencyMs
......@@ -68,22 +83,47 @@ func runCheckForModel(ctx context.Context, provider, endpoint, apiKey, model str
return res
}
// Replace 模式:跳过 challenge 校验(用户 body 是静态的,challenge 没法嵌入)。
// 改用「HTTP 2xx + 响应文本(adapter.textPath 抽取)非空」作为 operational 判定。
// 响应文本为空则降级为 failed(视为上游回了 200 但没实际内容)。
if mode == MonitorBodyOverrideModeReplace {
if strings.TrimSpace(respText) == "" {
res.Status = MonitorStatusFailed
res.Message = truncateMessage("replace-mode: upstream returned 2xx with empty text")
return res
}
return finalizeOperationalOrDegraded(res, latency, latencyMs)
}
if !validateChallenge(respText, challenge.Expected) {
res.Status = MonitorStatusFailed
res.Message = truncateMessage(sanitizeErrorMessage(fmt.Sprintf("challenge mismatch (expected %s, got %q)", challenge.Expected, respText)))
return res
}
return finalizeOperationalOrDegraded(res, latency, latencyMs)
}
// finalizeOperationalOrDegraded 负责走到最后一步的 operational/degraded 判定。
// 拆出来是为了让 runCheckForModel 不超过 30 行。
func finalizeOperationalOrDegraded(res *CheckResult, latency time.Duration, latencyMs int) *CheckResult {
if latency >= monitorDegradedThreshold {
res.Status = MonitorStatusDegraded
res.Message = truncateMessage(fmt.Sprintf("slow response: %dms", latencyMs))
return res
}
res.Status = MonitorStatusOperational
return res
}
// bodyOverrideMode 归一取 opts.BodyOverrideMode,nil opts / 空串都视为 off。
func bodyOverrideMode(opts *CheckOptions) string {
if opts == nil || opts.BodyOverrideMode == "" {
return MonitorBodyOverrideModeOff
}
return opts.BodyOverrideMode
}
// pingEndpointOrigin 对 endpoint 的 origin (scheme://host) 发起 HEAD 请求,返回耗时。
// 失败时返回 nil(不影响主状态判定)。
func pingEndpointOrigin(ctx context.Context, endpoint string) *int {
......@@ -183,29 +223,109 @@ func isSupportedProvider(p string) bool {
}
// callProvider 通过 providerAdapters 分发到具体实现。
// opts 承载用户的自定义 headers / body 覆盖(可为 nil)。
//
// 返回值:
// - extractedText: 按 textPath 抽出的成功文本,仅在 status 2xx 时有意义;非 2xx 时通常为空串
// - rawBody: 完整响应体的字符串形式(已被 monitorResponseMaxBytes 截断),用于错误路径保留上游真实回包
// - status: HTTP 状态码
// - err: 网络 / 序列化错误
func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt string) (extractedText, rawBody string, status int, err error) {
func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt string, opts *CheckOptions) (extractedText, rawBody string, status int, err error) {
adapter, ok := providerAdapters[provider]
if !ok {
return "", "", 0, fmt.Errorf("unsupported provider %q", provider)
}
body, err := adapter.buildBody(model, prompt)
body, err := buildRequestBody(adapter, provider, model, prompt, opts)
if err != nil {
return "", "", 0, fmt.Errorf("marshal body: %w", err)
return "", "", 0, err
}
headers := mergeHeaders(adapter.buildHeaders(apiKey), opts)
full := joinURL(endpoint, adapter.buildPath(model))
respBytes, status, err := postRawJSON(ctx, full, body, adapter.buildHeaders(apiKey))
respBytes, status, err := postRawJSON(ctx, full, body, headers)
if err != nil {
return "", "", status, err
}
return gjson.GetBytes(respBytes, adapter.textPath).String(), string(respBytes), status, nil
}
// mergeHeaders 把用户自定义 headers 合并到 adapter 默认 headers 上。
// 用户值覆盖默认;命中黑名单(hop-by-hop / 由 http.Client 自管的)的 key 静默丢弃。
func mergeHeaders(base map[string]string, opts *CheckOptions) map[string]string {
if opts == nil || len(opts.ExtraHeaders) == 0 {
return base
}
out := make(map[string]string, len(base)+len(opts.ExtraHeaders))
for k, v := range base {
out[k] = v
}
for k, v := range opts.ExtraHeaders {
if IsForbiddenHeaderName(k) {
continue
}
out[k] = v
}
return out
}
// buildRequestBody 根据 body_override_mode 构造请求 body。
//
// - off: adapter 默认 body
// - merge: adapter 默认 body 与 BodyOverride 浅合并;BodyOverride 中命中
// bodyMergeKeyDenyList[provider] 的 key 会被静默丢弃,避免破坏 challenge / model 路由
// - replace: 直接 marshal BodyOverride 作为完整 body
//
// 任何 mode 返回的 []byte 都已经是合法 JSON,可直接送入 postRawJSON。
func buildRequestBody(adapter providerAdapter, provider, model, prompt string, opts *CheckOptions) ([]byte, error) {
mode := bodyOverrideMode(opts)
if mode == MonitorBodyOverrideModeReplace {
if opts == nil || len(opts.BodyOverride) == 0 {
return nil, fmt.Errorf("replace mode: body_override is empty")
}
body, err := json.Marshal(opts.BodyOverride)
if err != nil {
return nil, fmt.Errorf("marshal body_override (replace): %w", err)
}
return body, nil
}
defaultBody, err := adapter.buildBody(model, prompt)
if err != nil {
return nil, fmt.Errorf("marshal default body: %w", err)
}
if mode != MonitorBodyOverrideModeMerge || opts == nil || len(opts.BodyOverride) == 0 {
return defaultBody, nil
}
var defaultMap map[string]any
if err := json.Unmarshal(defaultBody, &defaultMap); err != nil {
return nil, fmt.Errorf("unmarshal default body for merge: %w", err)
}
deny := bodyMergeKeyDenyList[provider]
for k, v := range opts.BodyOverride {
if deny[k] {
continue
}
defaultMap[k] = v
}
merged, err := json.Marshal(defaultMap)
if err != nil {
return nil, fmt.Errorf("marshal merged body: %w", err)
}
return merged, nil
}
// bodyMergeKeyDenyList 在 merge 模式下,禁止用户覆盖这些 provider-specific 的关键字段。
// 思路抄 check-cx 的 EXCLUDED_METADATA_KEYS:保护 challenge / model 路由不被用户误伤。
// 用户想动这些字段就用 replace 模式(已知会跳 challenge 校验)。
//
//nolint:gochecknoglobals // 静态查表,初始化后不变。
var bodyMergeKeyDenyList = map[string]map[string]bool{
MonitorProviderOpenAI: {"model": true, "messages": true, "stream": true},
MonitorProviderAnthropic: {"model": true, "messages": true},
MonitorProviderGemini: {"contents": true},
}
// postRawJSON 发送 POST + 已序列化好的 JSON 字节,限制响应体大小,返回响应字节、HTTP status、错误。
// adapter 自行 marshal 是为了精确控制字段顺序与类型,所以这里直接收 []byte 而不是 any。
func postRawJSON(ctx context.Context, fullURL string, payload []byte, headers map[string]string) ([]byte, int, error) {
......
//go:build unit
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// swapMonitorHTTPClient 临时替换 monitorHTTPClient 为不带 SSRF 校验的普通 client,
// 让 httptest (127.0.0.1) 能连通。测试结束后恢复。
func swapMonitorHTTPClient(t *testing.T) {
t.Helper()
orig := monitorHTTPClient
monitorHTTPClient = &http.Client{Timeout: 5 * time.Second}
t.Cleanup(func() { monitorHTTPClient = orig })
}
// captureHandler 把每次收到的请求 body 和 headers 存起来,测试断言用。
type captureHandler struct {
lastBody map[string]any
lastHeaders http.Header
respondText string // 写到 Anthropic content[0].text 里(校验用)
status int
}
func (h *captureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.lastHeaders = r.Header.Clone()
defer func() { _ = r.Body.Close() }()
var parsed map[string]any
_ = json.NewDecoder(r.Body).Decode(&parsed)
h.lastBody = parsed
if h.status == 0 {
h.status = 200
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(h.status)
// 构造 Anthropic 格式的响应:content[0].text = h.respondText
_ = json.NewEncoder(w).Encode(map[string]any{
"content": []map[string]any{
{"type": "text", "text": h.respondText},
},
})
}
func setupFakeAnthropic(t *testing.T, handler *captureHandler) string {
t.Helper()
swapMonitorHTTPClient(t)
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return srv.URL
}
func TestRunCheckForModel_OffMode_PreservesDefaultBody(t *testing.T) {
h := &captureHandler{respondText: "the answer is 42"}
endpoint := setupFakeAnthropic(t, h)
// 跑一次 off 模式(opts=nil),确认默认 body 行为未变
_ = runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", nil)
if h.lastBody["model"] != "claude-x" {
t.Errorf("default body should contain model=claude-x, got %v", h.lastBody["model"])
}
if _, ok := h.lastBody["messages"]; !ok {
t.Error("default body should contain messages")
}
if h.lastHeaders.Get("x-api-key") != "sk-fake" {
t.Errorf("expected adapter's x-api-key header, got %q", h.lastHeaders.Get("x-api-key"))
}
}
func TestRunCheckForModel_MergeMode_UserFieldsWinButDenyListProtects(t *testing.T) {
h := &captureHandler{respondText: "the answer is 42"}
endpoint := setupFakeAnthropic(t, h)
opts := &CheckOptions{
BodyOverrideMode: MonitorBodyOverrideModeMerge,
BodyOverride: map[string]any{
"system": "You are Claude Code...",
"max_tokens": float64(999), // 应该覆盖默认 50
"model": "hacked-model", // 应该被黑名单挡住,保留原 model
"messages": []any{}, // 同上,被挡
},
ExtraHeaders: map[string]string{
"User-Agent": "claude-cli/1.0",
"Content-Length": "999", // 黑名单
"x-custom": "ok",
},
}
_ = runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", opts)
if h.lastBody["system"] != "You are Claude Code..." {
t.Errorf("merge mode should inject system, got %v", h.lastBody["system"])
}
// max_tokens 覆盖生效
if mt, ok := h.lastBody["max_tokens"].(float64); !ok || mt != 999 {
t.Errorf("merge mode should override max_tokens to 999, got %v", h.lastBody["max_tokens"])
}
// model 在黑名单 — 应该保留默认值
if h.lastBody["model"] != "claude-x" {
t.Errorf("model should be protected by deny list, got %v", h.lastBody["model"])
}
// messages 在黑名单 — 应该保留默认值(非空)
msgs, _ := h.lastBody["messages"].([]any)
if len(msgs) == 0 {
t.Error("messages should be protected by deny list (kept default, non-empty)")
}
// header 合并
if h.lastHeaders.Get("User-Agent") != "claude-cli/1.0" {
t.Errorf("extra User-Agent should override, got %q", h.lastHeaders.Get("User-Agent"))
}
if h.lastHeaders.Get("x-custom") != "ok" {
t.Errorf("extra custom header should be present, got %q", h.lastHeaders.Get("x-custom"))
}
// Content-Length 黑名单:会被 net/http 自动重算,但不应由用户的 "999" 决定。
// 我们无法直接断言丢弃(http.Client 总会填上),只断言请求成功即可。
}
func TestRunCheckForModel_ReplaceMode_FullBodyUsedAndChallengeSkipped(t *testing.T) {
// replace 模式下我们的 body 完全自定义,challenge 数学题不会出现在请求里,
// 上游也不会回正确答案 — 但只要 2xx + 响应文本非空,就算 operational
h := &captureHandler{respondText: "any non-empty text"}
endpoint := setupFakeAnthropic(t, h)
userBody := map[string]any{
"model": "user-forced-model",
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
"max_tokens": float64(10),
"system": "You are someone else",
}
opts := &CheckOptions{
BodyOverrideMode: MonitorBodyOverrideModeReplace,
BodyOverride: userBody,
}
res := runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", opts)
// 请求 body = 用户提供的原样
if h.lastBody["model"] != "user-forced-model" {
t.Errorf("replace mode should use user's model, got %v", h.lastBody["model"])
}
if h.lastBody["system"] != "You are someone else" {
t.Errorf("replace mode should use user's system, got %v", h.lastBody["system"])
}
// challenge 虽然没命中,但由于 replace 模式跳过 challenge 校验 + 响应非空 → operational
if res.Status != MonitorStatusOperational {
t.Errorf("replace mode with 2xx + non-empty text should be operational, got status=%s message=%q",
res.Status, res.Message)
}
}
func TestRunCheckForModel_ReplaceMode_EmptyResponseIsFailed(t *testing.T) {
h := &captureHandler{respondText: ""} // 上游 200 但 content[0].text 为空
endpoint := setupFakeAnthropic(t, h)
opts := &CheckOptions{
BodyOverrideMode: MonitorBodyOverrideModeReplace,
BodyOverride: map[string]any{"model": "x", "messages": []any{}},
}
res := runCheckForModel(context.Background(), MonitorProviderAnthropic, endpoint, "sk-fake", "claude-x", opts)
if res.Status != MonitorStatusFailed {
t.Errorf("replace mode with empty text should be failed, got status=%s", res.Status)
}
if !strings.Contains(res.Message, "replace-mode") {
t.Errorf("failure message should hint replace-mode, got %q", res.Message)
}
}
......@@ -104,21 +104,31 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea
if err := validateCreateParams(p); err != nil {
return nil, err
}
if err := validateBodyModeParams(p.BodyOverrideMode, p.BodyOverride); err != nil {
return nil, err
}
if err := validateExtraHeaders(p.ExtraHeaders); err != nil {
return nil, err
}
encrypted, err := s.encryptor.Encrypt(p.APIKey)
if err != nil {
return nil, fmt.Errorf("encrypt api key: %w", err)
}
m := &ChannelMonitor{
Name: strings.TrimSpace(p.Name),
Provider: p.Provider,
Endpoint: normalizeEndpoint(p.Endpoint),
APIKey: encrypted, // 注意:传入 repository 时该字段为密文
PrimaryModel: strings.TrimSpace(p.PrimaryModel),
ExtraModels: normalizeModels(p.ExtraModels),
GroupName: strings.TrimSpace(p.GroupName),
Enabled: p.Enabled,
IntervalSeconds: p.IntervalSeconds,
CreatedBy: p.CreatedBy,
Name: strings.TrimSpace(p.Name),
Provider: p.Provider,
Endpoint: normalizeEndpoint(p.Endpoint),
APIKey: encrypted, // 注意:传入 repository 时该字段为密文
PrimaryModel: strings.TrimSpace(p.PrimaryModel),
ExtraModels: normalizeModels(p.ExtraModels),
GroupName: strings.TrimSpace(p.GroupName),
Enabled: p.Enabled,
IntervalSeconds: p.IntervalSeconds,
CreatedBy: p.CreatedBy,
TemplateID: p.TemplateID,
ExtraHeaders: emptyHeadersIfNil(p.ExtraHeaders),
BodyOverrideMode: defaultBodyMode(p.BodyOverrideMode),
BodyOverride: p.BodyOverride,
}
if err := s.repo.Create(ctx, m); err != nil {
return nil, fmt.Errorf("create channel monitor: %w", err)
......@@ -272,12 +282,19 @@ func (s *ChannelMonitorService) runChecksConcurrent(ctx context.Context, m *Chan
// ping 共享一次,所有模型记录同一个 ping 延迟。
pingMs := pingEndpointOrigin(ctx, m.Endpoint)
// 所有模型共用同一份 CheckOptions(来自监控的快照字段)。
opts := &CheckOptions{
ExtraHeaders: m.ExtraHeaders,
BodyOverrideMode: m.BodyOverrideMode,
BodyOverride: m.BodyOverride,
}
var eg errgroup.Group
var mu sync.Mutex
for i, model := range models {
i, model := i, model
eg.Go(func() error {
r := runCheckForModel(ctx, m.Provider, m.Endpoint, m.APIKey, model)
r := runCheckForModel(ctx, m.Provider, m.Endpoint, m.APIKey, model, opts)
r.PingLatencyMs = pingMs
mu.Lock()
results[i] = r
......@@ -476,5 +493,38 @@ func applyMonitorUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams)
}
existing.IntervalSeconds = *p.IntervalSeconds
}
return applyMonitorAdvancedUpdate(existing, p)
}
// applyMonitorAdvancedUpdate 处理自定义请求快照相关字段,从 applyMonitorUpdate 拆出避免过长。
func applyMonitorAdvancedUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams) error {
if p.ClearTemplate {
existing.TemplateID = nil
} else if p.TemplateID != nil {
id := *p.TemplateID
existing.TemplateID = &id
}
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 p.BodyOverrideMode != nil || p.BodyOverride != nil {
if err := validateBodyModeParams(newMode, newBody); err != nil {
return err
}
existing.BodyOverrideMode = defaultBodyMode(newMode)
existing.BodyOverride = newBody
}
return nil
}
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
}
package service
import (
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"time"
)
// ChannelMonitorRequestTemplate 请求模板(service 层模型)。
// 作用:把一组可复用的 headers + 可选 body 覆盖配置抽出来管理,
// 被监控「应用」时以快照方式拷贝到监控本身的同名字段。
type ChannelMonitorRequestTemplate struct {
ID int64
Name string
Provider string
Description string
ExtraHeaders map[string]string
BodyOverrideMode string
BodyOverride map[string]any
CreatedAt time.Time
UpdatedAt time.Time
}
// ChannelMonitorRequestTemplateListParams 列表过滤。
type ChannelMonitorRequestTemplateListParams struct {
Provider string // 空 = 全部;非空则按 provider 过滤
}
// ChannelMonitorRequestTemplateCreateParams 创建参数。
type ChannelMonitorRequestTemplateCreateParams struct {
Name string
Provider string
Description string
ExtraHeaders map[string]string
BodyOverrideMode string
BodyOverride map[string]any
}
// ChannelMonitorRequestTemplateUpdateParams 更新参数(指针字段 = 不修改)。
// 注意 Provider 不可修改:改 provider 会让已关联监控的 body 黑名单语义错乱。
type ChannelMonitorRequestTemplateUpdateParams struct {
Name *string
Description *string
ExtraHeaders *map[string]string
BodyOverrideMode *string
BodyOverride *map[string]any
}
// 模板相关错误(命名与现有 ErrChannelMonitor* 风格保持一致)。
var (
ErrChannelMonitorTemplateNotFound = infraerrors.NotFound(
"CHANNEL_MONITOR_TEMPLATE_NOT_FOUND", "channel monitor request template not found",
)
ErrChannelMonitorTemplateInvalidProvider = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_INVALID_PROVIDER", "template provider must be one of openai/anthropic/gemini",
)
ErrChannelMonitorTemplateMissingName = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_MISSING_NAME", "template name is required",
)
ErrChannelMonitorTemplateInvalidBodyMode = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_INVALID_BODY_MODE", "body_override_mode must be one of off/merge/replace",
)
ErrChannelMonitorTemplateBodyRequired = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_BODY_REQUIRED", "body_override is required when body_override_mode is merge or replace",
)
ErrChannelMonitorTemplateHeaderForbidden = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_HEADER_FORBIDDEN", "header name is forbidden (hop-by-hop or computed by HTTP client)",
)
ErrChannelMonitorTemplateHeaderInvalidName = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_HEADER_INVALID_NAME", "header name contains invalid characters",
)
ErrChannelMonitorTemplateProviderMismatch = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_PROVIDER_MISMATCH", "monitor provider does not match template provider",
)
)
......@@ -2,6 +2,19 @@ package service
import "time"
// MonitorBodyOverrideMode 自定义请求体处理模式。
//
// - off 使用 adapter 默认 body(忽略 BodyOverride)
// - merge adapter 默认 body 与 BodyOverride 浅合并(用户优先;
// model/messages/contents 等关键字段在 checker 黑名单内会被静默丢弃)
// - replace 完全用 BodyOverride 作为 body;跳过 challenge 校验,
// 改成 HTTP 2xx + 响应非空即视为可用(用户负责构造 body)
const (
MonitorBodyOverrideModeOff = "off"
MonitorBodyOverrideModeMerge = "merge"
MonitorBodyOverrideModeReplace = "replace"
)
// ChannelMonitor 渠道监控配置(service 层模型,不直接暴露 ent 类型)。
type ChannelMonitor struct {
ID int64
......@@ -19,6 +32,12 @@ type ChannelMonitor struct {
CreatedAt time.Time
UpdatedAt time.Time
// 请求自定义快照(来自模板拷贝 or 用户手填,运行时直接读取)
TemplateID *int64 // 仅用于 UI 分组 + 一键应用,运行时不用
ExtraHeaders map[string]string // 与 adapter 默认 headers 合并,用户优先
BodyOverrideMode string // off / merge / replace
BodyOverride map[string]any // 仅 mode != off 时使用
// APIKeyDecryptFailed 表示 APIKey 字段无法解密(密钥不一致或损坏)。
// 此时 APIKey 为空字符串,runner / RunCheck 必须跳过该监控并提示重填。
APIKeyDecryptFailed bool
......@@ -35,16 +54,20 @@ type ChannelMonitorListParams struct {
// ChannelMonitorCreateParams 创建参数。
type ChannelMonitorCreateParams struct {
Name string
Provider string
Endpoint string
APIKey string
PrimaryModel string
ExtraModels []string
GroupName string
Enabled bool
IntervalSeconds int
CreatedBy int64
Name string
Provider string
Endpoint string
APIKey string
PrimaryModel string
ExtraModels []string
GroupName string
Enabled bool
IntervalSeconds int
CreatedBy int64
TemplateID *int64
ExtraHeaders map[string]string
BodyOverrideMode string
BodyOverride map[string]any
}
// ChannelMonitorUpdateParams 更新参数(指针字段表示"未提供则不更新")。
......@@ -58,6 +81,14 @@ type ChannelMonitorUpdateParams struct {
GroupName *string
Enabled *bool
IntervalSeconds *int
// 自定义快照字段:指针为 nil 表示不更新,非 nil 覆盖
// TemplateID *(*int64):用 ** 表达三态:nil=不更新;&nil=清空;&&id=设为 id。
// 简化处理:用 ClearTemplate 显式标志 + TemplateID(普通指针)
TemplateID *int64
ClearTemplate bool // true 时无视 TemplateID,把监控的 template_id 置空
ExtraHeaders *map[string]string
BodyOverrideMode *string
BodyOverride *map[string]any
}
// CheckResult 单个模型一次检测的结果。
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment