From fdc2ec17e6776bef109c5ea614af28218e56299b Mon Sep 17 00:00:00 2001 From: feeeei Date: Thu, 3 Sep 2026 10:33:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(pricing):=20fallback/override=20=E5=AE=9A?= =?UTF-8?q?=E4=BB=B7=E6=96=87=E4=BB=B6=E6=8C=89=E5=86=85=E5=AE=B9=E5=93=88?= =?UTF-8?q?=E5=B8=8C=E9=9A=8F=E5=AE=9A=E6=97=B6=E5=99=A8=E7=83=AD=E9=87=8D?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PricingService 的定时器(hash_check_interval_minutes,默认 10 分钟)每轮先做 远程目录哈希同步,再比对 fallback_file/override_file 两个文件内容的联合 sha256 (customFilesHash),变化时从本地 model_pricing.json 重建叠加层,只替换内存数据 与指纹,不改远程同步锚点 localHash。只配置了自定义文件而 remote_url 为空时 调度器同样启动。 - 三条重建路径(启动加载、远程下载、热重载)统一走 buildPricingData,指纹在合并 读取之前采样,并发改文件只会落后于数据、下一轮补重建 - 删除文件视为该层清空,下一轮生效;文件存在但不可读或不是 JSON 对象时保留旧数据、 不更新指纹,每轮重试并告警 - config.example.yaml 同步生效说明;新增 pricing_service_hot_reload_test.go --- backend/internal/service/pricing_service.go | 149 ++++++++++-- .../pricing_service_hot_reload_test.go | 217 ++++++++++++++++++ deploy/config.example.yaml | 19 +- 3 files changed, 364 insertions(+), 21 deletions(-) create mode 100644 backend/internal/service/pricing_service_hot_reload_test.go diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 826f6a7803..63677df838 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" "fmt" @@ -170,6 +171,9 @@ type PricingService struct { pricingData map[string]*LiteLLMModelPricing lastUpdated time.Time localHash string + // fallback/override 文件在最近一次成功重建时的内容指纹,定时器据此判断是否 + // 需要从本地目录缓存重建叠加层。 + customFilesHash string // 停止信号 stopCh chan struct{} @@ -216,14 +220,21 @@ func (s *PricingService) Stop() { logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Service stopped") } -// startUpdateScheduler 启动定时更新调度器 +// startUpdateScheduler 启动定时调度器:每个周期先做远程目录哈希同步(配置了 remote_url 时), +// 再比对 fallback/override 文件指纹做本地热重载(配置了任一文件时)。两者都未配置则不启动。 func (s *PricingService) startUpdateScheduler() { - if s == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Pricing.RemoteURL) == "" { + if s == nil || s.cfg == nil { + return + } + remoteEnabled := strings.TrimSpace(s.cfg.Pricing.RemoteURL) != "" + watchCustom := s.hasCustomPricingFiles() + if !remoteEnabled { logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Remote sync disabled: pricing remote URL is empty") + } + if !remoteEnabled && !watchCustom { return } - // 定期检查哈希更新 hashInterval := time.Duration(s.cfg.Pricing.HashCheckIntervalMinutes) * time.Minute if hashInterval < time.Minute { hashInterval = 10 * time.Minute @@ -238,8 +249,13 @@ func (s *PricingService) startUpdateScheduler() { for { select { case <-ticker.C: - if err := s.syncWithRemote(); err != nil { - logger.LegacyPrintf("service.pricing", "[Pricing] Sync failed: %v", err) + if remoteEnabled { + if err := s.syncWithRemote(); err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Sync failed: %v", err) + } + } + if watchCustom { + s.reloadIfCustomFilesChanged() } case <-s.stopCh: return @@ -247,7 +263,7 @@ func (s *PricingService) startUpdateScheduler() { } }() - logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v)", hashInterval) + logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v, remote sync=%t, custom file watch=%t)", hashInterval, remoteEnabled, watchCustom) } // checkAndUpdatePricing 检查并更新价格数据 @@ -348,6 +364,101 @@ func (s *PricingService) syncWithRemote() error { return nil } +// hasCustomPricingFiles 报告是否配置了 fallback/override 任一文件路径(不要求文件存在)。 +func (s *PricingService) hasCustomPricingFiles() bool { + if s == nil || s.cfg == nil { + return false + } + return strings.TrimSpace(s.cfg.Pricing.FallbackFile) != "" || strings.TrimSpace(s.cfg.Pricing.OverrideFile) != "" +} + +// customPricingFilesFingerprint 返回 fallback、override 两个文件当前内容的联合 sha256。 +// 每个文件以"长度前缀 + 正文"参与计算,不可读的文件按空正文处理;未配置任何文件返回空串。 +func (s *PricingService) customPricingFilesFingerprint() string { + if !s.hasCustomPricingFiles() { + return "" + } + h := sha256.New() + for _, path := range []string{s.cfg.Pricing.FallbackFile, s.cfg.Pricing.OverrideFile} { + var body []byte + if p := strings.TrimSpace(path); p != "" { + body, _ = os.ReadFile(p) + } + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(body))) + _, _ = h.Write(size[:]) + _, _ = h.Write(body) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// validateCustomPricingFiles 要求每个已配置且存在的 fallback/override 文件可读且为 JSON +// 对象,任一不满足即返回带路径的错误;文件不存在视为该层为空,属合法状态。 +func (s *PricingService) validateCustomPricingFiles() error { + for _, path := range []string{s.cfg.Pricing.FallbackFile, s.cfg.Pricing.OverrideFile} { + p := strings.TrimSpace(path) + if p == "" { + continue + } + body, err := os.ReadFile(p) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + var entries map[string]json.RawMessage + if err := json.Unmarshal(body, &entries); err != nil { + return fmt.Errorf("%s: %w", p, err) + } + } + return nil +} + +// reloadIfCustomFilesChanged 比对 fallback/override 文件指纹,与最近一次重建时不同则从 +// 本地目录缓存重建内存数据。文件被删除视为该层清空,照常重建;文件存在但不可读或不是 +// JSON 对象时保留当前数据且不更新指纹,下一轮会再次尝试并重复告警。目录正文与远程同步 +// 锚点(localHash)不受本路径影响。 +func (s *PricingService) reloadIfCustomFilesChanged() { + fingerprint := s.customPricingFilesFingerprint() + s.mu.RLock() + unchanged := fingerprint == s.customFilesHash + s.mu.RUnlock() + if unchanged { + return + } + if err := s.validateCustomPricingFiles(); err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing file changed but previous data kept: %v", err) + return + } + if err := s.reloadCustomPricingLayers(); err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing file changed but reload failed: %v", err) + } +} + +// reloadCustomPricingLayers 读取本地目录缓存并重新叠加 fallback/override,只替换内存数据 +// 与叠加层指纹。 +func (s *PricingService) reloadCustomPricingLayers() error { + pricingFile := s.getPricingFilePath() + body, err := os.ReadFile(pricingFile) + if err != nil { + return fmt.Errorf("read file failed: %w", err) + } + data, fingerprint, err := s.buildPricingData(body) + if err != nil { + return fmt.Errorf("parse pricing data: %w", err) + } + + s.mu.Lock() + warnDroppedLongContextLadders(s.pricingData, data) + s.pricingData = data + s.customFilesHash = fingerprint + s.mu.Unlock() + + logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing files changed, reloaded %d models from %s", len(data), pricingFile) + return nil +} + // downloadPricingData 从远程下载价格数据 func (s *PricingService) downloadPricingData() error { remoteURL, err := s.validatePricingURL(s.cfg.Pricing.RemoteURL) @@ -382,13 +493,10 @@ func (s *PricingService) downloadPricingData() error { remoteHash[:min(8, len(remoteHash))], dataHashStr[:8]) } - // 解析JSON数据(使用灵活的解析方式) - data, err := s.parsePricingData(body) + data, customFilesHash, err := s.buildPricingData(body) if err != nil { return fmt.Errorf("parse pricing data: %w", err) } - data = s.mergeFallbackPricingData(data) - data = s.mergeOverrideOnlyModels(data) // 保存到本地文件 pricingFile := s.getPricingFilePath() @@ -413,6 +521,7 @@ func (s *PricingService) downloadPricingData() error { s.pricingData = data s.lastUpdated = time.Now() s.localHash = syncHash + s.customFilesHash = customFilesHash s.mu.Unlock() logger.LegacyPrintf("service.pricing", "[Pricing] Downloaded %d models successfully", len(data)) @@ -798,6 +907,20 @@ func (s *PricingService) mergeOverrideOnlyModels(data map[string]*LiteLLMModelPr return data } +// buildPricingData 解析目录正文并依次叠加 fallback、override 两层,返回合并结果与 +// 叠加层文件指纹。指纹在合并读取之前采样:并发改文件只会让存下的指纹落后于实际 +// 合并的数据、不会领先,下一轮定时比对因此会再次重建。 +func (s *PricingService) buildPricingData(body []byte) (map[string]*LiteLLMModelPricing, string, error) { + fingerprint := s.customPricingFilesFingerprint() + data, err := s.parsePricingData(body) + if err != nil { + return nil, "", err + } + data = s.mergeFallbackPricingData(data) + data = s.mergeOverrideOnlyModels(data) + return data, fingerprint, nil +} + // loadPricingData 从本地文件加载价格数据 func (s *PricingService) loadPricingData(filePath string) error { data, err := os.ReadFile(filePath) @@ -805,13 +928,10 @@ func (s *PricingService) loadPricingData(filePath string) error { return fmt.Errorf("read file failed: %w", err) } - // 使用灵活的解析方式 - pricingData, err := s.parsePricingData(data) + pricingData, customFilesHash, err := s.buildPricingData(data) if err != nil { return fmt.Errorf("parse pricing data: %w", err) } - pricingData = s.mergeFallbackPricingData(pricingData) - pricingData = s.mergeOverrideOnlyModels(pricingData) // 计算哈希 hash := sha256.Sum256(data) @@ -821,6 +941,7 @@ func (s *PricingService) loadPricingData(filePath string) error { warnDroppedLongContextLadders(s.pricingData, pricingData) s.pricingData = pricingData s.localHash = hashStr + s.customFilesHash = customFilesHash info, _ := os.Stat(filePath) if info != nil { diff --git a/backend/internal/service/pricing_service_hot_reload_test.go b/backend/internal/service/pricing_service_hot_reload_test.go new file mode 100644 index 0000000000..31a50ac145 --- /dev/null +++ b/backend/internal/service/pricing_service_hot_reload_test.go @@ -0,0 +1,217 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +const hotReloadCatalogJSON = `{ + "remote-model": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06} +}` + +func hotReloadModelJSON(name string, input, output float64) string { + return `"` + name + `": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": ` + formatFloat(input) + `, "output_cost_per_token": ` + formatFloat(output) + `}` +} + +func formatFloat(v float64) string { + return strconv.FormatFloat(v, 'g', -1, 64) +} + +// newHotReloadPricingService 在数据目录里放好目录缓存与 fallback/override 文件并完成首次加载。 +// 传空串表示不配置对应文件。 +func newHotReloadPricingService(t *testing.T, fallbackJSON, overrideJSON string) *PricingService { + t.Helper() + dir := t.TempDir() + svc := &PricingService{cfg: &config.Config{}} + svc.cfg.Pricing.DataDir = dir + require.NoError(t, os.WriteFile(svc.getPricingFilePath(), []byte(hotReloadCatalogJSON), 0644)) + if fallbackJSON != "" { + svc.cfg.Pricing.FallbackFile = filepath.Join(dir, "fallback.json") + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(fallbackJSON), 0644)) + } + if overrideJSON != "" { + svc.cfg.Pricing.OverrideFile = filepath.Join(dir, "overrides.json") + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(overrideJSON), 0644)) + } + require.NoError(t, svc.loadPricingData(svc.getPricingFilePath())) + return svc +} + +func TestPricingCustomFilesFingerprint(t *testing.T) { + svc := &PricingService{cfg: &config.Config{}} + require.Empty(t, svc.customPricingFilesFingerprint(), "未配置文件返回空串") + + dir := t.TempDir() + svc.cfg.Pricing.FallbackFile = filepath.Join(dir, "fallback.json") + missing := svc.customPricingFilesFingerprint() + require.NotEmpty(t, missing) + require.Equal(t, missing, svc.customPricingFilesFingerprint(), "同一状态下指纹稳定") + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{}`), 0644)) + present := svc.customPricingFilesFingerprint() + require.NotEqual(t, missing, present, "文件出现即视为变化") + + svc.cfg.Pricing.OverrideFile = filepath.Join(dir, "overrides.json") + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{}`), 0644)) + require.NotEqual(t, present, svc.customPricingFilesFingerprint(), "override 内容参与指纹") +} + +func TestPricingHotReload_FallbackChangeRebuildsWithoutTouchingSyncAnchor(t *testing.T) { + svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "") + require.InDelta(t, 4e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12) + require.Nil(t, svc.pricingData["custom-b"]) + require.NotEmpty(t, svc.customFilesHash) + anchor, updated := svc.localHash, svc.lastUpdated + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+ + hotReloadModelJSON("custom-a", 5e-6, 8e-6)+`,`+ + hotReloadModelJSON("custom-b", 1e-6, 3e-6)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + + require.InDelta(t, 5e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "改价即时生效") + require.NotNil(t, svc.pricingData["custom-b"], "新模型即时并入") + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "目录条目不受影响") + require.Equal(t, anchor, svc.localHash, "热重载不得改动远程同步锚点") + require.Equal(t, updated, svc.lastUpdated) + require.Equal(t, svc.customPricingFilesFingerprint(), svc.customFilesHash) +} + +func TestPricingHotReload_UnchangedFilesSkipRebuild(t *testing.T) { + svc := newHotReloadPricingService(t, + `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, + `{"remote-model": {"input_cost_per_token": 7e-06}}`) + svc.pricingData["sentinel"] = &LiteLLMModelPricing{} + + svc.reloadIfCustomFilesChanged() + + require.Contains(t, svc.pricingData, "sentinel", "指纹未变时不得重建") +} + +func TestPricingHotReload_OverrideChangePatchesCatalogAndAddsModels(t *testing.T) { + svc := newHotReloadPricingService(t, "", `{"remote-model": {"input_cost_per_token": 7e-06}}`) + require.InDelta(t, 7e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{ + "remote-model": {"input_cost_per_token": 9e-06}, + `+hotReloadModelJSON("override-new-model", 5e-6, 1e-5)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + + require.InDelta(t, 9e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + require.InDelta(t, 2e-6, svc.pricingData["remote-model"].OutputCostPerToken, 1e-12, "未覆盖字段保持目录值") + require.NotNil(t, svc.pricingData["override-new-model"]) + + // 清空补丁:目录条目回到原价,补丁新增的模型随之消失。 + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{}`), 0644)) + svc.reloadIfCustomFilesChanged() + + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + require.Nil(t, svc.pricingData["override-new-model"]) +} + +func TestPricingHotReload_InvalidFileKeepsCurrentDataUntilFixed(t *testing.T) { + svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "") + before := svc.customFilesHash + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{"custom-a": {"input_cost_per_token": `), 0644)) + svc.reloadIfCustomFilesChanged() + require.InDelta(t, 4e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "半写文件不得替换数据") + require.Equal(t, before, svc.customFilesHash, "指纹不更新,下一轮继续尝试") + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-a", 6e-6, 8e-6)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + require.InDelta(t, 6e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "文件修好后正常重建") + require.NotEqual(t, before, svc.customFilesHash) +} + +// 删除文件等于清空该层:override 补丁撤销、fallback 独有模型消失,都在下一轮比对时生效, +// 且缺失状态被记录,之后不会每轮重建。 +func TestPricingHotReload_DeletedFileDropsItsLayer(t *testing.T) { + svc := newHotReloadPricingService(t, + `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, + `{"remote-model": {"input_cost_per_token": 7e-06}}`) + require.NotNil(t, svc.pricingData["custom-a"]) + require.InDelta(t, 7e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + + require.NoError(t, os.Remove(svc.cfg.Pricing.OverrideFile)) + svc.reloadIfCustomFilesChanged() + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "删除 override 后目录条目回到原价") + require.NotNil(t, svc.pricingData["custom-a"], "fallback 层不受影响") + + require.NoError(t, os.Remove(svc.cfg.Pricing.FallbackFile)) + svc.reloadIfCustomFilesChanged() + require.Nil(t, svc.pricingData["custom-a"], "删除 fallback 后其独有模型消失") + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "目录条目不受影响") + + svc.pricingData["sentinel"] = &LiteLLMModelPricing{} + svc.reloadIfCustomFilesChanged() + require.Contains(t, svc.pricingData, "sentinel", "缺失状态已记录,不得每轮重建") + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + require.NotNil(t, svc.pricingData["custom-a"], "文件重新出现即恢复") +} + +type stubPricingRemoteClient struct{ body string } + +func (c stubPricingRemoteClient) FetchPricingJSON(context.Context, string) ([]byte, error) { + return []byte(c.body), nil +} + +func (c stubPricingRemoteClient) FetchHashText(context.Context, string) (string, error) { + return "", nil +} + +// 远程下载重建后指纹必须同步到当前文件内容,否则下一轮定时比对会多做一次无意义重载。 +func TestPricingHotReload_DownloadRefreshesFingerprint(t *testing.T) { + svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "") + svc.cfg.Pricing.RemoteURL = "https://example.com/pricing.json" + svc.remoteClient = stubPricingRemoteClient{body: hotReloadCatalogJSON} + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-b", 1e-6, 3e-6)+`}`), 0644)) + + require.NoError(t, svc.downloadPricingData()) + + require.NotNil(t, svc.pricingData["custom-b"]) + require.Nil(t, svc.pricingData["custom-a"]) + require.Equal(t, svc.customPricingFilesFingerprint(), svc.customFilesHash) + + svc.pricingData["sentinel"] = &LiteLLMModelPricing{} + svc.reloadIfCustomFilesChanged() + require.Contains(t, svc.pricingData, "sentinel", "下载已消化文件变化,不得再次重建") +} + +// 只配置了 fallback/override 而没有 remote_url 时调度器也要运行,否则文件改动无人比对。 +func TestPricingSchedulerStartsForCustomFilesWithoutRemoteURL(t *testing.T) { + svc := NewPricingService(&config.Config{Pricing: config.PricingConfig{ + RemoteURL: "", + FallbackFile: filepath.Join(t.TempDir(), "fallback.json"), + }}, nil) + + svc.startUpdateScheduler() + done := make(chan struct{}) + go func() { + svc.wg.Wait() + close(done) + }() + + select { + case <-done: + t.Fatal("custom file watch must keep the scheduler running") + case <-time.After(50 * time.Millisecond): + } + + svc.Stop() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("scheduler must exit after Stop") + } +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index c8798ee171..0ff4c9086e 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -1063,22 +1063,27 @@ pricing: # Local data directory for caching # 本地数据缓存目录 data_dir: "./data" - # Fallback pricing file - # 备用定价文件 + # Fallback pricing file: fills in models missing from the catalog. Edits are + # detected by content hash at the next hash check and reloaded without restart. + # 备用定价文件:只补目录缺失的模型。修改后在下一次哈希检查时按内容哈希自动重载,无需重启。 fallback_file: "./resources/model-pricing/model_prices_and_context_window.json" # Override patch file (optional, highest priority): entries are shallow-merged # field-by-field over catalog/fallback data; a null field value removes that field. - # Model names must match catalog keys exactly. Edits take effect on restart or - # the next catalog download. + # Model names must match catalog keys exactly. Edits are detected by content hash + # at the next hash check and reloaded without restart. # 覆盖补丁文件(可选,优先级最高):条目按字段浅合并覆盖目录/回退数据, # 字段值为 null 表示删除该字段。模型名须与目录键完全一致。 - # 修改后重启或下次目录下载时生效。 + # 修改后在下一次哈希检查时按内容哈希自动重载,无需重启。 # override_file: "./data/model_pricing_overrides.json" # Update interval in hours # 更新间隔(小时) update_interval_hours: 24 - # Hash check interval in minutes - # 哈希检查间隔(分钟) + # Hash check interval in minutes. Each check compares the remote catalog hash and + # the local fallback/override file contents. Deleting a local file drops its + # entries at the next check; a present but non-JSON file keeps the previous data + # and is retried next check. + # 哈希检查间隔(分钟)。每次检查同时比对远程目录哈希与本地 fallback/override 文件内容; + # 删除本地文件会在下次检查时移除其条目,文件存在但不是 JSON 时保留旧数据、下次检查再试。 hash_check_interval_minutes: 10 # =============================================================================