fix(library): 资源定位符同时改到目标存储后端
build / build (push) Successful in 1m16s

storage://<id> 是来源实例 storage_backends 的主键;只换桶不改 id 时,目标原版
应用以 storage backend not found 落回默认服务并拒绝整条路径(真实下载 500
code1007)。写入前按租户+目标桶解析目标后端,写入与校验共用同一份行变换;
解析不到就拒绝写入,不再产出目标读不出来的定位符。

试过:目标实例真实同步后 /api/v1/knowledge/{id}/download 读回 500 → 修后复测;
go test ./internal/releaseadapter/v071 与 make verify 15 项全绿。
This commit is contained in:
姚章浩
2026-10-29 07:04:45 +08:00
parent c2402947a9
commit 1aecd9d6af
4 changed files with 103 additions and 5 deletions
+5
View File
@@ -26,6 +26,11 @@
### Fixed
- **同步过去的资源行指向来源实例的存储后端 id**:`storage://<id>/minio://<bucket>/<key>`
里的 `<id>` 是来源 `storage_backends` 的主键,目标实例没有这条行。只换桶不改 id 时,
原版应用落回默认存储服务并拒绝整条路径(真实下载 500 `code 1007
invalid MinIO file path`)。现在写入前按"租户 + 目标桶名"在目标库解析后端 id,
写入与校验共用同一份行变换;解析不到就拒绝写入,不产出目标读不出来的定位符。
- 指纹对空白敏感会让"同一份内容"两边算不出同一个值(PostgreSQL `to_jsonb::text`
带空格、Go `json.Marshal` 不带);指纹改为先 `json.Compact` 再摘要。
@@ -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("归档不可用")
@@ -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://<id>/minio://<bucket>/<key> 里的 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, "://")
@@ -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://<id> 是来源实例 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)
}
}