Files
knowledge-governance/internal/releaseadapter/v071/library_scope_remap_test.go
T
姚章浩 5defe238e0
build / build (push) Successful in 1m34s
fix(library): 跨范围同步按目标范围改写并校验残留
真实事故:源 KB(10000/0c84a098) 同步到目标 KB(10001/e64e82b5) 时,归档行被原样
插入目标库,落在来源租户/知识库;资源阶段随即报"生产缺少资源 ... 的存储路径"
(资源查询按目标租户过滤,看不见刚写进去的行)。回滚只按目标范围回放,于是页面
报"已恢复到预备份",目标却多出 4 文件/38 分块/21 资源/4 绑定。

改:
- 写入与校验共用 retargetRow:tenant_id / knowledge_base_id / 资源定位符(后端、
  provider、桶、租户前缀) / storage_backend_id / provider / embedding_model_id
  统一映射到目标范围;行里的来源身份对不上就失败,宁可不写。
- Verify 增加"本次写集合仍以来源身份残留"判定;Restore 增加整库指纹比对(预备份
  时刻 vs 恢复后)与孤儿资源清理,回滚后用 Verify 复核,恢复工具退出 0 不再等于
  恢复完成。
- 新增 cmd/libraryresidue:按归档身份精确、可备份、可复核地清理已知的范围外残留。

验证:make verify 全绿;真实 PG+ES 用例复现"源目标 KB 不同 + 目标非空 + 资源阶段
失败",断言回滚后无来源范围残留、其它租户行未动、恢复后内容=预备份;正向用例断言
行落在目标范围且资源定位符指向目标存储。
2026-10-29 22:11:59 +08:00

195 lines
7.7 KiB
Go

