From 1aecd9d6af0e2f7e8da6710f9bc73711ecb76e81 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:04:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(library):=20=E8=B5=84=E6=BA=90=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D=E7=AC=A6=E5=90=8C=E6=97=B6=E6=94=B9=E5=88=B0=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E5=AD=98=E5=82=A8=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storage:// 是来源实例 storage_backends 的主键;只换桶不改 id 时,目标原版 应用以 storage backend not found 落回默认服务并拒绝整条路径(真实下载 500 code1007)。写入前按租户+目标桶解析目标后端,写入与校验共用同一份行变换; 解析不到就拒绝写入,不再产出目标读不出来的定位符。 试过:目标实例真实同步后 /api/v1/knowledge/{id}/download 读回 500 → 修后复测; go test ./internal/releaseadapter/v071 与 make verify 15 项全绿。 --- CHANGELOG.md | 5 ++ .../v071/library_production_target.go | 47 +++++++++++++++++-- internal/releaseadapter/v071/object_import.go | 15 ++++++ .../v071/resource_locator_test.go | 41 +++++++++++++++- 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa62e17..d231c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ ### Fixed +- **同步过去的资源行指向来源实例的存储后端 id**:`storage:///minio:///` + 里的 `` 是来源 `storage_backends` 的主键,目标实例没有这条行。只换桶不改 id 时, + 原版应用落回默认存储服务并拒绝整条路径(真实下载 500 `code 1007 + invalid MinIO file path`)。现在写入前按"租户 + 目标桶名"在目标库解析后端 id, + 写入与校验共用同一份行变换;解析不到就拒绝写入,不产出目标读不出来的定位符。 - 指纹对空白敏感会让"同一份内容"两边算不出同一个值(PostgreSQL `to_jsonb::text` 带空格、Go `json.Marshal` 不带);指纹改为先 `json.Compact` 再摘要。 diff --git a/internal/releaseadapter/v071/library_production_target.go b/internal/releaseadapter/v071/library_production_target.go index 1326138..781221c 100644 --- a/internal/releaseadapter/v071/library_production_target.go +++ b/internal/releaseadapter/v071/library_production_target.go @@ -50,9 +50,12 @@ type ProductionLibraryConfig struct { // ObjectsBucket 是目标对象存储的桶名:资源行里的定位符要换成它, // 否则同步过去的图/附件仍指向来源桶。 ObjectsBucket string - MaxDocuments int - HTTPClient *http.Client - Now func() time.Time + // ObjectsBackendID 是目标实例 storage_backends 的主键。留空则按租户+桶名在 + // 目标库里解析;定位符里的来源后端 id 在目标实例不存在,原版应用会 500。 + ObjectsBackendID string + MaxDocuments int + HTTPClient *http.Client + Now func() time.Time } func (config ProductionLibraryConfig) validate(scope ProductionScope) error { @@ -81,6 +84,36 @@ type ProductionLibraryTarget struct { scope ProductionScope search *SearchExporter now func() time.Time + // storageBackend 是本次写入使用的目标存储后端 id(写入与校验共用)。 + storageBackend string +} + +// resolveStorageBackend 在目标库里找本次写入的存储后端。找不到就报错:宁可不写, +// 也不写一条目标应用读不出来的定位符(上轮就是这样同步"成功"了却下载 500)。 +func (t *ProductionLibraryTarget) resolveStorageBackend(ctx context.Context) error { + if t.config.ObjectsBucket == "" { + return nil + } + if t.config.ObjectsBackendID != "" { + if !validSearchIdentifier(t.config.ObjectsBackendID) { + return errors.New("目标存储后端 id 形状不合法") + } + t.storageBackend = t.config.ObjectsBackendID + return nil + } + var id string + err := t.pool.QueryRow(ctx, ` +SELECT id FROM storage_backends +WHERE tenant_id=$1 AND coalesce(config->>'bucket_name','')=$2 +ORDER BY created_at LIMIT 1`, t.scope.TenantID, t.config.ObjectsBucket).Scan(&id) + if err != nil { + return errors.New("目标实例没有与目标桶匹配的存储后端,拒绝写入不可解析的资源定位符") + } + if !validSearchIdentifier(id) { + return errors.New("目标存储后端 id 形状不合法") + } + t.storageBackend = id + return nil } func NewProductionLibraryTarget(ctx context.Context, config ProductionLibraryConfig, scope ProductionScope) (*ProductionLibraryTarget, error) { @@ -223,6 +256,9 @@ func digestIDs(ids []string) string { // Apply 把一类数据从归档写进生产;database 与 wiki 走同一个知识域事务。 func (t *ProductionLibraryTarget) Apply(ctx context.Context, stage libraryrelease.Stage, snapshotID string) error { + if err := t.resolveStorageBackend(ctx); err != nil { + return err + } archive, err := librarysnapshot.Open(t.config.ArchiveRoot, 256*1024*1024) if err != nil { return errors.New("归档不可用") @@ -394,7 +430,7 @@ func (t *ProductionLibraryTarget) applyResourceBucket(row map[string]json.RawMes if err := json.Unmarshal(raw, &locator); err != nil { return errors.New("资源定位符不可解析") } - rewritten, ok := rewriteLocatorBucket(locator, t.config.ObjectsBucket) + rewritten, ok := rewriteLocatorTarget(locator, t.storageBackend, t.config.ObjectsBucket) if !ok { return fmt.Errorf("资源定位符无法改写到目标桶:%s", locator) } @@ -537,6 +573,9 @@ func (t *ProductionLibraryTarget) importIndex(ctx context.Context, archive *libr // Verify 用行数 + 身份集合摘要 + 索引条数核对生产与预发布快照是否一致。 func (t *ProductionLibraryTarget) Verify(ctx context.Context, expect libraryrelease.Expectation) error { + if err := t.resolveStorageBackend(ctx); err != nil { + return err + } archive, err := librarysnapshot.Open(t.config.ArchiveRoot, 256*1024*1024) if err != nil { return errors.New("归档不可用") diff --git a/internal/releaseadapter/v071/object_import.go b/internal/releaseadapter/v071/object_import.go index 728215b..a439a6f 100644 --- a/internal/releaseadapter/v071/object_import.go +++ b/internal/releaseadapter/v071/object_import.go @@ -108,6 +108,15 @@ func (importer *ObjectImporter) Promote(ctx context.Context, stage ObjectStage) // 目标实例之后按这一行去取图/附件时会指向测试环境的桶(跨环境引用), // 或者干脆取不到。键不变,只换桶。 func rewriteLocatorBucket(locator, bucket string) (string, bool) { + return rewriteLocatorTarget(locator, "", bucket) +} + +// rewriteLocatorTarget 同时改写定位符里的存储后端 id 与桶。 +// +// 为什么后端 id 也要改:storage:///minio:/// 里的 id 是来源实例 +// storage_backends 的主键。目标实例有自己的行;只改桶不改 id,原版应用会以 +// "storage backend not found" 落到默认服务,然后拒绝整条路径(真跑 500 code1007)。 +func rewriteLocatorTarget(locator, backendID, bucket string) (string, bool) { if bucket == "" || locator == "" || locator != strings.TrimSpace(locator) { return "", false } @@ -122,6 +131,12 @@ func rewriteLocatorBucket(locator, bucket string) (string, bool) { if parts[1] == "" { return "", false } + if backendID != "" { + if !validSearchIdentifier(backendID) { + return "", false + } + parts[0] = backendID + } prefix, rest = "storage://"+parts[0]+"/", parts[1] } separator := strings.Index(rest, "://") diff --git a/internal/releaseadapter/v071/resource_locator_test.go b/internal/releaseadapter/v071/resource_locator_test.go index 5fdfaf8..7c3c2bc 100644 --- a/internal/releaseadapter/v071/resource_locator_test.go +++ b/internal/releaseadapter/v071/resource_locator_test.go @@ -1,6 +1,9 @@ package v071 -import "testing" +import ( + "encoding/json" + "testing" +) // 同步把字节写进目标桶,但资源行是从快照原样搬的:定位符里写着来源桶时, // 目标实例之后取图/附件会指向测试环境的桶(跨环境引用)或直接取不到。 @@ -34,3 +37,39 @@ func TestRewriteLocatorBucketKeepsKeyAndSwapsBucket(t *testing.T) { t.Fatalf("裸键被改写了:%q ok=%v", got, ok) } } + +// 只换桶不够:storage:// 是来源实例 storage_backends 的主键。目标实例没有这条行时, +// 原版应用会以 storage backend not found 落到默认服务并拒绝整条路径(真跑 500 code1007), +// 所以后端 id 必须一起换成本次写入解析到的目标 id。 +func TestRewriteLocatorSwapsStorageBackendWithBucket(t *testing.T) { + got, ok := rewriteLocatorTarget("storage://source-backend/minio://kgmaint-1028a/10001/k/ab.md", + "target-backend", "kgseal-1029a") + if !ok || got != "storage://target-backend/minio://kgseal-1029a/10001/k/ab.md" { + t.Fatalf("rewrite=%q ok=%v", got, ok) + } + // 没有解析到后端时不猜:保留原 id(调用方在没有目标桶时不会走到这里)。 + got, ok = rewriteLocatorTarget("storage://source-backend/minio://b/k", "", "b2") + if !ok || got != "storage://source-backend/minio://b2/k" { + t.Fatalf("rewrite without backend=%q ok=%v", got, ok) + } + if _, ok := rewriteLocatorTarget("storage://x/minio://b/k", "../evil", "b2"); ok { + t.Fatal("非法后端 id 被接受了") + } +} + +// 行变换用解析到的目标后端与目标桶:写入与校验共用这一份,摘要才不会两边打架。 +func TestResourceRowTransformRewritesBackendAndBucket(t *testing.T) { + target := &ProductionLibraryTarget{config: ProductionLibraryConfig{ObjectsBucket: "kgseal-1029a"}, + storageBackend: "target-backend"} + row := map[string]json.RawMessage{"physical_path": json.RawMessage(`"storage://source-backend/minio://kgmaint-1028a/10001/k/ab.md"`)} + if err := target.rowTransform("resources")(row); err != nil { + t.Fatalf("row transform failed: %v", err) + } + var got string + if err := json.Unmarshal(row["physical_path"], &got); err != nil { + t.Fatal(err) + } + if got != "storage://target-backend/minio://kgseal-1029a/10001/k/ab.md" { + t.Fatalf("transform=%q", got) + } +}