dcdc8d1ddd
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环): - 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口) - 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入 parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截 - 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过): * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id) * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配 * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数 * 回滚 job payload 支持 prior_execution_id(strict 解码) - 三通道发布+回滚演练实测:publish 4/4 verified,检索命中, rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false - 前端 A·Data-console 风格全站统一,120 用例通过 - 门禁:go vet / go test 全包通过,gofmt 干净
341 lines
11 KiB
Go
341 lines
11 KiB
Go
package v071
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"regexp"
|
|
"sort"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
var resourceReferencePattern = regexp.MustCompile(`resource://([A-Za-z0-9_-]{22})`)
|
|
|
|
type PostgresIdentity struct {
|
|
TenantID uint64
|
|
KnowledgeBaseID string
|
|
KnowledgeID string
|
|
}
|
|
|
|
type TableSnapshot struct {
|
|
Name string
|
|
Rows []json.RawMessage
|
|
RowCount int
|
|
SHA256 string
|
|
}
|
|
|
|
type PostgresSnapshot struct {
|
|
Tables []TableSnapshot
|
|
}
|
|
|
|
func (snapshot PostgresSnapshot) Table(name string) TableSnapshot {
|
|
for _, table := range snapshot.Tables {
|
|
if table.Name == name {
|
|
return table
|
|
}
|
|
}
|
|
return TableSnapshot{Name: name}
|
|
}
|
|
|
|
type PostgresExporter struct {
|
|
db postgresQueryDB
|
|
}
|
|
|
|
type postgresQueryDB interface {
|
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
|
}
|
|
|
|
func NewPostgresExporter(db postgresQueryDB) (*PostgresExporter, error) {
|
|
if db == nil {
|
|
return nil, errors.New("PostgreSQL export database is required")
|
|
}
|
|
return &PostgresExporter{db: db}, nil
|
|
}
|
|
|
|
const exportKnowledgeSQL = `
|
|
SELECT to_jsonb(knowledge)
|
|
FROM knowledges AS knowledge
|
|
WHERE knowledge.id = $1
|
|
AND knowledge.tenant_id = $2
|
|
AND knowledge.knowledge_base_id = $3
|
|
AND knowledge.deleted_at IS NULL
|
|
AND knowledge.parse_status = 'completed'
|
|
`
|
|
|
|
const exportChunksSQL = `
|
|
SELECT to_jsonb(chunk)
|
|
FROM chunks AS chunk
|
|
WHERE chunk.knowledge_id = $1
|
|
AND chunk.tenant_id = $2
|
|
AND chunk.deleted_at IS NULL
|
|
ORDER BY chunk.id
|
|
`
|
|
|
|
const exportTagRelationsSQL = `
|
|
SELECT to_jsonb(relation)
|
|
FROM knowledge_tag_relations AS relation
|
|
WHERE relation.knowledge_id = $1
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM knowledges AS knowledge
|
|
WHERE knowledge.id = relation.knowledge_id
|
|
AND knowledge.tenant_id = $2
|
|
AND knowledge.knowledge_base_id = $3
|
|
AND knowledge.deleted_at IS NULL
|
|
)
|
|
ORDER BY relation.tag_id
|
|
`
|
|
|
|
const exportKnowledgeTagsSQL = `
|
|
SELECT to_jsonb(tag)
|
|
FROM knowledge_tags AS tag
|
|
WHERE tag.tenant_id = $2
|
|
AND tag.knowledge_base_id = $3
|
|
AND tag.deleted_at IS NULL
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM knowledge_tag_relations AS relation
|
|
WHERE relation.knowledge_id = $1
|
|
AND relation.tag_id = tag.id
|
|
)
|
|
ORDER BY tag.id
|
|
`
|
|
|
|
const exportResourcesSQL = `
|
|
SELECT to_jsonb(resource)
|
|
FROM resources AS resource
|
|
WHERE resource.tenant_id = $1
|
|
AND resource.deleted_at IS NULL
|
|
AND (
|
|
resource.handle = ANY($4::text[])
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM resource_bindings AS binding
|
|
WHERE binding.resource_id = resource.id
|
|
AND binding.tenant_id = $1
|
|
AND (
|
|
(binding.owner_type = 'knowledge' AND binding.owner_id = $2)
|
|
OR (binding.owner_type = 'chunk' AND binding.owner_id = ANY($3::text[]))
|
|
)
|
|
)
|
|
)
|
|
ORDER BY resource.id
|
|
`
|
|
|
|
const exportResourceBindingsSQL = `
|
|
SELECT to_jsonb(binding)
|
|
FROM resource_bindings AS binding
|
|
WHERE binding.tenant_id = $1
|
|
AND binding.resource_id = ANY($2::text[])
|
|
AND (
|
|
(binding.owner_type = 'knowledge' AND binding.owner_id = $3)
|
|
OR (binding.owner_type = 'chunk' AND binding.owner_id = ANY($4::text[]))
|
|
)
|
|
ORDER BY binding.id
|
|
`
|
|
|
|
func (exporter *PostgresExporter) Export(ctx context.Context, identity PostgresIdentity) (PostgresSnapshot, error) {
|
|
if identity.TenantID == 0 || !validSearchIdentifier(identity.KnowledgeBaseID) || !validSearchIdentifier(identity.KnowledgeID) {
|
|
return PostgresSnapshot{}, errors.New("invalid PostgreSQL export identity")
|
|
}
|
|
var knowledgeRaw []byte
|
|
if err := exporter.db.QueryRow(ctx, exportKnowledgeSQL, identity.KnowledgeID, identity.TenantID, identity.KnowledgeBaseID).Scan(&knowledgeRaw); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return PostgresSnapshot{}, errors.New("eligible PostgreSQL knowledge not found")
|
|
}
|
|
return PostgresSnapshot{}, errors.New("read PostgreSQL knowledge")
|
|
}
|
|
knowledge, err := canonicalJSON(knowledgeRaw)
|
|
if err != nil || !matchesKnowledgeRow(knowledge, identity) {
|
|
return PostgresSnapshot{}, errors.New("PostgreSQL returned a cross-scope knowledge row")
|
|
}
|
|
chunks, err := exporter.queryRows(ctx, exportChunksSQL, identity.KnowledgeID, identity.TenantID)
|
|
if err != nil {
|
|
return PostgresSnapshot{}, err
|
|
}
|
|
if len(chunks) == 0 {
|
|
return PostgresSnapshot{}, errors.New("PostgreSQL knowledge has no chunks")
|
|
}
|
|
chunkIDs := make([]string, 0, len(chunks))
|
|
for _, chunk := range chunks {
|
|
chunkID, ok := matchesChunkRow(chunk, identity)
|
|
if !ok {
|
|
return PostgresSnapshot{}, errors.New("PostgreSQL returned a cross-scope chunk row")
|
|
}
|
|
chunkIDs = append(chunkIDs, chunkID)
|
|
}
|
|
knowledgeTags, err := exporter.queryRows(ctx, exportKnowledgeTagsSQL, identity.KnowledgeID, identity.TenantID, identity.KnowledgeBaseID)
|
|
if err != nil {
|
|
return PostgresSnapshot{}, err
|
|
}
|
|
for _, tag := range knowledgeTags {
|
|
if !matchesKnowledgeTagRow(tag, identity) {
|
|
return PostgresSnapshot{}, errors.New("PostgreSQL returned a cross-scope knowledge tag")
|
|
}
|
|
}
|
|
tagRelations, err := exporter.queryRows(ctx, exportTagRelationsSQL, identity.KnowledgeID, identity.TenantID, identity.KnowledgeBaseID)
|
|
if err != nil {
|
|
return PostgresSnapshot{}, err
|
|
}
|
|
for _, relation := range tagRelations {
|
|
if !matchesTagRelationRow(relation, identity.KnowledgeID) {
|
|
return PostgresSnapshot{}, errors.New("PostgreSQL returned a cross-scope tag relation")
|
|
}
|
|
}
|
|
handles := referencedResourceHandles(append([]json.RawMessage{knowledge}, chunks...))
|
|
resources, err := exporter.queryRows(ctx, exportResourcesSQL, identity.TenantID, identity.KnowledgeID, chunkIDs, handles)
|
|
if err != nil {
|
|
return PostgresSnapshot{}, err
|
|
}
|
|
resourceIDs := make([]string, 0, len(resources))
|
|
for _, resource := range resources {
|
|
resourceID, ok := matchesResourceRow(resource, identity.TenantID)
|
|
if !ok {
|
|
return PostgresSnapshot{}, errors.New("PostgreSQL returned a cross-scope resource row")
|
|
}
|
|
resourceIDs = append(resourceIDs, resourceID)
|
|
}
|
|
bindings, err := exporter.queryRows(ctx, exportResourceBindingsSQL, identity.TenantID, resourceIDs, identity.KnowledgeID, chunkIDs)
|
|
if err != nil {
|
|
return PostgresSnapshot{}, err
|
|
}
|
|
for _, binding := range bindings {
|
|
if !matchesBindingRow(binding, identity.TenantID, identity.KnowledgeID, chunkIDs, resourceIDs) {
|
|
return PostgresSnapshot{}, errors.New("PostgreSQL returned a cross-scope resource binding")
|
|
}
|
|
}
|
|
tables := []TableSnapshot{
|
|
newTableSnapshot("knowledges", []json.RawMessage{knowledge}),
|
|
newTableSnapshot("chunks", chunks),
|
|
newTableSnapshot("knowledge_tags", knowledgeTags),
|
|
newTableSnapshot("knowledge_tag_relations", tagRelations),
|
|
newTableSnapshot("resources", resources),
|
|
newTableSnapshot("resource_bindings", bindings),
|
|
}
|
|
return PostgresSnapshot{Tables: tables}, nil
|
|
}
|
|
|
|
func (exporter *PostgresExporter) queryRows(ctx context.Context, query string, arguments ...any) ([]json.RawMessage, error) {
|
|
rows, err := exporter.db.Query(ctx, query, arguments...)
|
|
if err != nil {
|
|
return nil, errors.New("read PostgreSQL release rows")
|
|
}
|
|
defer rows.Close()
|
|
result := make([]json.RawMessage, 0)
|
|
for rows.Next() {
|
|
var raw []byte
|
|
if err := rows.Scan(&raw); err != nil {
|
|
return nil, errors.New("scan PostgreSQL release row")
|
|
}
|
|
canonical, err := canonicalJSON(raw)
|
|
if err != nil {
|
|
return nil, errors.New("invalid PostgreSQL release row")
|
|
}
|
|
result = append(result, canonical)
|
|
}
|
|
if rows.Err() != nil {
|
|
return nil, errors.New("iterate PostgreSQL release rows")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func newTableSnapshot(name string, rows []json.RawMessage) TableSnapshot {
|
|
rows = append([]json.RawMessage(nil), rows...)
|
|
sort.Slice(rows, func(left, right int) bool { return string(rows[left]) < string(rows[right]) })
|
|
encoded, _ := json.Marshal(rows)
|
|
digest := sha256.Sum256(encoded)
|
|
return TableSnapshot{Name: name, Rows: rows, RowCount: len(rows), SHA256: hex.EncodeToString(digest[:])}
|
|
}
|
|
|
|
func referencedResourceHandles(rows []json.RawMessage) []string {
|
|
seen := map[string]bool{}
|
|
for _, row := range rows {
|
|
for _, match := range resourceReferencePattern.FindAllSubmatch(row, -1) {
|
|
seen[string(match[1])] = true
|
|
}
|
|
}
|
|
handles := make([]string, 0, len(seen))
|
|
for handle := range seen {
|
|
handles = append(handles, handle)
|
|
}
|
|
sort.Strings(handles)
|
|
return handles
|
|
}
|
|
|
|
func matchesKnowledgeRow(raw json.RawMessage, identity PostgresIdentity) bool {
|
|
var row struct {
|
|
ID string `json:"id"`
|
|
TenantID uint64 `json:"tenant_id"`
|
|
KnowledgeBaseID string `json:"knowledge_base_id"`
|
|
ParseStatus string `json:"parse_status"`
|
|
}
|
|
return json.Unmarshal(raw, &row) == nil && row.ID == identity.KnowledgeID && row.TenantID == identity.TenantID && row.KnowledgeBaseID == identity.KnowledgeBaseID && row.ParseStatus == "completed"
|
|
}
|
|
|
|
func matchesChunkRow(raw json.RawMessage, identity PostgresIdentity) (string, bool) {
|
|
var row struct {
|
|
ID string `json:"id"`
|
|
TenantID uint64 `json:"tenant_id"`
|
|
KnowledgeID string `json:"knowledge_id"`
|
|
}
|
|
if json.Unmarshal(raw, &row) != nil {
|
|
return "", false
|
|
}
|
|
return row.ID, validSearchIdentifier(row.ID) && row.TenantID == identity.TenantID && row.KnowledgeID == identity.KnowledgeID
|
|
}
|
|
|
|
func matchesKnowledgeTagRow(raw json.RawMessage, identity PostgresIdentity) bool {
|
|
var row struct {
|
|
ID string `json:"id"`
|
|
TenantID uint64 `json:"tenant_id"`
|
|
KnowledgeBaseID string `json:"knowledge_base_id"`
|
|
}
|
|
return json.Unmarshal(raw, &row) == nil && validSearchIdentifier(row.ID) && row.TenantID == identity.TenantID && row.KnowledgeBaseID == identity.KnowledgeBaseID
|
|
}
|
|
|
|
func matchesTagRelationRow(raw json.RawMessage, knowledgeID string) bool {
|
|
var row struct {
|
|
KnowledgeID string `json:"knowledge_id"`
|
|
TagID string `json:"tag_id"`
|
|
}
|
|
return json.Unmarshal(raw, &row) == nil && row.KnowledgeID == knowledgeID && validSearchIdentifier(row.TagID)
|
|
}
|
|
|
|
func matchesResourceRow(raw json.RawMessage, tenantID uint64) (string, bool) {
|
|
var row struct {
|
|
ID string `json:"id"`
|
|
TenantID uint64 `json:"tenant_id"`
|
|
Handle string `json:"handle"`
|
|
}
|
|
if json.Unmarshal(raw, &row) != nil {
|
|
return "", false
|
|
}
|
|
return row.ID, validSearchIdentifier(row.ID) && row.TenantID == tenantID && len(row.Handle) == 22
|
|
}
|
|
|
|
func matchesBindingRow(raw json.RawMessage, tenantID uint64, knowledgeID string, chunkIDs, resourceIDs []string) bool {
|
|
var row struct {
|
|
ResourceID string `json:"resource_id"`
|
|
TenantID uint64 `json:"tenant_id"`
|
|
OwnerType string `json:"owner_type"`
|
|
OwnerID string `json:"owner_id"`
|
|
}
|
|
if json.Unmarshal(raw, &row) != nil || row.TenantID != tenantID || !containsString(resourceIDs, row.ResourceID) {
|
|
return false
|
|
}
|
|
return row.OwnerType == "knowledge" && row.OwnerID == knowledgeID || row.OwnerType == "chunk" && containsString(chunkIDs, row.OwnerID)
|
|
}
|
|
|
|
func containsString(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|