ea6e56b304
发布通道原来只有三条: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。
167 lines
7.0 KiB
Go
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)
|