feat(release): wiki 通道按归属判定替换,共享知识库不再被整库清空

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 补验收点与镜像前提。
This commit is contained in:
姚章浩
2026-10-08 19:06:36 +08:00
parent 86732ca987
commit e5fa063d7d
6 changed files with 1017 additions and 24 deletions
+20
View File
@@ -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`(形如 `<knowledge_id>|<doc_title>`;`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 通道
源导出:
+8 -1
View File
@@ -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 检索命中 → 撤权后零命中 → 回滚恢复。
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 通道的归属门禁」)。
+11 -23
View File
@@ -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")
+374
View File
@@ -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 形如 "<knowledge_id>|<doc_title>",只有引用的知识
// 全部属于"本包覆盖的知识集合"时,这一页才归本包管(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:"<knowledge_id>|<doc_title>"。
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
}
@@ -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)
}
}
@@ -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)
}
}