package v071
import (
"encoding/json"
"strings"
"testing"
)
// 真实事故的形状:来源 KB 与目标 KB 不同(租户也不同),归档行原样写进目标,
// 目标范围的删除/校验/恢复都看不到它们。这里把"必须改写哪些列"钉死。
func scopeTargetFixture() *ProductionLibraryTarget {
return &ProductionLibraryTarget{
scope: ProductionScope{TenantID: 10001, KnowledgeBaseID: "kb-target"},
config: ProductionLibraryConfig{ObjectsBucket: "kgseal-1029a"},
storageBackend: "backend-target",
storageProvider: "minio",
snapshotTenant: 10000,
snapshotKB: "kb-source",
}
}
func rawRow(t *testing.T, fields map[string]any) map[string]json.RawMessage {
t.Helper()
row := make(map[string]json.RawMessage, len(fields))
for key, value := range fields {
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("encode %s: %v", key, err)
}
row[key] = encoded
}
return row
}
func fieldText(t *testing.T, row map[string]json.RawMessage, key string) string {
t.Helper()
var value string
if err := json.Unmarshal(row[key], &value); err != nil {
t.Fatalf("%s not a string: %v", key, err)
}
return value
}
func fieldUint(t *testing.T, row map[string]json.RawMessage, key string) uint64 {
t.Helper()
var value uint64
if err := json.Unmarshal(row[key], &value); err != nil {
t.Fatalf("%s not a number: %v", key, err)
}
return value
}
func TestRetargetRowMapsSourceScopeToTarget(t *testing.T) {
target := scopeTargetFixture()
for _, table := range []string{"knowledges", "chunks", "knowledge_tags", "wiki_pages", "wiki_folders"} {
row := rawRow(t, map[string]any{"id": "row-1", "tenant_id": 10000, "knowledge_base_id": "kb-source"})
if err := target.retargetRow(table, row); err != nil {
t.Fatalf("%s retarget: %v", table, err)
}
if got := fieldUint(t, row, "tenant_id"); got != 10001 {
t.Fatalf("%s tenant_id = %d, want 10001", table, got)
}
if got := fieldText(t, row, "knowledge_base_id"); got != "kb-target" {
t.Fatalf("%s knowledge_base_id = %s, want kb-target", table, got)
}
}
// 共享表只有租户列:不得凭空写一个 knowledge_base_id 出来。
binding := rawRow(t, map[string]any{"id": "bind-1", "tenant_id": 10000, "owner_type": "knowledge", "owner_id": "row-1"})
if err := target.retargetRow("resource_bindings", binding); err != nil {
t.Fatalf("resource_bindings retarget: %v", err)
}
if got := fieldUint(t, binding, "tenant_id"); got != 10001 {
t.Fatalf("binding tenant_id = %d, want 10001", got)
}
if _, ok := binding["knowledge_base_id"]; ok {
t.Fatal("resource_bindings must not gain a knowledge_base_id column")
}
// knowledge_tag_relations 没有范围列:归属走父表,改写必须是空操作。
relation := rawRow(t, map[string]any{"knowledge_id": "row-1", "tag_id": "tag-1"})
if err := target.retargetRow("knowledge_tag_relations", relation); err != nil {
t.Fatalf("knowledge_tag_relations retarget: %v", err)
}
if len(relation) != 2 {
t.Fatalf("knowledge_tag_relations must stay untouched, got %v", relation)
}
}
// 文档行的 embedding 模型必须换成目标知识库绑定的那一个:来源模型 id 在目标
// 实例里通常不存在,留着它检索时的查询向量与库里向量就不是同一套。
func TestRetargetRowUsesTargetEmbeddingModel(t *testing.T) {
target := scopeTargetFixture()
target.targetEmbeddingModel = "model-target"
row := rawRow(t, map[string]any{"id": "doc-1", "tenant_id": 10000, "knowledge_base_id": "kb-source",
"embedding_model_id": "model-source"})
if err := target.retargetRow("knowledges", row); err != nil {
t.Fatalf("retarget: %v", err)
}
if got := fieldText(t, row, "embedding_model_id"); got != "model-target" {
t.Fatalf("embedding_model_id = %s, want model-target", got)
}
// 目标没绑模型时不猜:保持来源值,交给契约校验去发现不一致。
plain := scopeTargetFixture()
row = rawRow(t, map[string]any{"id": "doc-1", "tenant_id": 10000, "knowledge_base_id": "kb-source",
"embedding_model_id": "model-source"})
if err := plain.retargetRow("knowledges", row); err != nil {
t.Fatalf("retarget without target model: %v", err)
}
if got := fieldText(t, row, "embedding_model_id"); got != "model-source" {
t.Fatalf("embedding_model_id = %s, want unchanged", got)
}
}
func TestRetargetRowFailsClosedOutsideSnapshotScope(t *testing.T) {
target := scopeTargetFixture()
cases := map[string]map[string]any{
"foreign tenant": {"id": "x", "tenant_id": 20000, "knowledge_base_id": "kb-source"},
"foreign knowledge": {"id": "x", "tenant_id": 10000, "knowledge_base_id": "kb-other"},
"missing tenant": {"id": "x", "knowledge_base_id": "kb-source"},
"missing knowledge kb": {"id": "x", "tenant_id": 10000},
}
for name, fields := range cases {
if err := target.retargetRow("knowledges", rawRow(t, fields)); err == nil {
t.Fatalf("%s: retarget must fail closed", name)
}
}
// 快照来源未知时不许猜目标范围。
unknown := &ProductionLibraryTarget{scope: ProductionScope{TenantID: 10001, KnowledgeBaseID: "kb-target"}}
if err := unknown.retargetRow("knowledges", rawRow(t, map[string]any{"id": "x", "tenant_id": 10000, "knowledge_base_id": "kb"})); err == nil {
t.Fatal("unknown snapshot scope must fail closed")
}
}
func TestRetargetRowRewritesResourceStorageIdentity(t *testing.T) {
target := scopeTargetFixture()
row := rawRow(t, map[string]any{
"id": "res-1",
"tenant_id": 10000,
"storage_backend_id": "backend-source",
"provider": "oss",
"physical_path": "storage://backend-source/oss://yzh-weknora-test/weknora/10000/res-1/a.png",
})
if err := target.retargetRow("resources", row); err != nil {
t.Fatalf("retarget resource: %v", err)
}
if got := fieldUint(t, row, "tenant_id"); got != 10001 {
t.Fatalf("resource tenant = %d, want 10001", got)
}
want := "storage://backend-target/minio://kgseal-1029a/10001/res-1/a.png"
if got := fieldText(t, row, "physical_path"); got != want {
t.Fatalf("physical_path = %q, want %q", got, want)
}
if got := fieldText(t, row, "provider"); got != "minio" {
t.Fatalf("provider = %q, want minio", got)
}
if got := fieldText(t, row, "storage_backend_id"); got != "backend-target" {
t.Fatalf("storage_backend_id = %q, want backend-target", got)
}
}
func TestRetargetKeyTenantDropsSourcePrefix(t *testing.T) {
cases := []struct{ key, want string }{
{"weknora/10000/exports/a.png", "10001/exports/a.png"},
{"10000/knowledge/file.md", "10001/knowledge/file.md"},
{"other/prefix/a.png", "other/prefix/a.png"},
{"10000", "10000"},
}
for _, tc := range cases {
if got := retargetKeyTenant(tc.key, 10000, 10001); got != tc.want {
t.Fatalf("retargetKeyTenant(%q) = %q, want %q", tc.key, got, tc.want)
}
}
// 同租户不编码:键必须原样返回。
if got := retargetKeyTenant("weknora/10000/a.png", 10000, 10000); got != "weknora/10000/a.png" {
t.Fatalf("same tenant must keep key, got %q", got)
}
}
func TestRetargetLocatorRejectsMalformed(t *testing.T) {
if _, err := retargetLocator(" not-a-locator ", "backend", "minio", "bucket", 10000, 10001); err == nil {
t.Fatal("locator with surrounding spaces must be rejected")
}
if _, err := retargetLocator("storage://backend/oss://", "backend", "minio", "bucket", 10000, 10001); err == nil {
t.Fatal("locator without object key must be rejected")
}
// 没有目标桶(测试夹具)时不改写,保持原值可继续用于采集/校验。
locator := "storage://backend/oss://bucket/key"
if got, err := retargetLocator(locator, "backend", "minio", "", 10000, 10001); err != nil || got != locator {
t.Fatalf("empty bucket must be a no-op, got %q err=%v", got, err)
}
if !strings.Contains(retargetKeyTenant("weknora/10000/a", 10000, 10001), "10001") {
t.Fatal("tenant segment must be replaced")
}
}