Files
knowledge-governance/internal/releaseadapter/v071/document_export.go
T
姚章浩 ea6e56b304 feat(release): 文档发布会带上知识库 wiki 快照并支持撤权剥离
发布通道原来只有三条:PostgreSQL 关系行、Elasticsearch 分片、OSS 对象。
WeKnora 的 wiki 图谱是 wiki_pages 一张纯 PG 表(in_links/out_links 存 slug),
所以它不在任何通道里——撤权后生产 wiki 会一直留着已撤权文档的页面与引用。

本次新增第 5 条通道:库级 wiki 快照,挂在现有文档发布包里,检索与 wiki
同一个 release 单元(同一次执行、同一次校验、同一次回滚):

- 导出:ExportWiki 按 knowledge_base_id 全量读 wiki_pages/wiki_folders;
  收敛门禁(库内无解析中/合成中文档)不满足直接失败并给中文原因;
  未启用 wiki 且无 wiki 行的库不参与同步(生产 wiki 保持原样)。
- 撤权:item.Action == "delete" 时在导出端剥离该文档的 wiki 贡献
  (source_refs/chunk_refs 去条目、无引用页面整页不带、摘死链、清 parent_slug、
  无页面引用的空文件夹及其祖先一并去掉),使“源端仍在只停止发布”和
  “源端删除”两种情况收敛到同一结果。
- 导入:库级 replace-by-kb(同一事务内 DELETE by kb 后灌入快照,文件夹先于页面),
  新增 ReplaceWiki 供撤权路径使用(文档行只 disable,wiki 整体替换)。
- 校验:VerifyWiki 用生产 WeKnora wiki API 比对页面 slug 集合,撤权时额外断言
  生产 wiki 不再引用被撤权文档。

驱动与验收:新增 cmd/release-channel-e2e(复用发布 worker 真实代码路径,
支持 publish/withdraw/rollback),scripts/wiki_e2e_test5.sh、
scripts/wiki_e2e_newkb.sh 两套端到端脚本;证据见
docs/WIKI_CO_TRANSPORT_ACCEPTANCE.md。
2026-09-30 10:54:07 +08:00

167 lines
7.0 KiB
Go

