channel_monitor_service.go 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008
  1. package service
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/json"
  6. "fmt"
  7. "log/slog"
  8. "strconv"
  9. "strings"
  10. "sync"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/domain"
  13. "golang.org/x/sync/errgroup"
  14. )
  15. // ChannelMonitorRepository 渠道监控数据访问接口。
  16. // 入参/返回的指针类型均使用 service 包的 ChannelMonitor 模型,
  17. // repository 实现负责与 ent 模型互转,并保持 api_key_encrypted 字段为密文。
  18. type ChannelMonitorRepository interface {
  19. // CRUD
  20. Create(ctx context.Context, m *ChannelMonitor) error
  21. GetByID(ctx context.Context, id int64) (*ChannelMonitor, error)
  22. Update(ctx context.Context, m *ChannelMonitor) error
  23. Delete(ctx context.Context, id int64) error
  24. List(ctx context.Context, params ChannelMonitorListParams) ([]*ChannelMonitor, int64, error)
  25. FindByDuplicateOperationID(ctx context.Context, operationID string) (*ChannelMonitor, error)
  26. // 调度器辅助
  27. ListEnabled(ctx context.Context) ([]*ChannelMonitor, error)
  28. MarkChecked(ctx context.Context, id int64, checkedAt time.Time) error
  29. InsertHistoryBatch(ctx context.Context, rows []*ChannelMonitorHistoryRow) error
  30. DeleteHistoryBefore(ctx context.Context, before time.Time) (int64, error)
  31. // 历史记录
  32. ListHistory(ctx context.Context, monitorID int64, model string, limit int) ([]*ChannelMonitorHistoryEntry, error)
  33. // 用户视图聚合
  34. ListLatestPerModel(ctx context.Context, monitorID int64) ([]*ChannelMonitorLatest, error)
  35. ComputeAvailability(ctx context.Context, monitorID int64, windowDays int) ([]*ChannelMonitorAvailability, error)
  36. // 批量聚合(admin/user list 用,避免 N+1)
  37. ListLatestForMonitorIDs(ctx context.Context, ids []int64) (map[int64][]*ChannelMonitorLatest, error)
  38. ComputeAvailabilityForMonitors(ctx context.Context, ids []int64, windowDays int) (map[int64][]*ChannelMonitorAvailability, error)
  39. // ListRecentHistoryForMonitors 批量取多个 monitor 各自主模型(primaryModels[monitorID])最近 perMonitorLimit 条历史。
  40. // 返回的 entry 已按 checked_at DESC 排序(最新在前),不含 message 字段。
  41. ListRecentHistoryForMonitors(ctx context.Context, ids []int64, primaryModels map[int64]string, perMonitorLimit int) (map[int64][]*ChannelMonitorHistoryEntry, error)
  42. // ---------- 聚合维护(OpsCleanupService 调用) ----------
  43. // UpsertDailyRollupsFor 把 targetDate 当天的明细按 (monitor_id, model, bucket_date)
  44. // 聚合到 channel_monitor_daily_rollups。targetDate 会被截断到日期;
  45. // 用 ON CONFLICT DO UPDATE 实现幂等回填,返回 upsert 影响的行数。
  46. UpsertDailyRollupsFor(ctx context.Context, targetDate time.Time) (int64, error)
  47. // DeleteRollupsBefore 软删 bucket_date < beforeDate 的聚合行,返回删除行数。
  48. DeleteRollupsBefore(ctx context.Context, beforeDate time.Time) (int64, error)
  49. // LoadAggregationWatermark 读 watermark(id=1)。
  50. // 返回 nil 表示从未聚合过;watermark 表本身预期已存在单行(migration 110 写入)。
  51. LoadAggregationWatermark(ctx context.Context) (*time.Time, error)
  52. // UpdateAggregationWatermark 写 watermark(UPSERT 到 id=1)。
  53. UpdateAggregationWatermark(ctx context.Context, date time.Time) error
  54. }
  55. // channelMonitorRuntimeReader is the optional settings view used to gate V1
  56. // active probes by channel_monitor_enabled + channel_monitor_mode.
  57. type channelMonitorRuntimeReader interface {
  58. GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime
  59. }
  60. // ChannelMonitorService 渠道监控管理服务。
  61. type ChannelMonitorService struct {
  62. repo ChannelMonitorRepository
  63. encryptor SecretEncryptor
  64. // settings is optional; when nil, RunCheck fails closed for active probes
  65. // (mode defaults to v2 / retired) so tests without settings never hit upstream.
  66. settings channelMonitorRuntimeReader
  67. // scheduler 由 wire 通过 SetScheduler 注入;CRUD 后调用对应钩子即时同步任务。
  68. // 测试或未注入场景下保持 nil,所有钩子调用变为 no-op。
  69. scheduler MonitorScheduler
  70. // quotaFetcher 由 wire 通过 SetQuotaFetcher 注入(accountUsage/CN 服务在本服务
  71. // 之后构造,构造参数注入会破坏既有依赖顺序)。nil 时 fail-closed:
  72. // 配额模式的检测产出「未配置」错误快照,Create/Update 关联账号直接报错。
  73. quotaFetcher *ChannelMonitorQuotaFetcher
  74. }
  75. const maxChannelMonitorNameRunes = 100
  76. // ChannelMonitorDuplicateOperationIDMetadataKey is stored in the existing
  77. // extra_headers JSON column to avoid a schema migration. The colon makes it an
  78. // invalid HTTP header name, and repository adapters remove it before exposing
  79. // ExtraHeaders to the service layer.
  80. const ChannelMonitorDuplicateOperationIDMetadataKey = "sub2api:duplicate_operation_id"
  81. // NewChannelMonitorService 创建渠道监控服务实例。
  82. func NewChannelMonitorService(repo ChannelMonitorRepository, encryptor SecretEncryptor) *ChannelMonitorService {
  83. return &ChannelMonitorService{repo: repo, encryptor: encryptor}
  84. }
  85. // SetRuntimeReader injects the settings reader used to gate active probes.
  86. // Optional: when unset, active probes are treated as mode=v2 (retired).
  87. func (s *ChannelMonitorService) SetRuntimeReader(r channelMonitorRuntimeReader) {
  88. if s == nil {
  89. return
  90. }
  91. s.settings = r
  92. }
  93. func (s *ChannelMonitorService) probeRuntime(ctx context.Context) ChannelMonitorRuntime {
  94. if s == nil || s.settings == nil {
  95. return ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV2}
  96. }
  97. return s.settings.GetChannelMonitorRuntime(ctx)
  98. }
  99. // ---------- CRUD ----------
  100. // List 列表查询(支持 provider/enabled/search 过滤 + 分页)。
  101. // 返回的 ChannelMonitor.APIKey 已解密为明文,handler 层负责脱敏。
  102. func (s *ChannelMonitorService) List(ctx context.Context, params ChannelMonitorListParams) ([]*ChannelMonitor, int64, error) {
  103. if params.Page < 1 {
  104. params.Page = 1
  105. }
  106. if params.PageSize < 1 || params.PageSize > 200 {
  107. params.PageSize = 20
  108. }
  109. items, total, err := s.repo.List(ctx, params)
  110. if err != nil {
  111. return nil, 0, fmt.Errorf("list channel monitors: %w", err)
  112. }
  113. for _, it := range items {
  114. s.decryptInPlace(it)
  115. }
  116. return items, total, nil
  117. }
  118. // Get 查询单个监控(解密 API Key)。
  119. func (s *ChannelMonitorService) Get(ctx context.Context, id int64) (*ChannelMonitor, error) {
  120. m, err := s.repo.GetByID(ctx, id)
  121. if err != nil {
  122. return nil, err
  123. }
  124. s.decryptInPlace(m)
  125. return m, nil
  126. }
  127. // Create 创建监控(内部加密 api_key)。
  128. func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCreateParams) (*ChannelMonitor, error) {
  129. if err := validateCreateParams(p); err != nil {
  130. return nil, err
  131. }
  132. if err := validateBodyModeForProtocol(p.Provider, p.APIMode, p.BodyOverrideMode, p.BodyOverride); err != nil {
  133. return nil, err
  134. }
  135. if err := validateExtraHeaders(p.ExtraHeaders); err != nil {
  136. return nil, err
  137. }
  138. if err := s.validateLinkedAccount(ctx, p.Provider, p.AccountID); err != nil {
  139. return nil, err
  140. }
  141. checkMode := defaultCheckMode(p.CheckMode)
  142. encrypted, err := s.encryptor.Encrypt(p.APIKey)
  143. if err != nil {
  144. return nil, fmt.Errorf("encrypt api key: %w", err)
  145. }
  146. m := &ChannelMonitor{
  147. Name: strings.TrimSpace(p.Name),
  148. Provider: p.Provider,
  149. APIMode: defaultAPIMode(p.APIMode),
  150. Endpoint: normalizeEndpoint(p.Endpoint),
  151. APIKey: encrypted, // 注意:传入 repository 时该字段为密文
  152. PrimaryModel: normalizeMonitorPrimaryModel(p.Provider, checkMode, p.PrimaryModel),
  153. ExtraModels: normalizeModels(p.ExtraModels),
  154. GroupName: strings.TrimSpace(p.GroupName),
  155. Enabled: p.Enabled,
  156. IntervalSeconds: p.IntervalSeconds,
  157. JitterSeconds: p.JitterSeconds,
  158. CreatedBy: p.CreatedBy,
  159. TemplateID: p.TemplateID,
  160. ExtraHeaders: emptyHeadersIfNil(p.ExtraHeaders),
  161. BodyOverrideMode: defaultBodyMode(p.BodyOverrideMode),
  162. BodyOverride: p.BodyOverride,
  163. CheckMode: checkMode,
  164. AccountID: cloneInt64Pointer(p.AccountID),
  165. }
  166. if err := s.repo.Create(ctx, m); err != nil {
  167. return nil, fmt.Errorf("create channel monitor: %w", err)
  168. }
  169. // 不再调 s.Get 重走解密链:已知刚加密的明文,直接构造响应。
  170. // 这样可避免 SecretEncryptor 解密失败时 APIKey 被静默清空的问题(见 Fix 4)。
  171. m.APIKey = strings.TrimSpace(p.APIKey)
  172. if s.scheduler != nil {
  173. s.scheduler.Schedule(m)
  174. }
  175. return m, nil
  176. }
  177. // Duplicate creates an independent, disabled copy of an existing monitor.
  178. // The API key stays server-side: it is decrypted only long enough to encrypt a
  179. // fresh ciphertext for the new row. Runtime state and history are not copied.
  180. func (s *ChannelMonitorService) Duplicate(
  181. ctx context.Context,
  182. id, createdBy int64,
  183. actorScope, operationKey string,
  184. ) (*ChannelMonitor, error) {
  185. operationID := duplicateChannelMonitorOperationID(id, actorScope, operationKey)
  186. existing, err := s.RecoverDuplicate(ctx, id, actorScope, operationKey)
  187. if err != nil {
  188. return nil, err
  189. }
  190. if existing != nil {
  191. return existing, nil
  192. }
  193. source, err := s.repo.GetByID(ctx, id)
  194. if err != nil {
  195. return nil, err
  196. }
  197. plainAPIKey, err := s.decryptAPIKeyForDuplicate(source)
  198. if err != nil {
  199. return nil, err
  200. }
  201. encryptedAPIKey, err := s.encryptor.Encrypt(plainAPIKey)
  202. if err != nil {
  203. return nil, fmt.Errorf("encrypt duplicate channel monitor api key: %w", err)
  204. }
  205. bodyOverride, err := cloneChannelMonitorJSONMap(source.BodyOverride)
  206. if err != nil {
  207. return nil, fmt.Errorf("clone duplicate channel monitor body override: %w", err)
  208. }
  209. duplicate := &ChannelMonitor{
  210. Name: duplicateChannelMonitorName(source.Name),
  211. Provider: source.Provider,
  212. APIMode: source.APIMode,
  213. Endpoint: source.Endpoint,
  214. APIKey: encryptedAPIKey,
  215. PrimaryModel: source.PrimaryModel,
  216. ExtraModels: append([]string{}, source.ExtraModels...),
  217. GroupName: source.GroupName,
  218. Enabled: false,
  219. IntervalSeconds: source.IntervalSeconds,
  220. JitterSeconds: source.JitterSeconds,
  221. CreatedBy: createdBy,
  222. TemplateID: cloneInt64Pointer(source.TemplateID),
  223. ExtraHeaders: cloneChannelMonitorHeaders(source.ExtraHeaders),
  224. BodyOverrideMode: source.BodyOverrideMode,
  225. BodyOverride: bodyOverride,
  226. CheckMode: defaultCheckMode(source.CheckMode),
  227. AccountID: cloneInt64Pointer(source.AccountID),
  228. DuplicateOperationID: operationID,
  229. }
  230. if err := s.repo.Create(ctx, duplicate); err != nil {
  231. return nil, fmt.Errorf("duplicate channel monitor: %w", err)
  232. }
  233. // Match Create/Update response semantics: repository receives ciphertext,
  234. // while handlers receive plaintext only so they can return the masked form.
  235. duplicate.APIKey = plainAPIKey
  236. return duplicate, nil
  237. }
  238. // RecoverDuplicate performs a read-only lookup for a duplicate that was
  239. // already committed for the same actor, source monitor, and idempotency key.
  240. // It deliberately never repeats the create side effect.
  241. func (s *ChannelMonitorService) RecoverDuplicate(
  242. ctx context.Context,
  243. id int64,
  244. actorScope, operationKey string,
  245. ) (*ChannelMonitor, error) {
  246. operationID := duplicateChannelMonitorOperationID(id, actorScope, operationKey)
  247. if operationID == "" {
  248. return nil, nil
  249. }
  250. monitor, err := s.repo.FindByDuplicateOperationID(ctx, operationID)
  251. if err != nil {
  252. return nil, fmt.Errorf("find duplicate channel monitor operation: %w", err)
  253. }
  254. if monitor == nil {
  255. return nil, nil
  256. }
  257. s.decryptInPlace(monitor)
  258. return monitor, nil
  259. }
  260. func duplicateChannelMonitorOperationID(sourceID int64, actorScope, operationKey string) string {
  261. operationKey = strings.TrimSpace(operationKey)
  262. if operationKey == "" {
  263. return ""
  264. }
  265. actorScope = strings.TrimSpace(actorScope)
  266. if actorScope == "" {
  267. actorScope = "admin:0"
  268. }
  269. payload := "admin.channel_monitors.duplicate\x00" + actorScope + "\x00" + strconv.FormatInt(sourceID, 10) + "\x00" + operationKey
  270. digest := sha256.Sum256([]byte(payload))
  271. return fmt.Sprintf("%x", digest)
  272. }
  273. func (s *ChannelMonitorService) decryptAPIKeyForDuplicate(source *ChannelMonitor) (string, error) {
  274. if source == nil || strings.TrimSpace(source.APIKey) == "" {
  275. return "", ErrChannelMonitorAPIKeyDecryptFailed
  276. }
  277. plain, err := s.encryptor.Decrypt(source.APIKey)
  278. if err != nil {
  279. slog.Warn("channel_monitor: decrypt api key for duplicate failed",
  280. "monitor_id", source.ID, "error", err)
  281. return "", ErrChannelMonitorAPIKeyDecryptFailed
  282. }
  283. // quota 模式明文为空串是合法状态(api_key_encrypted 存的是加密空串):
  284. // 重加密空串即可。若在此报错,克隆出的配额监控会被 runner 当作
  285. // 解密失败而 Unschedule,静默停摆。
  286. if strings.TrimSpace(plain) == "" {
  287. if monitorCheckModeUsesQuota(defaultCheckMode(source.CheckMode)) {
  288. return "", nil
  289. }
  290. slog.Warn("channel_monitor: decrypted api key for duplicate is empty",
  291. "monitor_id", source.ID)
  292. return "", ErrChannelMonitorAPIKeyDecryptFailed
  293. }
  294. return plain, nil
  295. }
  296. func duplicateChannelMonitorName(sourceName string) string {
  297. const suffix = " (Copy)"
  298. nameRunes := []rune(strings.TrimSpace(sourceName))
  299. maxBaseRunes := maxChannelMonitorNameRunes - len([]rune(suffix))
  300. if len(nameRunes) > maxBaseRunes {
  301. nameRunes = nameRunes[:maxBaseRunes]
  302. }
  303. return string(nameRunes) + suffix
  304. }
  305. func cloneInt64Pointer(value *int64) *int64 {
  306. if value == nil {
  307. return nil
  308. }
  309. cloned := *value
  310. return &cloned
  311. }
  312. func cloneChannelMonitorHeaders(source map[string]string) map[string]string {
  313. if source == nil {
  314. return map[string]string{}
  315. }
  316. cloned := make(map[string]string, len(source))
  317. for key, value := range source {
  318. cloned[key] = value
  319. }
  320. return cloned
  321. }
  322. func cloneChannelMonitorJSONMap(source map[string]any) (map[string]any, error) {
  323. if source == nil {
  324. return nil, nil
  325. }
  326. payload, err := json.Marshal(source)
  327. if err != nil {
  328. return nil, err
  329. }
  330. cloned := make(map[string]any, len(source))
  331. if err := json.Unmarshal(payload, &cloned); err != nil {
  332. return nil, err
  333. }
  334. return cloned, nil
  335. }
  336. // validateCreateParams 把 Create 入参的所有校验聚拢为一个函数,避免 Create 主体超过 30 行。
  337. // 按 check_mode 分支:probe 沿用 endpoint+api_key 必填;quota 只需关联账号;
  338. // quota_probe 两者皆需。
  339. func validateCreateParams(p ChannelMonitorCreateParams) error {
  340. if err := validateProvider(p.Provider); err != nil {
  341. return err
  342. }
  343. checkMode := defaultCheckMode(p.CheckMode)
  344. if err := validateCheckMode(p.Provider, checkMode); err != nil {
  345. return err
  346. }
  347. if err := validateAPIMode(p.Provider, p.APIMode); err != nil {
  348. return err
  349. }
  350. if err := validateInterval(p.IntervalSeconds); err != nil {
  351. return err
  352. }
  353. if err := validateJitter(p.JitterSeconds, p.IntervalSeconds); err != nil {
  354. return err
  355. }
  356. usesQuota := monitorCheckModeUsesQuota(checkMode)
  357. // probe 分支(含 quota_probe 的探活部分)仍需 endpoint + api_key;
  358. // quota 模式 endpoint/api_key 留空,避免要求用户填无意义的占位值。
  359. if checkMode != MonitorCheckModeQuota {
  360. if err := validateEndpoint(p.Endpoint); err != nil {
  361. return err
  362. }
  363. if strings.TrimSpace(p.APIKey) == "" {
  364. return ErrChannelMonitorMissingAPIKey
  365. }
  366. }
  367. if usesQuota && (p.AccountID == nil || *p.AccountID <= 0) {
  368. return ErrChannelMonitorAccountRequired
  369. }
  370. if normalizeMonitorPrimaryModel(p.Provider, checkMode, p.PrimaryModel) == "" {
  371. return ErrChannelMonitorMissingPrimaryModel
  372. }
  373. return nil
  374. }
  375. // validateLinkedAccount 校验关联账号存在、平台与监控 provider 一致、且能充当
  376. // 配额数据源(能力拦截,见 monitorAccountQuotaCapability)。
  377. // fetcher 未注入时 fail-closed(拒绝创建配额监控,而不是创建后静默坏)。
  378. func (s *ChannelMonitorService) validateLinkedAccount(ctx context.Context, provider string, accountID *int64) error {
  379. if accountID == nil || *accountID <= 0 {
  380. return nil
  381. }
  382. if s.quotaFetcher == nil {
  383. return ErrChannelMonitorAccountRequired
  384. }
  385. account, err := s.quotaFetcher.LoadAccount(ctx, *accountID)
  386. if err != nil || account == nil {
  387. return ErrChannelMonitorAccountRequired
  388. }
  389. if account.Platform != provider {
  390. return ErrChannelMonitorProviderIncompatible
  391. }
  392. return monitorAccountQuotaCapability(account)
  393. }
  394. // Update 更新监控。APIKey 字段:nil 或空字符串 = 不修改;非空 = 加密后覆盖。
  395. func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelMonitorUpdateParams) (*ChannelMonitor, error) {
  396. existing, err := s.repo.GetByID(ctx, id)
  397. if err != nil {
  398. return nil, err
  399. }
  400. if err := applyMonitorUpdate(existing, p); err != nil {
  401. return nil, err
  402. }
  403. newPlainAPIKey, apiKeyUpdated, err := s.applyAPIKeyUpdate(existing, p.APIKey)
  404. if err != nil {
  405. return nil, err
  406. }
  407. if err := s.validateProbeAPIKey(existing, newPlainAPIKey); err != nil {
  408. return nil, err
  409. }
  410. if p.Provider != nil || p.CheckMode != nil || p.AccountID != nil {
  411. if err := s.revalidateLinkedAccount(ctx, existing); err != nil {
  412. return nil, err
  413. }
  414. }
  415. if err := s.repo.Update(ctx, existing); err != nil {
  416. return nil, fmt.Errorf("update channel monitor: %w", err)
  417. }
  418. // 不再调 s.Get 重走解密链:避免二次解密带来的"密文被静默清空"风险(与 Create 一致)。
  419. if apiKeyUpdated {
  420. existing.APIKey = newPlainAPIKey
  421. } else {
  422. s.decryptInPlace(existing)
  423. }
  424. if s.scheduler != nil {
  425. // Schedule 内部根据 Enabled 自动选择 Unschedule 或重建任务,
  426. // IntervalSeconds 变化也会被自然吸收(旧 task 取消 + 新 task 用新 interval)。
  427. s.scheduler.Schedule(existing)
  428. }
  429. return existing, nil
  430. }
  431. // validateMonitorModeFields 校验 check_mode 与其它字段的组合约束
  432. // (在 provider/check_mode/account_id/endpoint 全部应用后调用):
  433. // - quota / quota_probe 必须关联账号
  434. // - probe / quota_probe 必须持有 endpoint(探活目标)
  435. func validateMonitorModeFields(m *ChannelMonitor) error {
  436. checkMode := defaultCheckMode(m.CheckMode)
  437. if monitorCheckModeUsesQuota(checkMode) && m.AccountID == nil {
  438. return ErrChannelMonitorAccountRequired
  439. }
  440. if checkMode != MonitorCheckModeQuota && strings.TrimSpace(m.Endpoint) == "" {
  441. return ErrChannelMonitorInvalidEndpoint
  442. }
  443. return nil
  444. }
  445. // validateProbeAPIKey 探活模式(probe / quota_probe)必须持有可用明文 key:
  446. // 存量密文解密为空串(quota 监控切回探活但未重填 key)时拒绝。
  447. // 密文损坏的情况交给既有 APIKeyDecryptFailed 链路(Get/RunCheck 会显式报错)。
  448. func (s *ChannelMonitorService) validateProbeAPIKey(m *ChannelMonitor, newPlainKey string) error {
  449. if defaultCheckMode(m.CheckMode) == MonitorCheckModeQuota {
  450. return nil
  451. }
  452. if strings.TrimSpace(newPlainKey) != "" {
  453. return nil
  454. }
  455. if strings.TrimSpace(m.APIKey) == "" {
  456. return ErrChannelMonitorMissingAPIKey
  457. }
  458. plain, err := s.encryptor.Decrypt(m.APIKey)
  459. if err != nil {
  460. return nil
  461. }
  462. if strings.TrimSpace(plain) == "" {
  463. return ErrChannelMonitorMissingAPIKey
  464. }
  465. return nil
  466. }
  467. // revalidateLinkedAccount 在 provider/check_mode/account_id 任一变化后复核关联账号:
  468. // - 账号已被删除或平台失配:probe 模式自动解绑(静默修复),
  469. // quota 模式显式报错(配额监控必须有可用数据源)
  470. func (s *ChannelMonitorService) revalidateLinkedAccount(ctx context.Context, m *ChannelMonitor) error {
  471. usesQuota := monitorCheckModeUsesQuota(defaultCheckMode(m.CheckMode))
  472. if m.AccountID == nil {
  473. if usesQuota {
  474. return ErrChannelMonitorAccountRequired
  475. }
  476. return nil
  477. }
  478. if s.quotaFetcher == nil {
  479. return ErrChannelMonitorAccountRequired
  480. }
  481. account, err := s.quotaFetcher.LoadAccount(ctx, *m.AccountID)
  482. if err != nil || account == nil {
  483. if usesQuota {
  484. return ErrChannelMonitorAccountRequired
  485. }
  486. m.AccountID = nil
  487. return nil
  488. }
  489. if account.Platform != m.Provider {
  490. if usesQuota {
  491. return ErrChannelMonitorProviderIncompatible
  492. }
  493. m.AccountID = nil
  494. return nil
  495. }
  496. // 能力失配(如 deepseek coding / zhipu payg / API-Key 型海外账号):
  497. // quota 模式显式报错(有该类存量监控时编辑会被拦,出路是换账号或切 probe),
  498. // probe 模式账号无用途,静默解绑。
  499. if err := monitorAccountQuotaCapability(account); err != nil {
  500. if usesQuota {
  501. return err
  502. }
  503. m.AccountID = nil
  504. }
  505. return nil
  506. }
  507. // applyAPIKeyUpdate 处理 Update 中的 APIKey 字段:
  508. // - 入参 raw 为 nil 或空白:不修改 existing.APIKey(仍为密文),返回 updated=false
  509. // - 非空:加密后写入 existing.APIKey;同时把明文返回给调用方,
  510. // 供写库成功后塞回 existing 避免把密文吐回客户端
  511. func (s *ChannelMonitorService) applyAPIKeyUpdate(existing *ChannelMonitor, raw *string) (plain string, updated bool, err error) {
  512. if raw == nil || strings.TrimSpace(*raw) == "" {
  513. return "", false, nil
  514. }
  515. plain = strings.TrimSpace(*raw)
  516. encrypted, encErr := s.encryptor.Encrypt(plain)
  517. if encErr != nil {
  518. return "", false, fmt.Errorf("encrypt api key: %w", encErr)
  519. }
  520. existing.APIKey = encrypted
  521. return plain, true, nil
  522. }
  523. // Delete 删除监控(历史通过外键 CASCADE 自动清理)。
  524. func (s *ChannelMonitorService) Delete(ctx context.Context, id int64) error {
  525. if err := s.repo.Delete(ctx, id); err != nil {
  526. return fmt.Errorf("delete channel monitor: %w", err)
  527. }
  528. if s.scheduler != nil {
  529. s.scheduler.Unschedule(id)
  530. }
  531. return nil
  532. }
  533. // ListHistory 列出某个监控最近的检测历史。
  534. // model 为空表示返回所有模型;limit <= 0 时使用默认值,超过上限会被截断。
  535. func (s *ChannelMonitorService) ListHistory(ctx context.Context, id int64, model string, limit int) ([]*ChannelMonitorHistoryEntry, error) {
  536. if _, err := s.repo.GetByID(ctx, id); err != nil {
  537. return nil, err
  538. }
  539. if limit <= 0 {
  540. limit = MonitorHistoryDefaultLimit
  541. }
  542. if limit > MonitorHistoryMaxLimit {
  543. limit = MonitorHistoryMaxLimit
  544. }
  545. entries, err := s.repo.ListHistory(ctx, id, strings.TrimSpace(model), limit)
  546. if err != nil {
  547. return nil, fmt.Errorf("list history: %w", err)
  548. }
  549. return entries, nil
  550. }
  551. // ---------- 业务 ----------
  552. // RunCheck 同步触发对一个监控的检测:并发跑 primary + extra 模型,
  553. // 写历史记录并更新 last_checked_at。返回每个模型的检测结果。
  554. // 仅当 channel_monitor_enabled=true 且 channel_monitor_mode=v1 时真正探测;
  555. // mode=v2 时返回 ErrChannelMonitorActiveProbesRetired,不产生上游流量。
  556. //
  557. // 按 check_mode 分派:probe(默认,现状探活)/ quota(仅查关联账号配额,
  558. // 零 LLM 成本)/ quota_probe(探活 + 配额快照挂主模型行)。
  559. func (s *ChannelMonitorService) RunCheck(ctx context.Context, id int64) ([]*CheckResult, error) {
  560. rt := s.probeRuntime(ctx)
  561. if !rt.Enabled {
  562. return nil, ErrChannelMonitorDisabled
  563. }
  564. if !rt.ActiveProbesAllowed() {
  565. return nil, ErrChannelMonitorActiveProbesRetired
  566. }
  567. m, err := s.Get(ctx, id) // 已解密 APIKey
  568. if err != nil {
  569. return nil, err
  570. }
  571. checkMode := defaultCheckMode(m.CheckMode)
  572. if checkMode != MonitorCheckModeQuota && m.APIKeyDecryptFailed {
  573. return nil, ErrChannelMonitorAPIKeyDecryptFailed
  574. }
  575. var results []*CheckResult
  576. switch checkMode {
  577. case MonitorCheckModeQuota:
  578. results = s.runQuotaOnlyCheck(ctx, m)
  579. case MonitorCheckModeQuotaProbe:
  580. results = s.runChecksConcurrent(ctx, m)
  581. attachQuotaSnapshot(results, s.fetchQuotaSnapshot(ctx, m))
  582. default:
  583. results = s.runChecksConcurrent(ctx, m)
  584. }
  585. s.persistCheckResults(ctx, m, results)
  586. return results, nil
  587. }
  588. // runQuotaOnlyCheck quota 模式:一次配额抓取 → 单条 CheckResult
  589. // (Model=PrimaryModel,默认 "quota";无 ping/latency,状态由快照推导)。
  590. func (s *ChannelMonitorService) runQuotaOnlyCheck(ctx context.Context, m *ChannelMonitor) []*CheckResult {
  591. snapshot := s.fetchQuotaSnapshot(ctx, m)
  592. res := deriveQuotaCheckResult(snapshot, m.PrimaryModel, time.Now())
  593. res.Quota = snapshot
  594. return []*CheckResult{res}
  595. }
  596. // fetchQuotaSnapshot 抓取关联账号配额。未关联账号 / fetcher 未注入时返回
  597. // 显式错误快照(不返回 error,保证检测周期与历史时间线连续)。
  598. func (s *ChannelMonitorService) fetchQuotaSnapshot(ctx context.Context, m *ChannelMonitor) *domain.MonitorQuotaSnapshot {
  599. if m.AccountID == nil {
  600. return quotaErrorSnapshot("usage", "linked account not found", time.Now())
  601. }
  602. if s.quotaFetcher == nil {
  603. return quotaErrorSnapshot("usage", "quota fetcher is not configured", time.Now())
  604. }
  605. return s.quotaFetcher.Fetch(ctx, *m.AccountID)
  606. }
  607. // attachQuotaSnapshot quota_probe:把配额快照挂到主模型行(results[0])。
  608. // 配额失败不改变探活状态,仅在探活 message 为空时附注失败原因。
  609. func attachQuotaSnapshot(results []*CheckResult, snapshot *domain.MonitorQuotaSnapshot) {
  610. if len(results) == 0 || snapshot == nil {
  611. return
  612. }
  613. primary := results[0]
  614. primary.Quota = snapshot
  615. if !snapshot.Success && strings.TrimSpace(primary.Message) == "" {
  616. primary.Message = truncateMessage("quota fetch failed: " + snapshot.Error)
  617. }
  618. }
  619. // persistCheckResults 写入本次检测的历史记录并更新 last_checked_at。
  620. // 任一写库失败都只记日志,不影响调用方拿到 results(与 MVP 期望一致:宁可漏记历史也要先返回结果)。
  621. func (s *ChannelMonitorService) persistCheckResults(ctx context.Context, m *ChannelMonitor, results []*CheckResult) {
  622. rows := make([]*ChannelMonitorHistoryRow, 0, len(results))
  623. for _, r := range results {
  624. rows = append(rows, &ChannelMonitorHistoryRow{
  625. MonitorID: m.ID,
  626. Model: r.Model,
  627. Status: r.Status,
  628. LatencyMs: r.LatencyMs,
  629. PingLatencyMs: r.PingLatencyMs,
  630. Message: r.Message,
  631. CheckedAt: r.CheckedAt,
  632. Quota: r.Quota,
  633. })
  634. }
  635. if err := s.repo.InsertHistoryBatch(ctx, rows); err != nil {
  636. slog.Error("channel_monitor: insert history failed",
  637. "monitor_id", m.ID, "name", m.Name, "error", err)
  638. }
  639. if err := s.repo.MarkChecked(ctx, m.ID, time.Now()); err != nil {
  640. slog.Error("channel_monitor: mark checked failed",
  641. "monitor_id", m.ID, "error", err)
  642. }
  643. }
  644. // runChecksConcurrent 对 primary + extra 模型并发执行检测。
  645. // errgroup 仅用于等待,不传播错误(每个 model 失败都已打包进 CheckResult)。
  646. func (s *ChannelMonitorService) runChecksConcurrent(ctx context.Context, m *ChannelMonitor) []*CheckResult {
  647. models := append([]string{m.PrimaryModel}, m.ExtraModels...)
  648. results := make([]*CheckResult, len(models))
  649. // ping 共享一次,所有模型记录同一个 ping 延迟。
  650. pingMs := pingEndpointOrigin(ctx, m.Endpoint)
  651. // 所有模型共用同一份 CheckOptions(来自监控的快照字段)。
  652. opts := &CheckOptions{
  653. APIMode: m.APIMode,
  654. ExtraHeaders: m.ExtraHeaders,
  655. BodyOverrideMode: m.BodyOverrideMode,
  656. BodyOverride: m.BodyOverride,
  657. }
  658. var eg errgroup.Group
  659. var mu sync.Mutex
  660. for i, model := range models {
  661. i, model := i, model
  662. eg.Go(func() error {
  663. r := runCheckForModel(ctx, m.Provider, m.Endpoint, m.APIKey, model, opts)
  664. r.PingLatencyMs = pingMs
  665. mu.Lock()
  666. results[i] = r
  667. mu.Unlock()
  668. return nil
  669. })
  670. }
  671. _ = eg.Wait()
  672. return results
  673. }
  674. // ---------- 调度器协作 ----------
  675. // SetScheduler 由 wire 在 runner 构造后注入,用于在 CRUD 时即时同步任务表。
  676. // 通过 setter 注入避免 service ↔ runner 的依赖环。
  677. func (s *ChannelMonitorService) SetScheduler(sched MonitorScheduler) {
  678. s.scheduler = sched
  679. }
  680. // SetQuotaFetcher 由 wire 注入配额抓取器(账号侧用量服务聚合)。
  681. func (s *ChannelMonitorService) SetQuotaFetcher(fetcher *ChannelMonitorQuotaFetcher) {
  682. if s == nil {
  683. return
  684. }
  685. s.quotaFetcher = fetcher
  686. }
  687. // ListEnabledMonitors 返回所有 enabled=true 的监控(解密后),供 runner 启动时建立任务表。
  688. func (s *ChannelMonitorService) ListEnabledMonitors(ctx context.Context) ([]*ChannelMonitor, error) {
  689. all, err := s.repo.ListEnabled(ctx)
  690. if err != nil {
  691. return nil, err
  692. }
  693. for _, m := range all {
  694. s.decryptInPlace(m)
  695. }
  696. return all, nil
  697. }
  698. // cleanupOldHistory 删除 monitorHistoryRetentionDays 天之前的明细历史记录。
  699. // 由 RunDailyMaintenance 调用;SoftDeleteMixin 自动把 DELETE 改为 UPDATE deleted_at。
  700. func (s *ChannelMonitorService) cleanupOldHistory(ctx context.Context) error {
  701. before := time.Now().UTC().AddDate(0, 0, -monitorHistoryRetentionDays)
  702. deleted, err := s.repo.DeleteHistoryBefore(ctx, before)
  703. if err != nil {
  704. return fmt.Errorf("delete history before %s: %w", before.Format(time.RFC3339), err)
  705. }
  706. if deleted > 0 {
  707. slog.Info("channel_monitor: history cleanup",
  708. "deleted_rows", deleted, "before", before.Format(time.RFC3339))
  709. }
  710. return nil
  711. }
  712. // RunDailyMaintenance 每日维护任务:聚合昨天之前未聚合的明细,软删过期明细和聚合。
  713. // 由 OpsCleanupService 的 cron 调度触发(共享 schedule 和 leader lock)。
  714. //
  715. // 幂等性:
  716. // - watermark 保证已聚合的日期不会重复处理;
  717. // - UpsertDailyRollupsFor 内部使用 ON CONFLICT DO UPDATE,同一日重复跑结果一致。
  718. //
  719. // 每一步失败都只记 slog.Warn,整体函数始终返回 nil 让后续步骤能继续跑
  720. // (与 OpsCleanupService.runCleanupOnce 风格一致)。
  721. func (s *ChannelMonitorService) RunDailyMaintenance(ctx context.Context) error {
  722. now := time.Now().UTC()
  723. today := now.Truncate(24 * time.Hour)
  724. if err := s.runDailyAggregation(ctx, today); err != nil {
  725. slog.Warn("channel_monitor: maintenance step failed",
  726. "step", "aggregate", "error", err)
  727. }
  728. if err := s.cleanupOldHistory(ctx); err != nil {
  729. slog.Warn("channel_monitor: maintenance step failed",
  730. "step", "prune_history", "error", err)
  731. }
  732. if err := s.cleanupOldRollups(ctx, today); err != nil {
  733. slog.Warn("channel_monitor: maintenance step failed",
  734. "step", "prune_rollups", "error", err)
  735. }
  736. return nil
  737. }
  738. // runDailyAggregation 从 watermark+1 聚合到昨天(UTC)。
  739. // 首次跑(watermark nil):从 today-monitorRollupRetentionDays 开始回填。
  740. // 每次最多聚合 monitorMaintenanceMaxDaysPerRun 天,避免长事务。
  741. func (s *ChannelMonitorService) runDailyAggregation(ctx context.Context, today time.Time) error {
  742. watermark, err := s.repo.LoadAggregationWatermark(ctx)
  743. if err != nil {
  744. return fmt.Errorf("load watermark: %w", err)
  745. }
  746. start := s.resolveAggregationStart(watermark, today)
  747. if !start.Before(today) {
  748. return nil // 没有需要聚合的日期
  749. }
  750. iterations := 0
  751. for d := start; d.Before(today); d = d.Add(24 * time.Hour) {
  752. if iterations >= monitorMaintenanceMaxDaysPerRun {
  753. slog.Info("channel_monitor: maintenance aggregation capped",
  754. "max_days", monitorMaintenanceMaxDaysPerRun,
  755. "next_resume", d.Format("2006-01-02"))
  756. break
  757. }
  758. affected, upErr := s.repo.UpsertDailyRollupsFor(ctx, d)
  759. if upErr != nil {
  760. return fmt.Errorf("upsert rollups for %s: %w", d.Format("2006-01-02"), upErr)
  761. }
  762. if err := s.repo.UpdateAggregationWatermark(ctx, d); err != nil {
  763. return fmt.Errorf("update watermark to %s: %w", d.Format("2006-01-02"), err)
  764. }
  765. slog.Info("channel_monitor: rollups upserted",
  766. "date", d.Format("2006-01-02"), "affected_rows", affected)
  767. iterations++
  768. }
  769. return nil
  770. }
  771. // resolveAggregationStart 计算本次聚合起点:
  772. // - watermark == nil:today - monitorRollupRetentionDays(首次回填最多 30 天)
  773. // - watermark != nil:*watermark + 1 day
  774. func (s *ChannelMonitorService) resolveAggregationStart(watermark *time.Time, today time.Time) time.Time {
  775. if watermark == nil {
  776. return today.AddDate(0, 0, -monitorRollupRetentionDays)
  777. }
  778. return watermark.UTC().Truncate(24 * time.Hour).Add(24 * time.Hour)
  779. }
  780. // cleanupOldRollups 软删 bucket_date < today - monitorRollupRetentionDays 的日聚合行。
  781. func (s *ChannelMonitorService) cleanupOldRollups(ctx context.Context, today time.Time) error {
  782. cutoff := today.AddDate(0, 0, -monitorRollupRetentionDays)
  783. deleted, err := s.repo.DeleteRollupsBefore(ctx, cutoff)
  784. if err != nil {
  785. return fmt.Errorf("delete rollups before %s: %w", cutoff.Format("2006-01-02"), err)
  786. }
  787. if deleted > 0 {
  788. slog.Info("channel_monitor: rollups cleanup",
  789. "deleted_rows", deleted, "before", cutoff.Format("2006-01-02"))
  790. }
  791. return nil
  792. }
  793. // ---------- helpers ----------
  794. // decryptInPlace 把 ChannelMonitor.APIKey 从密文解密为明文。
  795. // 解密失败时把字段清空 + 设置 APIKeyDecryptFailed=true(不返回错误,避免阻断列表渲染)。
  796. // runner / RunCheck 必须读取该标志位并拒绝执行检测。
  797. func (s *ChannelMonitorService) decryptInPlace(m *ChannelMonitor) {
  798. if m == nil || m.APIKey == "" {
  799. return
  800. }
  801. plain, err := s.encryptor.Decrypt(m.APIKey)
  802. if err != nil {
  803. slog.Warn("channel_monitor: decrypt api key failed",
  804. "monitor_id", m.ID, "error", err)
  805. m.APIKey = ""
  806. m.APIKeyDecryptFailed = true
  807. return
  808. }
  809. m.APIKey = plain
  810. }
  811. // applyMonitorUpdate 把 update params 中非 nil 的字段应用到 existing 上。
  812. // APIKey 字段在调用方单独处理(涉及加密)。
  813. //
  814. // 行数稍超过 30:这是逐字段平铺的 dispatcher,每个 if 都是 1-3 行的"非 nil 则覆盖"模式,
  815. // 拆分反而会增加跳转噪音、影响可读性,故保留为单函数。
  816. func applyMonitorUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams) error {
  817. providerChanged := false
  818. if p.Name != nil {
  819. existing.Name = strings.TrimSpace(*p.Name)
  820. }
  821. if p.Provider != nil {
  822. if err := validateProvider(*p.Provider); err != nil {
  823. return err
  824. }
  825. providerChanged = existing.Provider != *p.Provider
  826. existing.Provider = *p.Provider
  827. }
  828. if p.CheckMode != nil {
  829. existing.CheckMode = defaultCheckMode(*p.CheckMode)
  830. }
  831. // provider 与 check_mode 任一变化后统一复核组合矩阵:provider-only 更新
  832. // (如把 probe 监控的 provider 改成 antigravity)也不得落库非法组合,否则
  833. // 运行期恒 error。条件限定避免把存量非法行的 name/enabled-only 更新也判死
  834. // (否则连改名/停用都无法操作)。
  835. if p.Provider != nil || p.CheckMode != nil {
  836. if err := validateCheckMode(existing.Provider, defaultCheckMode(existing.CheckMode)); err != nil {
  837. return err
  838. }
  839. }
  840. if p.AccountID != nil {
  841. if *p.AccountID > 0 {
  842. id := *p.AccountID
  843. existing.AccountID = &id
  844. } else {
  845. existing.AccountID = nil // 0 = 清空关联
  846. }
  847. }
  848. if p.Endpoint != nil {
  849. // quota 模式允许清空 endpoint(校验由 validateMonitorModeFields 兜底)。
  850. if strings.TrimSpace(*p.Endpoint) != "" {
  851. if err := validateEndpoint(*p.Endpoint); err != nil {
  852. return err
  853. }
  854. }
  855. existing.Endpoint = normalizeEndpoint(*p.Endpoint)
  856. }
  857. // 模式与字段的组合校验(provider/check_mode/account_id/endpoint 全部应用后)。
  858. if err := validateMonitorModeFields(existing); err != nil {
  859. return err
  860. }
  861. if p.PrimaryModel != nil {
  862. primaryModel := normalizeMonitorPrimaryModel(existing.Provider, defaultCheckMode(existing.CheckMode), *p.PrimaryModel)
  863. if primaryModel == "" {
  864. return ErrChannelMonitorMissingPrimaryModel
  865. }
  866. existing.PrimaryModel = primaryModel
  867. } else if providerChanged && existing.Provider == MonitorProviderGrok {
  868. existing.PrimaryModel = MonitorDefaultGrokModel
  869. }
  870. if p.ExtraModels != nil {
  871. existing.ExtraModels = normalizeModels(*p.ExtraModels)
  872. }
  873. if p.GroupName != nil {
  874. existing.GroupName = strings.TrimSpace(*p.GroupName)
  875. }
  876. if p.Enabled != nil {
  877. existing.Enabled = *p.Enabled
  878. }
  879. if p.IntervalSeconds != nil {
  880. if err := validateInterval(*p.IntervalSeconds); err != nil {
  881. return err
  882. }
  883. existing.IntervalSeconds = *p.IntervalSeconds
  884. }
  885. if p.JitterSeconds != nil {
  886. existing.JitterSeconds = *p.JitterSeconds
  887. }
  888. if p.IntervalSeconds != nil || p.JitterSeconds != nil {
  889. // interval 与 jitter 任一变化都需要重新校验组合约束(interval - jitter >= 下限)。
  890. if err := validateJitter(existing.JitterSeconds, existing.IntervalSeconds); err != nil {
  891. return err
  892. }
  893. }
  894. return applyMonitorAdvancedUpdate(existing, p, providerChanged)
  895. }
  896. // applyMonitorAdvancedUpdate 处理自定义请求快照相关字段,从 applyMonitorUpdate 拆出避免过长。
  897. func applyMonitorAdvancedUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams, providerChanged bool) error {
  898. if p.ClearTemplate {
  899. existing.TemplateID = nil
  900. } else if p.TemplateID != nil {
  901. id := *p.TemplateID
  902. existing.TemplateID = &id
  903. }
  904. if p.ExtraHeaders != nil {
  905. if err := validateExtraHeaders(*p.ExtraHeaders); err != nil {
  906. return err
  907. }
  908. existing.ExtraHeaders = emptyHeadersIfNil(*p.ExtraHeaders)
  909. }
  910. newAPIMode := defaultAPIMode(existing.APIMode)
  911. if p.APIMode != nil {
  912. newAPIMode = defaultAPIMode(*p.APIMode)
  913. } else if existing.Provider != MonitorProviderOpenAI {
  914. newAPIMode = MonitorAPIModeChatCompletions
  915. }
  916. if err := validateAPIMode(existing.Provider, newAPIMode); err != nil {
  917. return err
  918. }
  919. // BodyOverrideMode / BodyOverride 联合校验,和模板一致。
  920. newMode := existing.BodyOverrideMode
  921. newBody := existing.BodyOverride
  922. if p.BodyOverrideMode != nil {
  923. newMode = *p.BodyOverrideMode
  924. }
  925. if p.BodyOverride != nil {
  926. newBody = *p.BodyOverride
  927. }
  928. if providerChanged || p.APIMode != nil || p.BodyOverrideMode != nil || p.BodyOverride != nil {
  929. if err := validateBodyModeForProtocol(existing.Provider, newAPIMode, newMode, newBody); err != nil {
  930. return err
  931. }
  932. existing.BodyOverrideMode = defaultBodyMode(newMode)
  933. existing.BodyOverride = newBody
  934. }
  935. existing.APIMode = newAPIMode
  936. return nil
  937. }