From e5fa063d7d9b6bb16369ff1d2f67a0cb7160cab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Thu, 8 Oct 2026 19:06:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20wiki=20=E9=80=9A=E9=81=93?= =?UTF-8?q?=E6=8C=89=E5=BD=92=E5=B1=9E=E5=88=A4=E5=AE=9A=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=EF=BC=8C=E5=85=B1=E4=BA=AB=E7=9F=A5=E8=AF=86=E5=BA=93=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A2=AB=E6=95=B4=E5=BA=93=E6=B8=85=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WeKnora 的 wiki 图谱与页面同表(wiki_pages),旧的库级 DELETE + 灌包在共享真实 知识库上会把其他文档的页面与 agent 手写页一起抹掉(2026-10-08 生产演练现场)。 - 新增 internal/releaseadapter/v071/wiki_scope.go:按 source_refs 判定归属 (全部引用属于本包 = InScope;引用包外知识 = 外来页;无引用 = index/log/ agent 页永不触碰),目标库存在外来页时降级为只替换包内页集,外来页上仅摘掉 本包知识引用,包内库级页不写;包内会写入的页与外来页同 slug 时 fail-closed 拒绝发布且不落任何改动。 - Replace 与 ReplaceWiki(撤权/回滚)共用同一套判定,不再整库清空。 - 作用域以一行日志落盘:release wiki replace scope in_scope=… degraded=… - 测试:单测 7 条(纯函数判定)+ 真库集成 3 条(共享库保护 / slug 冲突拒绝 / 镜像库整库替换不变),集成用例只用带随机后缀的临时知识库并自清理。 - 文档:P6 新增「wiki 通道的归属门禁」,P8 补验收点与镜像前提。 --- docs/P6_RELEASE_WORKER_ACCEPTANCE.md | 20 + docs/P8_PRODUCTION_KB_MAPPING_ACCEPTANCE.md | 9 +- .../releaseadapter/v071/postgres_import.go | 34 +- internal/releaseadapter/v071/wiki_scope.go | 374 +++++++++++++++ .../v071/wiki_scope_integration_test.go | 453 ++++++++++++++++++ .../releaseadapter/v071/wiki_scope_test.go | 151 ++++++ 6 files changed, 1017 insertions(+), 24 deletions(-) create mode 100644 internal/releaseadapter/v071/wiki_scope.go create mode 100644 internal/releaseadapter/v071/wiki_scope_integration_test.go create mode 100644 internal/releaseadapter/v071/wiki_scope_test.go diff --git a/docs/P6_RELEASE_WORKER_ACCEPTANCE.md b/docs/P6_RELEASE_WORKER_ACCEPTANCE.md index 3c31864..e363db6 100644 --- a/docs/P6_RELEASE_WORKER_ACCEPTANCE.md +++ b/docs/P6_RELEASE_WORKER_ACCEPTANCE.md @@ -256,6 +256,26 @@ tenant_id + knowledge_base_id + knowledge_id - Chunk seq_id 冲突时整个事务失败,不静默改 ID。 - 不调用 WeKnora 解析和 Embedding。 +### wiki 通道的归属门禁(2026-10-08 起) + +WeKnora 的 wiki 图谱与页面同表(`wiki_pages`,节点/边靠 slug + `in_links`/`out_links` 表达), +所以"库级快照整库替换"在**共享真实知识库**上会把其他文档的页面和 agent 手写页一起抹掉。 +导入端现在先判定归属,再决定写入范围: + +- 归属来自页面 `source_refs`(形如 `|`;`chunk_refs` 只有 chunk UUID, + 不带知识 id)。一页引用的知识**全部属于本包**才算 `InScope`;引用包外知识的是外来页;完全没有 + 引用的页(`index`/`log`、agent 手写页)永不触碰。 +- **目标库没有外来页** → 镜像库对原件的前提成立,保持整库替换语义(行为与旧实现一致,仍不删 + "无引用且不在包内"的页)。 +- **目标库存在外来页** → 自动降级为"只替换包内页集":按 slug 删除重建包内页、只删包内 folder id、 + 外来页上仅摘掉本包知识的引用(正文不动)、包内库级页(`index`/`log`)不写以免毁掉目标库自己的聚合。 + 作用域以一行日志落盘:`release wiki replace scope in_scope=… foreign=… foreign_ref_stripped=… + untouched=… skipped_library=… degraded=…`。 +- **包内会写入的页与外来页同 slug** → 拒绝发布(fail-closed),事务不落任何改动,错误信息点名该 slug。 +- 撤权(`delete`)与回滚走同一套判定(`ReplaceWiki`),不再整库清空。 +- 已知取舍:降级路径上共享页的正文仍由目标库的库级合成结果决定,无法在不重算(不额外烧 token)的 + 前提下与本包一致;这类页只做引用级差集,并在作用域日志里计入 `foreign_ref_stripped`。 + ## Elasticsearch 通道 源导出: diff --git a/docs/P8_PRODUCTION_KB_MAPPING_ACCEPTANCE.md b/docs/P8_PRODUCTION_KB_MAPPING_ACCEPTANCE.md index 8567cda..4c7487f 100644 --- a/docs/P8_PRODUCTION_KB_MAPPING_ACCEPTANCE.md +++ b/docs/P8_PRODUCTION_KB_MAPPING_ACCEPTANCE.md @@ -59,4 +59,11 @@ target.KnowledgeBaseID == mapping.TargetKnowledgeBaseID (目标 1. `go test ./...`、`go vet`、前端 `tsc` + `vitest` 全绿;迁移 44 up/down 往返可用。 2. 单元/集成:映射生效后 `knowledges/chunks/wiki_pages` 的 KB 列、ES 文档字段均为目标库;摘要随改写重算;未绑定退回同 ID。 3. 界面:系统设置可选目标库(列真实库名),保存时对目标 WeKnora 真校验(无效 ID 拒绝);发布管理显示本次目标库。 -4. 真实跨环境:1 份文档发布到目标库 → 三通道读回一致 → 官方 API 检索命中 → 撤权后零命中 → 回滚恢复。 \ No newline at end of file +4. 真实跨环境:1 份文档发布到目标库 → 三通道读回一致 → 官方 API 检索命中 → 撤权后零命中 → 回滚恢复。 +5. **wiki 归属门禁(2026-10-08 起)**:目标库存在引用包外知识的页时,发布自动降级为"只替换包内页集", + 外来页 / agent 手写页 / 目标库自己的 `index`-`log` 一行不动;包内会写入的页与外来页同 slug 时拒绝发布 + 且事务不留任何改动。真库集成用例:`TestWikiReplaceOnSharedKnowledgeBaseKeepsForeignPages`、 + `TestWikiReplaceRefusesWhenPackageSlugBelongsToForeignPage`、`TestWikiReplaceOnFaithfulMirrorReplacesWholeLibrary` + (`TEST_WEKNORA_V071_TARGET_DATABASE_URL` 指向 v0.7.1 库时运行,只使用带随机后缀的临时知识库并自清理)。 +6. 跨库对发布的前置条件:**测试库与生产库承载同一批文档**(镜像)时 wiki 才能保持"测试=生产"; + 只做单文档发布时,共享页正文不保证两边一致(见 P6「wiki 通道的归属门禁」)。 \ No newline at end of file diff --git a/internal/releaseadapter/v071/postgres_import.go b/internal/releaseadapter/v071/postgres_import.go index 6cf0363..ef22921 100644 --- a/internal/releaseadapter/v071/postgres_import.go +++ b/internal/releaseadapter/v071/postgres_import.go @@ -119,29 +119,25 @@ WHERE (id = $1 OR handle = $2) {`DELETE FROM knowledge_tag_relations WHERE knowledge_id = $1`, []any{identity.KnowledgeID}}, {`DELETE FROM chunks WHERE tenant_id = $1 AND knowledge_base_id = $2 AND knowledge_id = $3`, []any{identity.TenantID, identity.KnowledgeBaseID, identity.KnowledgeID}}, } - if hasWiki { - // wiki 是库级快照:整库替换(幂等),不做单文档 delta。 - clearStatements = append(clearStatements, - clearStatement{`DELETE FROM wiki_pages WHERE tenant_id = $1 AND knowledge_base_id = $2`, []any{identity.TenantID, identity.KnowledgeBaseID}}, - clearStatement{`DELETE FROM wiki_folders WHERE tenant_id = $1 AND knowledge_base_id = $2`, []any{identity.TenantID, identity.KnowledgeBaseID}}, - ) - } for _, statement := range clearStatements { if _, err := tx.Exec(ctx, statement.sql, statement.args...); err != nil { return errors.New("clear prior target document rows") } } insertOrder := []string{"knowledges", "resources", "chunks", "knowledge_tags", "knowledge_tag_relations", "resource_bindings"} - if hasWiki { - // 文件夹先于页面:页面通过 folder_id 引用文件夹。 - insertOrder = append(insertOrder, "wiki_folders", "wiki_pages") - } for _, tableName := range insertOrder { table := prepared.Table(tableName) if err := insertSnapshotRows(ctx, tx, table); err != nil { return err } } + // wiki 是库级快照,但不能盲删整库:先按 source_refs 判定归属,只替换本包 + // 拥有的页面,其余(其他文档、agent 手写页)一行不动。 + if hasWiki { + if _, err := importer.replaceWiki(ctx, tx, identity, prepared); err != nil { + return err + } + } if _, err := tx.Exec(ctx, `SELECT setval('chunks_seq_id_seq', GREATEST(COALESCE((SELECT MAX(seq_id) FROM chunks), 1), 1), true)`); err != nil { return errors.New("advance target chunk sequence") } @@ -191,18 +187,10 @@ func (importer *PostgresImporter) ReplaceWiki(ctx context.Context, identity Post if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 1))`, identity.KnowledgeBaseID); err != nil { return errors.New("lock target wiki knowledge base") } - for _, statement := range []clearStatement{ - {`DELETE FROM wiki_pages WHERE tenant_id = $1 AND knowledge_base_id = $2`, []any{identity.TenantID, identity.KnowledgeBaseID}}, - {`DELETE FROM wiki_folders WHERE tenant_id = $1 AND knowledge_base_id = $2`, []any{identity.TenantID, identity.KnowledgeBaseID}}, - } { - if _, err := tx.Exec(ctx, statement.sql, statement.args...); err != nil { - return errors.New("clear prior target wiki rows") - } - } - for _, table := range tables { - if err := insertSnapshotRows(ctx, tx, table); err != nil { - return err - } + // 撤权 / 回滚也走同一套归属判定:只撤本包知识拥有的页面与引用, + // 目标库里其他文档和 agent 手写的内容一行不动。 + if _, err := importer.replaceWiki(ctx, tx, identity, snapshot); err != nil { + return err } if err := tx.Commit(ctx); err != nil { return errors.New("commit PostgreSQL wiki import") diff --git a/internal/releaseadapter/v071/wiki_scope.go b/internal/releaseadapter/v071/wiki_scope.go new file mode 100644 index 0000000..4438034 --- /dev/null +++ b/internal/releaseadapter/v071/wiki_scope.go @@ -0,0 +1,374 @@ +package v071 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + + "github.com/jackc/pgx/v5" +) + +// wikiReplaceScope 描述一次库级 wiki 替换的真实作用域,以及它是否被降级。 +// +// 背景:WeKnora 的 wiki 图谱与页面同表(wiki_pages,节点/边靠 slug + in_links +// / out_links 表达),旧的"整库 DELETE + 灌包"在**共享真实知识库**上会把其他 +// 文档的页面和 agent 手写页一起抹掉(2026-10-08 生产演练的现场)。 +// +// 现在的判定规则: +// +// - 目标页的 source_refs 形如 "|",只有引用的知识 +// 全部属于"本包覆盖的知识集合"时,这一页才归本包管(InScope); +// - 引用了包外知识的页是外来页(Foreign),永不删除; +// - 完全没有 source_refs/chunk_refs 的页(index/log、agent 手写页)永不删除; +// - 外来页与包内页同 slug 时无法安全处理 → 拒绝发布(fail-closed)。 +// +// 目标库没有外来页时("镜像库 → 原件"前提成立)仍走整库替换,行为与旧实现一致; +// 只要出现外来页就降级为"只替换包内页集",其余页面一行不动。 +type wikiReplaceScope struct { + InScope int // 包内知识拥有、会被删除重建的页 + Foreign int // 引用了包外知识的页,永不删除 + ForeignReferencingIn int // 外来页中仍引用本包知识的页(只摘引用,不动正文) + Untouched int // 无引用来源的页(index/log/agent),永不触碰 + SkippedLibraryPages int // 降级时被跳过的库级页(index/log 不能由部分快照重写) + Degraded bool // 目标库存在外来页 → 只替换本包页集 +} + +func (scope wikiReplaceScope) summary() string { + return fmt.Sprintf("in_scope=%d foreign=%d foreign_ref_stripped=%d untouched=%d skipped_library=%d degraded=%t", + scope.InScope, scope.Foreign, scope.ForeignReferencingIn, scope.Untouched, scope.SkippedLibraryPages, scope.Degraded) +} + +// targetWikiRef 是目标库现存 wiki 页面的归属信息。 +type targetWikiRef struct { + ID string + Slug string + SourceRefs []string + ChunkRefs []string +} + +const targetWikiRefsSQL = ` +SELECT id, slug, COALESCE(source_refs, '[]'::jsonb), COALESCE(chunk_refs, '[]'::jsonb) +FROM wiki_pages +WHERE tenant_id = $1 AND knowledge_base_id = $2 AND deleted_at IS NULL` + +// packageWikiOwners 返回本包 wiki 快照覆盖的知识 id 集合:快照里 knowledges +// 行的 id,加上所有页面 source_refs 的归属。 +// +// 镜像库对原件发布时这个集合是整个库(几百上千篇),单文档包发布时它只有一篇 +// ——所以同一套判定既能保住"整库替换"的镜像语义,又能在共享库上自动收敛。 +func packageWikiOwners(snapshot PostgresSnapshot) (map[string]bool, error) { + owners := map[string]bool{} + if tablePresent(snapshot, "knowledges") { + for _, raw := range snapshot.Table("knowledges").Rows { + var row struct { + ID string `json:"id"` + } + if json.Unmarshal(raw, &row) != nil || !validSearchIdentifier(row.ID) { + return nil, errors.New("invalid package knowledge row") + } + owners[row.ID] = true + } + } + if tablePresent(snapshot, "wiki_pages") { + for _, raw := range snapshot.Table("wiki_pages").Rows { + page, err := decodeWikiRow(raw) + if err != nil { + return nil, err + } + for _, reference := range stringList(page["source_refs"]) { + if owner := referenceOwner(reference); owner != "" { + owners[owner] = true + } + } + } + } + if len(owners) == 0 { + return nil, errors.New("package wiki snapshot carries no owning knowledge") + } + return owners, nil +} + +// referenceOwner 取 source_refs 条目的知识 id:"|"。 +func referenceOwner(reference string) string { + if index := strings.Index(reference, "|"); index > 0 { + reference = reference[:index] + } + if !validSearchIdentifier(reference) { + return "" + } + return reference +} + +// classifyTargetWikiPages 判定目标库里每一页的归属(纯函数,便于单测)。 +// 返回作用域、将被替换的包内 slug,以及"仍引用本包知识的外来页"。 +func classifyTargetWikiPages(pages []targetWikiRef, owners map[string]bool) (wikiReplaceScope, []string, []targetWikiRef) { + scope := wikiReplaceScope{} + inScopeSlugs := make([]string, 0, len(pages)) + foreignReferencingIn := make([]targetWikiRef, 0) + for _, page := range pages { + if page.Slug == "" || (!validSearchIdentifier(page.ID)) { + // 结构异常的页按外来页处理:不删、不改。 + scope.Foreign++ + continue + } + if len(page.SourceRefs) == 0 && len(page.ChunkRefs) == 0 { + scope.Untouched++ + continue + } + if len(page.SourceRefs) == 0 { + // 只有 chunk_refs(chunk UUID 不带知识 id)时无法归属 → 按外来页处理。 + scope.Foreign++ + continue + } + pageOwners := make([]string, 0, len(page.SourceRefs)) + mine, malformed := 0, false + for _, reference := range page.SourceRefs { + owner := referenceOwner(reference) + if owner == "" { + malformed = true + break + } + pageOwners = append(pageOwners, owner) + if owners[owner] { + mine++ + } + } + if malformed { + scope.Foreign++ + continue + } + if mine == len(pageOwners) { + scope.InScope++ + inScopeSlugs = append(inScopeSlugs, page.Slug) + continue + } + scope.Foreign++ + if mine > 0 { + scope.ForeignReferencingIn++ + foreignReferencingIn = append(foreignReferencingIn, page) + } + } + scope.Degraded = scope.Foreign > 0 + return scope, inScopeSlugs, foreignReferencingIn +} + +// replaceWiki 是发布 / 撤权 / 回滚共用的 wiki 通道:先判定归属,再按作用域写入。 +// 必须在已经持有 knowledge base 维度 advisory lock 的事务里调用。 +func (importer *PostgresImporter) replaceWiki(ctx context.Context, tx pgx.Tx, identity PostgresIdentity, snapshot PostgresSnapshot) (wikiReplaceScope, error) { + tables, hasWiki := wikiSnapshotRows(snapshot) + if !hasWiki { + // 老包 / 未启用 wiki 的库:不动目标 wiki。 + return wikiReplaceScope{}, nil + } + pageRows := snapshot.Table("wiki_pages").Rows + folderRows := snapshot.Table("wiki_folders").Rows + bundleSlugs := make([]string, 0, len(pageRows)) + for _, raw := range pageRows { + var row struct { + Slug string `json:"slug"` + } + if json.Unmarshal(raw, &row) != nil || row.Slug == "" { + return wikiReplaceScope{}, errors.New("invalid package wiki page slug") + } + bundleSlugs = append(bundleSlugs, row.Slug) + } + folderIDs := make([]string, 0, len(folderRows)) + for _, raw := range folderRows { + var row struct { + ID string `json:"id"` + } + if json.Unmarshal(raw, &row) != nil || row.ID == "" { + return wikiReplaceScope{}, errors.New("invalid package wiki folder id") + } + folderIDs = append(folderIDs, row.ID) + } + owners, err := packageWikiOwners(snapshot) + if err != nil { + return wikiReplaceScope{}, err + } + targetPages, err := loadTargetWikiRefs(ctx, tx, identity) + if err != nil { + return wikiReplaceScope{}, err + } + scope, inScopeSlugs, foreignReferencingIn := classifyTargetWikiPages(targetPages, owners) + // 外来页与包内会写入的页同 slug:两边正文不同、又都不该被覆盖 → 拒绝发布。 + bundleRefBearing := make(map[string]bool, len(bundleSlugs)) + for _, raw := range pageRows { + page, err := decodeWikiRow(raw) + if err != nil { + return scope, err + } + if len(stringList(page["source_refs"])) == 0 && len(stringList(page["chunk_refs"])) == 0 { + continue + } + slug, _ := page["slug"].(string) + bundleRefBearing[slug] = true + } + if slug, collided := wikiSlugCollision(targetPages, inScopeSlugs, bundleRefBearing); collided { + return scope, fmt.Errorf("目标知识库的 wiki 页 %s 由其他文档或人工维护,发布包无法安全覆盖,已拒绝发布(%s)", slug, scope.summary()) + } + if scope.Degraded { + // 降级:只摘掉外来页上对本包知识的引用(正文不动,避免破坏库级合成结果)。 + if err := stripForeignWikiRefs(ctx, tx, identity, foreignReferencingIn, owners, chunkIDSet(snapshot)); err != nil { + return scope, err + } + if len(inScopeSlugs) > 0 { + if _, err := tx.Exec(ctx, ` +DELETE FROM wiki_pages +WHERE tenant_id = $1 AND knowledge_base_id = $2 AND slug = ANY($3::text[]) +`, identity.TenantID, identity.KnowledgeBaseID, inScopeSlugs); err != nil { + return scope, errors.New("clear in-scope target wiki pages") + } + } + if len(folderIDs) > 0 { + if _, err := tx.Exec(ctx, ` +DELETE FROM wiki_folders +WHERE tenant_id = $1 AND knowledge_base_id = $2 AND id = ANY($3::text[]) +`, identity.TenantID, identity.KnowledgeBaseID, folderIDs); err != nil { + return scope, errors.New("clear package target wiki folders") + } + } + filteredPages := make([]json.RawMessage, 0, len(pageRows)) + for _, raw := range pageRows { + page, err := decodeWikiRow(raw) + if err != nil { + return scope, err + } + if len(stringList(page["source_refs"])) == 0 && len(stringList(page["chunk_refs"])) == 0 { + // index/log 是库级聚合页,部分快照重写会毁掉目标库自己的聚合。 + scope.SkippedLibraryPages++ + continue + } + filteredPages = append(filteredPages, raw) + } + if err := insertSnapshotRows(ctx, tx, newTableSnapshot("wiki_folders", folderRows)); err != nil { + return scope, err + } + if err := insertSnapshotRows(ctx, tx, newTableSnapshot("wiki_pages", filteredPages)); err != nil { + return scope, err + } + log.Printf("release wiki replace scope %s (只替换包内页集,目标库其余页面未改动)", scope.summary()) + return scope, nil + } + // 无外来页:镜像库对原件的正常路径,整库替换(与旧行为一致), + // 但保留"无引用来源且不在包内"的页面(agent 手写页等)。 + if _, err := tx.Exec(ctx, ` +DELETE FROM wiki_pages +WHERE tenant_id = $1 AND knowledge_base_id = $2 + AND ((COALESCE(source_refs, '[]'::jsonb) <> '[]'::jsonb) OR (COALESCE(chunk_refs, '[]'::jsonb) <> '[]'::jsonb) + OR slug = ANY($3::text[])) +`, identity.TenantID, identity.KnowledgeBaseID, bundleSlugs); err != nil { + return scope, errors.New("clear target wiki pages") + } + if _, err := tx.Exec(ctx, ` +DELETE FROM wiki_folders AS folder +WHERE folder.tenant_id = $1 AND folder.knowledge_base_id = $2 + AND (folder.id = ANY($3::text[]) + OR NOT EXISTS (SELECT 1 FROM wiki_pages AS page + WHERE page.knowledge_base_id = folder.knowledge_base_id + AND page.folder_id = folder.id AND page.deleted_at IS NULL)) +`, identity.TenantID, identity.KnowledgeBaseID, folderIDs); err != nil { + return scope, errors.New("clear target wiki folders") + } + for _, table := range tables { + if err := insertSnapshotRows(ctx, tx, table); err != nil { + return scope, err + } + } + log.Printf("release wiki replace scope %s", scope.summary()) + return scope, nil +} + +func loadTargetWikiRefs(ctx context.Context, tx pgx.Tx, identity PostgresIdentity) ([]targetWikiRef, error) { + rows, err := tx.Query(ctx, targetWikiRefsSQL, identity.TenantID, identity.KnowledgeBaseID) + if err != nil { + return nil, errors.New("read target wiki page ownership") + } + defer rows.Close() + pages := make([]targetWikiRef, 0) + for rows.Next() { + var page targetWikiRef + var sourceRefs, chunkRefs []byte + if err := rows.Scan(&page.ID, &page.Slug, &sourceRefs, &chunkRefs); err != nil { + return nil, errors.New("read target wiki page ownership") + } + if json.Unmarshal(sourceRefs, &page.SourceRefs) != nil || json.Unmarshal(chunkRefs, &page.ChunkRefs) != nil { + // 非数组结构(异常行)按"无法归属"处理:不删、不改。 + page.SourceRefs = nil + page.ChunkRefs = []string{""} + } + pages = append(pages, page) + } + if rows.Err() != nil { + return nil, errors.New("read target wiki page ownership") + } + return pages, nil +} + +// stripForeignWikiRefs 在外来页上只摘掉本包知识的引用,正文一行不改。 +func stripForeignWikiRefs(ctx context.Context, tx pgx.Tx, identity PostgresIdentity, pages []targetWikiRef, owners map[string]bool, removedChunks map[string]bool) error { + for _, page := range pages { + keptSources := make([]string, 0, len(page.SourceRefs)) + for _, reference := range page.SourceRefs { + if !owners[referenceOwner(reference)] { + keptSources = append(keptSources, reference) + } + } + keptChunks := make([]string, 0, len(page.ChunkRefs)) + for _, reference := range page.ChunkRefs { + if !removedChunks[reference] { + keptChunks = append(keptChunks, reference) + } + } + if len(keptSources) == len(page.SourceRefs) && len(keptChunks) == len(page.ChunkRefs) { + continue + } + encodedSources, _ := json.Marshal(keptSources) + encodedChunks, _ := json.Marshal(keptChunks) + if _, err := tx.Exec(ctx, ` +UPDATE wiki_pages SET source_refs = $3::jsonb, chunk_refs = $4::jsonb, updated_at = NOW() +WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL +`, page.ID, identity.TenantID, string(encodedSources), string(encodedChunks)); err != nil { + return errors.New("strip package wiki references from shared target page") + } + } + return nil +} + +// wikiSlugCollision 找"包内会写入的页 slug 已被目标库外来页占用"的第一个 slug。 +// 命中时两侧正文不同、又都不该被覆盖,只能拒绝发布(fail-closed)。 +// +// bundleRefBearing 是包内**携带引用**的页 slug:只有这些页在降级路径上会真正 +// 写入;包内的库级页(index/log)会被跳过,因此不构成占用。 +func wikiSlugCollision(pages []targetWikiRef, inScopeSlugs []string, bundleRefBearing map[string]bool) (string, bool) { + inScope := make(map[string]bool, len(inScopeSlugs)) + for _, slug := range inScopeSlugs { + inScope[slug] = true + } + for _, page := range pages { + if !bundleRefBearing[page.Slug] || inScope[page.Slug] { + continue + } + return page.Slug, true + } + return "", false +} + +func chunkIDSet(snapshot PostgresSnapshot) map[string]bool { + ids := map[string]bool{} + if !tablePresent(snapshot, "chunks") { + return ids + } + for _, raw := range snapshot.Table("chunks").Rows { + var row struct { + ID string `json:"id"` + } + if json.Unmarshal(raw, &row) == nil && row.ID != "" { + ids[row.ID] = true + } + } + return ids +} diff --git a/internal/releaseadapter/v071/wiki_scope_integration_test.go b/internal/releaseadapter/v071/wiki_scope_integration_test.go new file mode 100644 index 0000000..c33906e --- /dev/null +++ b/internal/releaseadapter/v071/wiki_scope_integration_test.go @@ -0,0 +1,453 @@ +package v071 + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// 这组用例在真实的 WeKnora v0.7.1 库上验证"共享知识库只动本包页集"的语义: +// 源库(测试库)导出 → 重映射到目标库 → 目标库上带外来页/agent 页导入。 +// +// 只使用本次运行新建的临时知识库(id 带随机后缀),结束时按 id 精确清理。 +const wikiGateTenant = 10000 + +type wikiGateFixture struct { + pool *pgxpool.Pool + ctx context.Context + suffix string + sourceK string + targetK string + ours string + theirs string +} + +func newWikiGateFixture(t *testing.T) (*wikiGateFixture, bool) { + t.Helper() + url := os.Getenv("TEST_WEKNORA_V071_TARGET_DATABASE_URL") + if url == "" { + url = os.Getenv("TEST_WEKNORA_V071_DATABASE_URL") + } + if url == "" { + return nil, false + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatal(err) + } + buffer := make([]byte, 4) + if _, err := readRandom(buffer); err != nil { + t.Fatal(err) + } + suffix := hex.EncodeToString(buffer) + fixture := &wikiGateFixture{ + pool: pool, ctx: ctx, suffix: suffix, + sourceK: "kb-wiki-src-" + suffix, targetK: "kb-wiki-tgt-" + suffix, + ours: "knowledge-wiki-src-" + suffix, theirs: "knowledge-wiki-other-" + suffix, + } + t.Cleanup(func() { + fixture.cleanup(t) + pool.Close() + }) + fixture.cleanup(t) + return fixture, true +} + +func readRandom(buffer []byte) (int, error) { + file, err := os.Open("/dev/urandom") + if err != nil { + return 0, err + } + defer file.Close() + return file.Read(buffer) +} + +func (fixture *wikiGateFixture) exec(t *testing.T, sql string, arguments ...any) { + t.Helper() + if _, err := fixture.pool.Exec(fixture.ctx, sql, arguments...); err != nil { + t.Fatalf("exec %s: %v", firstLineSQL(sql), err) + } +} + +func firstLineSQL(sql string) string { + for index, char := range sql { + if char == '\n' { + return sql[:index] + } + } + return sql +} + +func (fixture *wikiGateFixture) cleanup(t *testing.T) { + t.Helper() + for _, table := range []string{"wiki_pages", "wiki_folders", "chunks", "knowledges"} { + if _, err := fixture.pool.Exec(fixture.ctx, + fmt.Sprintf("DELETE FROM %s WHERE knowledge_base_id = ANY($1::text[])", table), + []string{fixture.sourceK, fixture.targetK}); err != nil { + t.Logf("cleanup %s: %v", table, err) + } + } + if _, err := fixture.pool.Exec(fixture.ctx, + `DELETE FROM knowledge_bases WHERE id = ANY($1::text[])`, []string{fixture.sourceK, fixture.targetK}); err != nil { + t.Logf("cleanup knowledge_bases: %v", err) + } +} + +// templateRowID 找一个可复制的真实行,避免手写全部 NOT NULL 列。 +func (fixture *wikiGateFixture) templateRowID(t *testing.T, table string) string { + t.Helper() + column := "knowledge_base_id" + if table == "knowledge_bases" { + column = "id" + } + var id string + if err := fixture.pool.QueryRow(fixture.ctx, + fmt.Sprintf("SELECT id FROM %s WHERE %s <> ALL($1::text[]) AND deleted_at IS NULL LIMIT 1", table, column), + []string{fixture.sourceK, fixture.targetK}).Scan(&id); err != nil { + t.Skipf("no template row in %s: %v", table, err) + } + return id +} + +func (fixture *wikiGateFixture) cloneRow(t *testing.T, table string, templateID string, overrides map[string]any) { + t.Helper() + encoded, err := json.Marshal(overrides) + if err != nil { + t.Fatal(err) + } + fixture.exec(t, fmt.Sprintf(` +INSERT INTO %s +SELECT (jsonb_populate_record(NULL::%s, to_jsonb(template) || $1::jsonb)).* +FROM %s AS template WHERE template.id = $2 +`, table, table, table), string(encoded), templateID) +} + +// seedSourceKnowledgeBase 建一个"测试库":启用 wiki、一篇完成解析的文档 + 一个 chunk +// + 我们自己的页与被撤权页。 +func (fixture *wikiGateFixture) seedSourceKnowledgeBase(t *testing.T) { + t.Helper() + fixture.cloneRow(t, "knowledge_bases", fixture.templateRowID(t, "knowledge_bases"), map[string]any{ + "id": fixture.sourceK, "tenant_id": wikiGateTenant, "name": "wiki-gate-source-" + fixture.suffix, + "indexing_strategy": map[string]any{"wiki_enabled": true}, + }) + fixture.cloneRow(t, "knowledges", fixture.templateRowID(t, "knowledges"), map[string]any{ + "id": fixture.ours, "tenant_id": wikiGateTenant, "knowledge_base_id": fixture.sourceK, + "title": "WikiGate Ours.docx", "parse_status": "completed", "enable_status": "enabled", + "pending_subtasks_count": 0, "deleted_at": nil, + }) + // chunks.seq_id 有唯一索引:克隆必须换成新序号,不能带模板的值。 + var chunkSeq int64 + if err := fixture.pool.QueryRow(fixture.ctx, `SELECT nextval('chunks_seq_id_seq')`).Scan(&chunkSeq); err != nil { + t.Fatal(err) + } + fixture.cloneRow(t, "chunks", fixture.templateRowID(t, "chunks"), map[string]any{ + "id": "chunk-wiki-src-" + fixture.suffix, "tenant_id": wikiGateTenant, + "knowledge_id": fixture.ours, "knowledge_base_id": fixture.sourceK, + "is_enabled": true, "content": "WikiGate source chunk", "deleted_at": nil, + "seq_id": chunkSeq, + }) + fixture.cloneRow(t, "wiki_folders", fixture.templateRowID(t, "wiki_folders"), map[string]any{ + "id": "folder-wiki-src-" + fixture.suffix, "tenant_id": wikiGateTenant, + "knowledge_base_id": fixture.sourceK, "name": "WikiGate " + fixture.suffix, + "parent_id": "", "deleted_at": nil, + }) + for index, slug := range []string{"summary/" + fixture.ours, "index"} { + refs := []string{fixture.ours + "|WikiGate Ours.docx"} + chunkRefs := []string{"chunk-wiki-src-" + fixture.suffix} + if slug == "index" { + refs, chunkRefs = []string{}, []string{} + } + fixture.cloneRow(t, "wiki_pages", fixture.templateRowID(t, "wiki_pages"), map[string]any{ + "id": fmt.Sprintf("page-wiki-src-%s-%d", fixture.suffix, index), "tenant_id": wikiGateTenant, + "knowledge_base_id": fixture.sourceK, "slug": slug, "title": "WikiGate " + slug, + "content": "package content for " + slug, "source_refs": refs, "chunk_refs": chunkRefs, + "folder_id": "folder-wiki-src-" + fixture.suffix, "in_links": []string{}, "out_links": []string{}, + "deleted_at": nil, + }) + } +} + +// seedTargetKnowledgeBase 建一个"共享真实库":别人的页、我们与别人共享的页、agent 手写页、 +// 以及库级 index。 +func (fixture *wikiGateFixture) seedTargetKnowledgeBase(t *testing.T) { + t.Helper() + fixture.cloneRow(t, "knowledge_bases", fixture.templateRowID(t, "knowledge_bases"), map[string]any{ + "id": fixture.targetK, "tenant_id": wikiGateTenant, "name": "wiki-gate-target-" + fixture.suffix, + "indexing_strategy": map[string]any{"wiki_enabled": true}, + }) + fixture.cloneRow(t, "wiki_folders", fixture.templateRowID(t, "wiki_folders"), map[string]any{ + "id": "folder-wiki-theirs-" + fixture.suffix, "tenant_id": wikiGateTenant, + "knowledge_base_id": fixture.targetK, "name": "Theirs " + fixture.suffix, + "parent_id": "", "deleted_at": nil, + }) + pages := []struct { + id, slug, content string + refs []string + }{ + {"page-theirs-" + fixture.suffix, "summary/" + fixture.theirs, "theirs content", []string{fixture.theirs + "|Theirs.xlsx"}}, + {"page-shared-" + fixture.suffix, "entity/shared", "merged content from both documents", []string{fixture.ours + "|WikiGate Ours.docx", fixture.theirs + "|Theirs.xlsx"}}, + {"page-agent-" + fixture.suffix, "concept/hand-made", "agent written page", []string{}}, + {"page-index-" + fixture.suffix, "index", "target library index", []string{}}, + } + for _, page := range pages { + fixture.cloneRow(t, "wiki_pages", fixture.templateRowID(t, "wiki_pages"), map[string]any{ + "id": page.id, "tenant_id": wikiGateTenant, "knowledge_base_id": fixture.targetK, + "slug": page.slug, "title": "Theirs " + page.slug, "content": page.content, + "source_refs": page.refs, "chunk_refs": []string{}, + "folder_id": "folder-wiki-theirs-" + fixture.suffix, "in_links": []string{}, "out_links": []string{}, + "deleted_at": nil, + }) + } +} + +// exportSourceSnapshot 用真实导出器取包,再把库 id 重映射成目标库(与发布链一致)。 +func (fixture *wikiGateFixture) exportSourceSnapshot(t *testing.T) PostgresSnapshot { + t.Helper() + exporter, err := NewPostgresExporter(fixture.pool) + if err != nil { + t.Fatal(err) + } + identity := PostgresIdentity{TenantID: wikiGateTenant, KnowledgeBaseID: fixture.sourceK, KnowledgeID: fixture.ours} + snapshot, err := exporter.Export(fixture.ctx, identity) + if err != nil { + t.Fatalf("export source snapshot: %v", err) + } + // 与文档导出器一致:库级 wiki 快照追加进同一个发布单元。 + wikiTables, err := exporter.ExportWiki(fixture.ctx, identity, "") + if err != nil { + t.Fatalf("export source wiki snapshot: %v", err) + } + snapshot.Tables = append(snapshot.Tables, wikiTables...) + remapped, err := remapSnapshotKnowledgeBase(snapshot, fixture.sourceK, fixture.targetK) + if err != nil { + t.Fatal(err) + } + // 真实部署里源库与目标库在不同环境;本地跑同一个库时,先把源库行清掉, + // 否则目标端"同一 knowledge id 不能属于别的库"的守卫会正确拦住我们。 + fixture.dropSourceRows(t) + return remapped +} + +func (fixture *wikiGateFixture) dropSourceRows(t *testing.T) { + t.Helper() + for _, table := range []string{"wiki_pages", "wiki_folders", "chunks", "knowledges"} { + fixture.exec(t, fmt.Sprintf("DELETE FROM %s WHERE knowledge_base_id = $1", table), fixture.sourceK) + } + fixture.exec(t, `DELETE FROM knowledge_bases WHERE id = $1`, fixture.sourceK) +} + +func remapSnapshotKnowledgeBase(snapshot PostgresSnapshot, sourceID, targetID string) (PostgresSnapshot, error) { + result := cloneSnapshot(snapshot) + remappable := map[string]bool{} + for _, name := range remappableTables { + remappable[name] = true + } + for index, table := range result.Tables { + if !remappable[table.Name] { + // 与发布链一致:只有携带库身份的表演进重映射。 + continue + } + rows := make([]json.RawMessage, 0, len(table.Rows)) + for _, raw := range table.Rows { + rewritten, err := remapKnowledgeBaseField(raw, sourceID, targetID) + if err != nil { + return PostgresSnapshot{}, err + } + rows = append(rows, rewritten) + } + result.Tables[index] = newTableSnapshot(table.Name, rows) + } + return result, nil +} + +func (fixture *wikiGateFixture) pageState(t *testing.T, slug string) (content string, sourceRefs []string, present bool) { + t.Helper() + var rawContent, refs []byte + err := fixture.pool.QueryRow(fixture.ctx, ` +SELECT content, COALESCE(source_refs, '[]'::jsonb) +FROM wiki_pages +WHERE tenant_id = $1 AND knowledge_base_id = $2 AND slug = $3 AND deleted_at IS NULL +`, wikiGateTenant, fixture.targetK, slug).Scan(&rawContent, &refs) + if err != nil { + return "", nil, false + } + if json.Unmarshal(refs, &sourceRefs) != nil { + t.Fatalf("decode refs for %s", slug) + } + return string(rawContent), sourceRefs, true +} + +func TestWikiReplaceOnSharedKnowledgeBaseKeepsForeignPages(t *testing.T) { + fixture, ok := newWikiGateFixture(t) + if !ok { + t.Skip("v0.7.1 integration database URL is not set") + } + fixture.seedSourceKnowledgeBase(t) + fixture.seedTargetKnowledgeBase(t) + snapshot := fixture.exportSourceSnapshot(t) + importer, err := NewPostgresImporter(fixture.pool, PostgresImporterConfig{ + TargetStorageBackendID: "6129cfd1-a26c-41dd-88cb-e6a800daa3bb", TargetProvider: "oss", + TargetBucket: "weknora-gate-test", + }) + if err != nil { + t.Fatal(err) + } + if err := importer.Replace(fixture.ctx, PostgresIdentity{ + TenantID: wikiGateTenant, KnowledgeBaseID: fixture.targetK, KnowledgeID: fixture.ours, + }, snapshot); err != nil { + t.Fatalf("replace on shared knowledge base: %v", err) + } + + // 包内页按快照重建。 + content, refs, present := fixture.pageState(t, "summary/"+fixture.ours) + if !present || content != "package content for summary/"+fixture.ours { + t.Fatalf("in-scope page not replaced: present=%t content=%q", present, content) + } + if len(refs) != 1 || !referencesKnowledge(refs[0], fixture.ours) { + t.Fatalf("in-scope page refs=%v", refs) + } + // 别人的页一行不动。 + if content, _, present := fixture.pageState(t, "summary/"+fixture.theirs); !present || content != "theirs content" { + t.Fatalf("foreign page must survive: present=%t content=%q", present, content) + } + // agent 手写页一行不动。 + if content, _, present := fixture.pageState(t, "concept/hand-made"); !present || content != "agent written page" { + t.Fatalf("agent page must survive: present=%t content=%q", present, content) + } + // 库级 index 不被部分快照重写。 + if content, _, present := fixture.pageState(t, "index"); !present || content != "target library index" { + t.Fatalf("library index must survive: present=%t content=%q", present, content) + } + // 共享页保留正文,只摘掉本包知识的引用。 + content, refs, present = fixture.pageState(t, "entity/shared") + if !present || content != "merged content from both documents" { + t.Fatalf("shared page content must be untouched: present=%t content=%q", present, content) + } + for _, reference := range refs { + if referencesKnowledge(reference, fixture.ours) { + t.Fatalf("shared page still references the withdrawn/updated package document: %v", refs) + } + } + if len(refs) == 0 { + t.Fatal("shared page must keep the other document's reference") + } + // 库里的总页数:包内 2 页(summary + 不写 index)+ 别人的 4 页。 + var total int + if err := fixture.pool.QueryRow(fixture.ctx, + `SELECT COUNT(*)::INT FROM wiki_pages WHERE tenant_id = $1 AND knowledge_base_id = $2 AND deleted_at IS NULL`, + wikiGateTenant, fixture.targetK).Scan(&total); err != nil { + t.Fatal(err) + } + if total != 5 { + t.Fatalf("target wiki page count=%d, want 5 (2 package + 3 foreign/untouched)", total) + } +} + +// 包内页 slug 已被外来页占用时必须拒绝发布,且一行都不能改。 +func TestWikiReplaceRefusesWhenPackageSlugBelongsToForeignPage(t *testing.T) { + fixture, ok := newWikiGateFixture(t) + if !ok { + t.Skip("v0.7.1 integration database URL is not set") + } + fixture.seedSourceKnowledgeBase(t) + fixture.seedTargetKnowledgeBase(t) + snapshot := fixture.exportSourceSnapshot(t) + // 把包里 summary 页的 slug 抢到外来页名下:目标库该 slug 归属别人。 + fixture.cloneRow(t, "wiki_pages", fixture.templateRowID(t, "wiki_pages"), map[string]any{ + "id": "page-contested-" + fixture.suffix, "tenant_id": wikiGateTenant, + "knowledge_base_id": fixture.targetK, "slug": "summary/" + fixture.ours, + "title": "Theirs contested", "content": "theirs content at the package slug", + "source_refs": []string{fixture.theirs + "|Theirs.xlsx"}, "chunk_refs": []string{}, + "folder_id": "folder-wiki-theirs-" + fixture.suffix, "in_links": []string{}, "out_links": []string{}, + "deleted_at": nil, + }) + importer, err := NewPostgresImporter(fixture.pool, PostgresImporterConfig{ + TargetStorageBackendID: "6129cfd1-a26c-41dd-88cb-e6a800daa3bb", TargetProvider: "oss", + TargetBucket: "weknora-gate-test", + }) + if err != nil { + t.Fatal(err) + } + err = importer.Replace(fixture.ctx, PostgresIdentity{ + TenantID: wikiGateTenant, KnowledgeBaseID: fixture.targetK, KnowledgeID: fixture.ours, + }, snapshot) + if err == nil { + t.Fatal("expected a fail-closed refusal when a package slug is owned by another document") + } + if !strings.Contains(err.Error(), "已拒绝发布") { + t.Fatalf("expected the wiki ownership refusal, got: %v", err) + } + var total int + if queryErr := fixture.pool.QueryRow(fixture.ctx, + `SELECT COUNT(*)::INT FROM wiki_pages WHERE tenant_id = $1 AND knowledge_base_id = $2 AND deleted_at IS NULL`, + wikiGateTenant, fixture.targetK).Scan(&total); queryErr != nil { + t.Fatal(queryErr) + } + if total != 5 { + t.Fatalf("refused release must not change target wiki: page count=%d", total) + } + if content, _, present := fixture.pageState(t, "summary/"+fixture.ours); !present || content != "theirs content at the package slug" { + t.Fatalf("contested foreign content must be intact after refusal: %t %q", present, content) + } +} + +// 镜像库对原件(目标库没有外来页)时必须保持旧的整库替换语义: +// 包里的库级页(index)也要写进去,不能因为降级保护而漏掉。 +func TestWikiReplaceOnFaithfulMirrorReplacesWholeLibrary(t *testing.T) { + fixture, ok := newWikiGateFixture(t) + if !ok { + t.Skip("v0.7.1 integration database URL is not set") + } + fixture.seedSourceKnowledgeBase(t) + fixture.cloneRow(t, "knowledge_bases", fixture.templateRowID(t, "knowledge_bases"), map[string]any{ + "id": fixture.targetK, "tenant_id": wikiGateTenant, "name": "wiki-gate-mirror-" + fixture.suffix, + "indexing_strategy": map[string]any{"wiki_enabled": true}, + }) + fixture.cloneRow(t, "wiki_pages", fixture.templateRowID(t, "wiki_pages"), map[string]any{ + "id": "page-mirror-" + fixture.suffix, "tenant_id": wikiGateTenant, "knowledge_base_id": fixture.targetK, + "slug": "summary/" + fixture.ours, "title": "mirror page", "content": "stale mirror content", + "source_refs": []string{fixture.ours + "|WikiGate Ours.docx"}, "chunk_refs": []string{}, + "folder_id": "", "in_links": []string{}, "out_links": []string{}, "deleted_at": nil, + }) + fixture.cloneRow(t, "wiki_pages", fixture.templateRowID(t, "wiki_pages"), map[string]any{ + "id": "page-mirror-index-" + fixture.suffix, "tenant_id": wikiGateTenant, "knowledge_base_id": fixture.targetK, + "slug": "index", "title": "mirror index", "content": "stale mirror index", + "source_refs": []string{}, "chunk_refs": []string{}, + "folder_id": "", "in_links": []string{}, "out_links": []string{}, "deleted_at": nil, + }) + snapshot := fixture.exportSourceSnapshot(t) + importer, err := NewPostgresImporter(fixture.pool, PostgresImporterConfig{ + TargetStorageBackendID: "6129cfd1-a26c-41dd-88cb-e6a800daa3bb", TargetProvider: "oss", + TargetBucket: "weknora-gate-test", + }) + if err != nil { + t.Fatal(err) + } + if err := importer.Replace(fixture.ctx, PostgresIdentity{ + TenantID: wikiGateTenant, KnowledgeBaseID: fixture.targetK, KnowledgeID: fixture.ours, + }, snapshot); err != nil { + t.Fatalf("replace on faithful mirror: %v", err) + } + if content, _, present := fixture.pageState(t, "summary/"+fixture.ours); !present || content != "package content for summary/"+fixture.ours { + t.Fatalf("mirror page not replaced: present=%t content=%q", present, content) + } + if content, _, present := fixture.pageState(t, "index"); !present || content != "package content for index" { + t.Fatalf("library page must be written on the mirror path: present=%t content=%q", present, content) + } + var total int + if err := fixture.pool.QueryRow(fixture.ctx, + `SELECT COUNT(*)::INT FROM wiki_pages WHERE tenant_id = $1 AND knowledge_base_id = $2 AND deleted_at IS NULL`, + wikiGateTenant, fixture.targetK).Scan(&total); err != nil { + t.Fatal(err) + } + if total != 2 { + t.Fatalf("mirror replace page count=%d, want 2", total) + } +} diff --git a/internal/releaseadapter/v071/wiki_scope_test.go b/internal/releaseadapter/v071/wiki_scope_test.go new file mode 100644 index 0000000..fcd22d3 --- /dev/null +++ b/internal/releaseadapter/v071/wiki_scope_test.go @@ -0,0 +1,151 @@ +package v071 + +import ( + "encoding/json" + "testing" +) + +func wikiPageRefJSON(t *testing.T, fields map[string]any) json.RawMessage { + t.Helper() + encoded, err := json.Marshal(fields) + if err != nil { + t.Fatalf("encode wiki page row: %v", err) + } + return encoded +} + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +// 镜像库对原件:目标库每一页的来源都在包内 → 允许整库替换(旧行为保持不变)。 +func TestClassifyTargetWikiPagesAllowsLibraryReplaceOnFaithfulMirror(t *testing.T) { + owners := map[string]bool{"knowledge-a": true, "knowledge-b": true} + pages := []targetWikiRef{ + {ID: "p1", Slug: "summary/a", SourceRefs: []string{"knowledge-a|A.docx"}}, + {ID: "p2", Slug: "entity/shared", SourceRefs: []string{"knowledge-a|A.docx", "knowledge-b|B.docx"}}, + {ID: "p3", Slug: "index", SourceRefs: nil, ChunkRefs: nil}, + } + scope, inScope, foreign := classifyTargetWikiPages(pages, owners) + if scope.Degraded || scope.Foreign != 0 { + t.Fatalf("mirror must not degrade: %+v", scope) + } + if scope.InScope != 2 || scope.Untouched != 1 { + t.Fatalf("scope=%+v", scope) + } + if !contains(inScope, "summary/a") || !contains(inScope, "entity/shared") { + t.Fatalf("in-scope slugs=%v", inScope) + } + if len(foreign) != 0 { + t.Fatalf("foreign=%v", foreign) + } +} + +// 共享真实库:目标库存在别的文档的页 → 降级,只换本包页集,其余绝不触碰。 +func TestClassifyTargetWikiPagesDegradesOnSharedKnowledgeBase(t *testing.T) { + owners := map[string]bool{"knowledge-ours": true} + pages := []targetWikiRef{ + {ID: "p1", Slug: "summary/ours", SourceRefs: []string{"knowledge-ours|Ours.docx"}}, + {ID: "p2", Slug: "entity/shared", SourceRefs: []string{"knowledge-ours|Ours.docx", "knowledge-theirs|Theirs.xlsx"}}, + {ID: "p3", Slug: "concept/agent-made", SourceRefs: nil, ChunkRefs: nil}, + {ID: "p4", Slug: "summary/theirs", SourceRefs: []string{"knowledge-theirs|Theirs.xlsx"}}, + {ID: "p5", Slug: "orphan/chunk-only", SourceRefs: nil, ChunkRefs: []string{"chunk-1"}}, + } + scope, inScope, foreign := classifyTargetWikiPages(pages, owners) + if !scope.Degraded { + t.Fatalf("shared knowledge base must degrade: %+v", scope) + } + if scope.InScope != 1 || scope.Foreign != 3 || scope.Untouched != 1 { + t.Fatalf("scope=%+v", scope) + } + if scope.ForeignReferencingIn != 1 || len(foreign) != 1 || foreign[0].Slug != "entity/shared" { + t.Fatalf("foreign referencing in-scope must be reported for ref stripping: %+v %v", scope, foreign) + } + if len(inScope) != 1 || inScope[0] != "summary/ours" { + t.Fatalf("in-scope slugs=%v", inScope) + } +} + +// 结构异常(引用带坏 id / 非数组)的页按外来页处理:不删、不改。 +func TestClassifyTargetWikiPagesTreatsMalformedRowsAsForeign(t *testing.T) { + owners := map[string]bool{"knowledge-ours": true} + pages := []targetWikiRef{ + {ID: "p1", Slug: "summary/ours", SourceRefs: []string{"knowledge-ours|Ours.docx"}}, + {ID: "p2", Slug: "broken/ref", SourceRefs: []string{"not a valid id|Doc"}}, + } + scope, inScope, _ := classifyTargetWikiPages(pages, owners) + if scope.InScope != 1 || scope.Foreign != 1 || !scope.Degraded { + t.Fatalf("scope=%+v", scope) + } + if len(inScope) != 1 { + t.Fatalf("in-scope slugs=%v", inScope) + } +} + +func TestPackageWikiOwnersUnionsKnowledgesAndPageReferences(t *testing.T) { + snapshot := PostgresSnapshot{Tables: []TableSnapshot{ + newTableSnapshot("knowledges", []json.RawMessage{wikiPageRefJSON(t, map[string]any{"id": "knowledge-ours"})}), + newTableSnapshot("wiki_pages", []json.RawMessage{ + wikiPageRefJSON(t, map[string]any{"id": "p1", "slug": "summary/ours", "source_refs": []string{"knowledge-ours|Ours.docx", "knowledge-other|Other.pdf"}}), + wikiPageRefJSON(t, map[string]any{"id": "p2", "slug": "index", "source_refs": []string{}}), + }), + }} + owners, err := packageWikiOwners(snapshot) + if err != nil { + t.Fatal(err) + } + if !owners["knowledge-ours"] || !owners["knowledge-other"] { + t.Fatalf("owners=%v", owners) + } + empty := PostgresSnapshot{Tables: []TableSnapshot{ + newTableSnapshot("knowledges", []json.RawMessage{wikiPageRefJSON(t, map[string]any{"id": "knowledge-ours"})}), + // 只有空 wiki 快照时集合仍来自 knowledges 行。 + newTableSnapshot("wiki_pages", []json.RawMessage{wikiPageRefJSON(t, map[string]any{"id": "p1", "slug": "index", "source_refs": []string{}})}), + }} + if _, err := packageWikiOwners(empty); err != nil { + t.Fatalf("knowledge rows alone must define ownership: %v", err) + } + if _, err := packageWikiOwners(PostgresSnapshot{}); err == nil { + t.Fatal("empty package must be rejected") + } +} + +func TestReferenceOwnerParsesKnowledgeID(t *testing.T) { + if got := referenceOwner("knowledge-abc|手册.pdf"); got != "knowledge-abc" { + t.Fatalf("owner=%q", got) + } + if got := referenceOwner("knowledge-abc"); got != "knowledge-abc" { + t.Fatalf("owner=%q", got) + } + if got := referenceOwner("not a valid id|Doc"); got != "" { + t.Fatalf("malformed reference must not yield an owner: %q", got) + } +} + +// 包内页 slug 已被外来页占用 → 必须拒绝发布。 +func TestWikiSlugCollisionDetectsForeignSlug(t *testing.T) { + pages := []targetWikiRef{ + {ID: "p1", Slug: "summary/ours", SourceRefs: []string{"knowledge-ours|Ours.docx"}}, + {ID: "p2", Slug: "entity/contested", SourceRefs: []string{"knowledge-theirs|Theirs.xlsx"}}, + } + bundle := map[string]bool{"summary/ours": true, "entity/contested": true} + if slug, collided := wikiSlugCollision(pages, []string{"summary/ours"}, bundle); !collided || slug != "entity/contested" { + t.Fatalf("collision=%q %t", slug, collided) + } + if _, collided := wikiSlugCollision(pages, []string{"summary/ours", "entity/contested"}, bundle); collided { + t.Fatal("in-scope slugs must not count as collisions") + } + // 包内的库级页(index/log)在降级路径上不写,因此目标库的同名页不算占用。 + library := []targetWikiRef{{ID: "p9", Slug: "index", SourceRefs: nil, ChunkRefs: nil}} + if slug, collided := wikiSlugCollision(library, nil, map[string]bool{}); collided { + t.Fatalf("library pages must not collide: %q", slug) + } + if slug, collided := wikiSlugCollision(library, nil, map[string]bool{"index": true}); !collided || slug != "index" { + t.Fatalf("ref-bearing package page must collide: %q %t", slug, collided) + } +}