From e508abd958834d93515e86add2fa2e89a5b09e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Thu, 29 Oct 2026 07:13:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(library):=20=E6=AD=BB=E4=BF=A1=E8=A7=A3?= =?UTF-8?q?=E9=99=A4=E7=BB=91=E5=AE=9A=E5=B0=81=E5=AD=98=E6=BA=90=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E8=80=8C=E4=B8=8D=E6=98=AF=E5=90=8C=E6=AD=A5=E7=9B=AE?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步目标与封存源常常不是同一个实例(kgseal vs kgmaint);用目标名去绑来源死信, 真实观测的 project 永远对不上,解除会被一律拒绝。新增 LIBRARY_SOURCE_INSTANCE, 留空回落到目标实例名,API 写记录与 worker 取指纹共用同一个来源实例名。 试过:真实 API GET/POST、go test ./internal/{httpapi,appconfig} 与 make verify 全绿。 --- CHANGELOG.md | 3 +++ cmd/api/main.go | 1 + cmd/worker/main.go | 2 +- internal/appconfig/config.go | 15 ++++++++++++++- internal/httpapi/library_deadletters.go | 2 +- internal/httpapi/server.go | 11 +++++++++++ 6 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bcdb71..9fbd316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ ### Changed +- 死信解除绑定 `LIBRARY_SOURCE_INSTANCE`(封存源),留空回落到 `LIBRARY_TARGET_INSTANCE`: + 同步目标与封存源常常不是同一个实例,用目标名去绑来源死信会让真实观测永远对不上 + (拒绝而不是误放行)。worker 取指纹、API 写记录用同一个来源实例名。 - 结算/封存不再"死信存在即阻断",而是"每条实时死信都要被精确解除命中": `checkLibrarySourceSettlementReleased` / `ExportLibraryWithReleases` 对每条行 重算指纹,未命中、行被改过、出现新行都继续阻断并点名。 diff --git a/cmd/api/main.go b/cmd/api/main.go index 4b8e8c7..23dad2d 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -203,6 +203,7 @@ func run(ctx context.Context, getenv func(string) string) error { VersionHistoryRepo: versionHistoryRepo, LibraryArchiveRoot: config.LibraryArchiveRoot, LibraryTargetInstance: config.LibraryTargetInstance, + LibrarySourceInstance: config.SourceInstance(), LibraryAgentSocket: config.LibraryAgentSocket, LibraryCapacityBudgetBytes: config.LibraryCapacityBudgetBytes, LibraryProductionDatabaseURL: config.LibraryProductionDatabaseURL, diff --git a/cmd/worker/main.go b/cmd/worker/main.go index a8a52df..6e5b1eb 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -278,7 +278,7 @@ func workerHandlers(store *postgresstore.Store, config appconfig.Config) (map[st } // 平台审计过的精确死信解除指纹:读取失败必须阻断(fail closed), // 不能因为存储不可用就静默按"没有解除"或"全部解除"处理。 - released, releaseErr := store.DeadLetterReleaseFingerprints(ctx, config.LibraryTargetInstance) + released, releaseErr := store.DeadLetterReleaseFingerprints(ctx, config.SourceInstance()) if releaseErr != nil { _ = store.UpdateLibraryStage(ctx, libraryJobID, "export", "failed", nil, nil, nil, nil, releaseErr.Error(), time.Now().UTC()) _ = store.AdvanceLibraryJob(ctx, libraryJobID, governance.LibraryJobFailed, "export", "dead letter release storage unavailable", "", "", "", time.Now().UTC()) diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 097703d..1bd8e59 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -96,7 +96,10 @@ type Config struct { LibraryRetentionDays int // 目标维护(实例级):同步/恢复期间关闭目标入口、停下写入者、排空在途请求。 // 任一项留空即"未接线",生产写入会被拒绝——只锁治理库 scope 不算维护。 - LibraryTargetInstance string + LibraryTargetInstance string + // LibrarySourceInstance 是死信解除记录绑定的"来源实例"名字(封存源,不是同步目标)。 + // 留空时回落到 LibraryTargetInstance,保持单实例部署的旧行为。 + LibrarySourceInstance string LibraryTargetProject string LibraryTargetWriters []string LibraryTargetNonWriters map[string]string @@ -199,6 +202,7 @@ func Load(getenv func(string) string) (Config, error) { LibraryProductionS3AccessKey: strings.TrimSpace(getenv("LIBRARY_PRODUCTION_S3_ACCESS_KEY")), LibraryProductionS3SecretKey: strings.TrimSpace(getenv("LIBRARY_PRODUCTION_S3_SECRET_KEY")), LibraryTargetInstance: strings.TrimSpace(getenv("LIBRARY_TARGET_INSTANCE")), + LibrarySourceInstance: strings.TrimSpace(getenv("LIBRARY_SOURCE_INSTANCE")), LibraryTargetProject: strings.TrimSpace(getenv("LIBRARY_TARGET_PROJECT")), LibraryTargetWriters: splitList(getenv("LIBRARY_TARGET_WRITERS")), LibraryTargetNonWriters: splitDeclarations(getenv("LIBRARY_TARGET_NON_WRITERS")), @@ -562,3 +566,12 @@ func positiveInt64OrDefault(value string, fallback int64) (int64, error) { } return parsed, nil } + +// SourceInstance 是死信解除记录绑定的实例名:封存源与同步目标常常不是同一个实例, +// 用目标名去绑来源死信会让真实观测永远对不上(拒绝而不是误放行)。 +func (config Config) SourceInstance() string { + if config.LibrarySourceInstance != "" { + return config.LibrarySourceInstance + } + return config.LibraryTargetInstance +} diff --git a/internal/httpapi/library_deadletters.go b/internal/httpapi/library_deadletters.go index d879fcd..38efbfd 100644 --- a/internal/httpapi/library_deadletters.go +++ b/internal/httpapi/library_deadletters.go @@ -36,7 +36,7 @@ func deadLetterReleaseHandlers(w http.ResponseWriter, r *http.Request, config Co writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "死信解除存储不可用"}) return } - instance := strings.TrimSpace(config.LibraryTargetInstance) + instance := strings.TrimSpace(config.SourceInstance()) if instance == "" { writeJSON(w, http.StatusServiceUnavailable, map[string]any{ "code": "dead_letter_release_unwired", "reason": "未接线:没有配置来源实例身份,无法绑定解除记录", diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7b70c85..caf8b0d 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -148,6 +148,8 @@ type Config struct { // LibraryTargetInstance 是实例级维护的目标实例名;留空即"未接线", // 页面不能发起按预备份恢复,接口返回原因而不是假装能恢复。 LibraryTargetInstance string + // LibrarySourceInstance 是死信解除记录的来源实例名(封存源),留空回落到目标实例名。 + LibrarySourceInstance string // LibraryAgentSocket 是主机侧封存 agent 的 unix socket;留空即页面不能发起封存。 LibraryAgentSocket string // LibraryCapacityBudgetBytes 是整库归档容量预算;0 = 未设置(容量门禁不放行)。 @@ -2807,3 +2809,12 @@ func writeJSON(w http.ResponseWriter, status int, value any) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } + +// SourceInstance 是死信解除记录绑定的实例名:来源实例与同步目标常常不是同一个, +// 用目标名去绑来源死信会让真实观测永远对不上。 +func (config Config) SourceInstance() string { + if config.LibrarySourceInstance != "" { + return config.LibrarySourceInstance + } + return config.LibraryTargetInstance +}