package v071
import (
"context"
"errors"
"strings"
"time"
"knowledge-governance/internal/releasebundle"
releaseworkflow "knowledge-governance/internal/workflows/release"
)
type postgresSnapshotExporter interface {
Export(ctx context.Context, identity PostgresIdentity) (PostgresSnapshot, error)
}
type searchSnapshotExporter interface {
Export(ctx context.Context, identity SearchIdentity) (SearchSnapshot, error)
}
type objectSnapshotExporter interface {
Export(ctx context.Context, snapshot PostgresSnapshot) (ObjectSnapshot, error)
}
type DocumentExporterConfig struct {
Contract targetContractInspector
Postgres postgresSnapshotExporter
Search searchSnapshotExporter
Objects objectSnapshotExporter
TenantID uint64
EmbeddingModel string
Now func() time.Time
}
type DocumentExporter struct {
config DocumentExporterConfig
}
type DocumentBundle struct {
Manifest releasebundle.Manifest
Postgres PostgresSnapshot
Search SearchSnapshot
Objects ObjectSnapshot
}
func (bundle *DocumentBundle) ReleaseManifest() releasebundle.Manifest {
if bundle == nil {
return releasebundle.Manifest{}
}
return bundle.Manifest
}
func NewDocumentExporter(config DocumentExporterConfig) (*DocumentExporter, error) {
if config.Contract == nil || config.Postgres == nil || config.Search == nil || config.Objects == nil || config.TenantID == 0 ||
!validSearchIdentifier(config.EmbeddingModel) || config.Now == nil {
return nil, errors.New("invalid v0.7.1 document exporter configuration")
}
return &DocumentExporter{config: config}, nil
}
func (exporter *DocumentExporter) Export(ctx context.Context, item releaseworkflow.PlanItem) (releaseworkflow.ExportedBundle, error) {
if !validDocumentPlan(item) {
return nil, errors.New("invalid document release plan")
}
sourceContract, err := exporter.config.Contract.Contract(ctx, item)
if err != nil || sourceContract.WeKnoraVersion != releasebundle.WeKnoraVersionV071 ||
sourceContract.MigrationVersion != releasebundle.WeKnoraMigrationV071 || sourceContract.TenantID != exporter.config.TenantID ||
sourceContract.KnowledgeBaseID != item.TestKnowledgeBaseID || sourceContract.RetrieverEngine != "elasticsearch_v8" ||
sourceContract.EmbeddingModel != exporter.config.EmbeddingModel || sourceContract.EmbeddingDimension != releasebundle.EmbeddingDimensionV071 {
return nil, errors.New("source WeKnora release contract mismatch")
}
postgresSnapshot, err := exporter.config.Postgres.Export(ctx, PostgresIdentity{
TenantID: exporter.config.TenantID, KnowledgeBaseID: item.TestKnowledgeBaseID, KnowledgeID: item.KnowledgeID,
})
if err != nil {
return nil, errors.New("export PostgreSQL document snapshot")
}
searchSnapshot, err := exporter.config.Search.Export(ctx, SearchIdentity{
TenantID: exporter.config.TenantID, KnowledgeBaseID: item.TestKnowledgeBaseID, KnowledgeID: item.KnowledgeID,
})
if err != nil {
return nil, errors.New("export Elasticsearch document snapshot")
}
if len(searchSnapshot.Documents) == 0 || !validLowerSHA256(searchSnapshot.MappingSHA256) || !validLowerSHA256(searchSnapshot.DocumentsSHA256) {
return nil, errors.New("Elasticsearch document snapshot is incomplete")
}
if searchSnapshot.MappingSHA256 != sourceContract.ElasticsearchMappingSHA256 {
return nil, errors.New("source Elasticsearch mapping contract mismatch")
}
objectSnapshot, err := exporter.config.Objects.Export(ctx, postgresSnapshot)
if err != nil {
return nil, errors.New("export object document snapshot")
}
if !validLowerSHA256(objectSnapshot.SHA256) {
return nil, errors.New("object document snapshot is incomplete")
}
// 库级 wiki 快照与本文档同一个发布单元:检索(PG/ES/OSS)和 wiki 一起进生产。
// 撤权(delete)时剥掉该文档在 wiki 里的贡献,避免生产 wiki 继续引用已撤权的文档。
if wikiExporter, ok := exporter.config.Postgres.(wikiSnapshotExporter); ok {
removingKnowledgeID := ""
if item.Action == "delete" {
removingKnowledgeID = item.KnowledgeID
}
wikiTables, err := wikiExporter.ExportWiki(ctx, PostgresIdentity{
TenantID: exporter.config.TenantID, KnowledgeBaseID: item.TestKnowledgeBaseID, KnowledgeID: item.KnowledgeID,
}, removingKnowledgeID)
if err != nil {
// 例如源端 wiki 未收敛,直接透出面向运营的中文原因。
return nil, err
}
postgresSnapshot.Tables = append(postgresSnapshot.Tables, wikiTables...)
}
tables := make([]releasebundle.TableDigest, 0, len(postgresSnapshot.Tables))
for _, table := range postgresSnapshot.Tables {
if !validReleaseTable(table.Name) || table.RowCount != len(table.Rows) || !validLowerSHA256(table.SHA256) {
return nil, errors.New("PostgreSQL document snapshot is incomplete")
}
tables = append(tables, releasebundle.TableDigest{Name: table.Name, RowCount: table.RowCount, SHA256: table.SHA256})
}
manifest, err := releasebundle.Seal(releasebundle.Manifest{
FormatVersion: releasebundle.FormatVersionV1, ExecutionID: item.ReleaseExecutionID,
PublicationID: item.PublicationID, SourceItemVersionID: item.SourceItemVersionID,
SourceRevision: item.SourceRevision, ParseRevision: item.ParseRevision, ReleaseRevision: item.ReleaseRevision,
CreatedAt: exporter.config.Now().UTC(),
Contract: releasebundle.Contract{
WeKnoraVersion: sourceContract.WeKnoraVersion, MigrationVersion: sourceContract.MigrationVersion,
TenantID: sourceContract.TenantID, KnowledgeBaseID: sourceContract.KnowledgeBaseID, KnowledgeID: item.KnowledgeID,
RetrieverEngine: sourceContract.RetrieverEngine, EmbeddingModel: sourceContract.EmbeddingModel,
EmbeddingDimension: sourceContract.EmbeddingDimension, ElasticsearchMappingSHA256: sourceContract.ElasticsearchMappingSHA256,
},
Tables: tables,
Search: releasebundle.SearchDigest{DocumentCount: len(searchSnapshot.Documents), SHA256: searchSnapshot.DocumentsSHA256},
Assets: releasebundle.AssetDigest{ObjectCount: len(objectSnapshot.Objects), TotalBytes: objectSnapshot.TotalBytes, SHA256: objectSnapshot.SHA256},
})
if err != nil {
return nil, errors.New("seal v0.7.1 document release bundle")
}
return &DocumentBundle{Manifest: manifest, Postgres: postgresSnapshot, Search: searchSnapshot, Objects: objectSnapshot}, nil
}
func validDocumentPlan(item releaseworkflow.PlanItem) bool {
for _, value := range []string{item.ReleaseExecutionID, item.PublicationID, item.SourceItemVersionID,
item.TestKnowledgeBaseID, item.KnowledgeID} {
if !validSearchIdentifier(value) {
return false
}
}
for _, value := range []string{item.SourceRevision, item.ParseRevision, item.ReleaseRevision} {
if value == "" || len(value) > 128 || value != strings.TrimSpace(value) || strings.ContainsAny(value, "\x00\r\n\t/?#\\") {
return false
}
}
return item.DecidedVisibility == "internal" || item.DecidedVisibility == "external"
}
func validReleaseTable(name string) bool {
switch name {
case "knowledges", "chunks", "knowledge_tags", "knowledge_tag_relations", "resources", "resource_bindings",
"wiki_pages", "wiki_folders":
return true
default:
return false
}
}
var _ releaseworkflow.BundleExporter = (*DocumentExporter)(nil)