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 干净
359 lines
16 KiB
Go
359 lines
16 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
)
|
|
|
|
// ConfirmCatalogChangeSet executes ONLY the human-selected items of one planned
|
|
// ChangeSet: it creates publications + confirmations + test.upload jobs, or
|
|
// disables retrieval and enqueues test.remove for deletes. NO_CHANGE never
|
|
// enqueues. Re-confirmation replays the first stored outcome.
|
|
func (s *Store) ConfirmCatalogChangeSet(ctx context.Context, request governance.ConfirmCatalogChangeSetRequest) (governance.ConfirmCatalogChangeSetResult, error) {
|
|
operator := strings.TrimSpace(request.OperatorRef)
|
|
reason := strings.TrimSpace(request.Reason)
|
|
if !validRouteID(request.ChangeSetID) || operator == "" || len(operator) > 128 || reason == "" || len(reason) > 500 || request.Now.IsZero() || len(request.ItemSeqs) == 0 || len(request.ItemSeqs) > 200 {
|
|
return governance.ConfirmCatalogChangeSetResult{}, errors.New("invalid change set confirmation")
|
|
}
|
|
seen := make(map[int64]bool, len(request.ItemSeqs))
|
|
for _, seq := range request.ItemSeqs {
|
|
if seq < 1 || seen[seq] {
|
|
return governance.ConfirmCatalogChangeSetResult{}, errors.New("invalid change set item selections")
|
|
}
|
|
seen[seq] = true
|
|
}
|
|
var tx pgx.Tx
|
|
ownsTransaction := false
|
|
if existing, ok := s.db.(pgx.Tx); ok {
|
|
tx = existing
|
|
} else {
|
|
beginner, ok := s.db.(interface {
|
|
Begin(context.Context) (pgx.Tx, error)
|
|
})
|
|
if !ok {
|
|
return governance.ConfirmCatalogChangeSetResult{}, errors.New("change set confirmation transactions are unavailable")
|
|
}
|
|
var err error
|
|
tx, err = beginner.Begin(ctx)
|
|
if err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("begin change set confirmation: %w", err)
|
|
}
|
|
ownsTransaction = true
|
|
defer tx.Rollback(ctx)
|
|
}
|
|
result, err := confirmCatalogChangeSetTx(ctx, tx, request)
|
|
if err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, err
|
|
}
|
|
if ownsTransaction {
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("commit change set confirmation: %w", err)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func confirmCatalogChangeSetTx(ctx context.Context, tx pgx.Tx, request governance.ConfirmCatalogChangeSetRequest) (governance.ConfirmCatalogChangeSetResult, error) {
|
|
result := governance.ConfirmCatalogChangeSetResult{ChangeSetID: request.ChangeSetID}
|
|
selections := append([]int64(nil), request.ItemSeqs...)
|
|
var state string
|
|
err := tx.QueryRow(ctx, `SELECT state FROM source_catalog_change_sets WHERE id=$1 FOR UPDATE`, request.ChangeSetID).Scan(&state)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return governance.ConfirmCatalogChangeSetResult{}, ErrCatalogChangeSetNotFound
|
|
}
|
|
if err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("lock change set for confirmation: %w", err)
|
|
}
|
|
if state != "planned" {
|
|
// Even on replay the requested locators must belong to this change set.
|
|
var owned int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*)::integer FROM source_catalog_change_items WHERE change_set_id=$1 AND item_seq=ANY($2::bigint[])`, request.ChangeSetID, selections).Scan(&owned); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("replay change set item check: %w", err)
|
|
}
|
|
if owned != len(selections) {
|
|
return governance.ConfirmCatalogChangeSetResult{}, errors.New("change set confirmation references unknown items")
|
|
}
|
|
// Replay the first stored outcome from persisted evidence.
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*)::integer FROM processing_confirmations WHERE change_set_id=$1`, request.ChangeSetID).Scan(&result.ConfirmedItems); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("replay change set confirmation: %w", err)
|
|
}
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*)::integer FROM jobs WHERE type='test.upload' AND payload->>'change_set_id'=$1`, request.ChangeSetID).Scan(&result.UploadsCreated); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("replay change set uploads: %w", err)
|
|
}
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*)::integer FROM jobs WHERE type='test.remove' AND payload->>'change_set_id'=$1`, request.ChangeSetID).Scan(&result.RemovalsRequested); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("replay change set removals: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT item.item_seq,item.action,COALESCE(item.new_scope,''),COALESCE(item.old_scope,''),item.title,
|
|
COALESCE(item.new_source_item_id,item.old_source_item_id) AS source_item_id,
|
|
version.id AS version_id,
|
|
version.content_state='verified' AND content.content_state='verified' AND content.local_spool_ref<>'' AS artifact_ready
|
|
FROM source_catalog_change_items item
|
|
LEFT JOIN source_items source ON source.id=COALESCE(item.new_source_item_id,item.old_source_item_id)
|
|
LEFT JOIN source_item_versions version ON version.source_item_id=source.id AND version.revision=source.revision
|
|
LEFT JOIN content_objects content ON content.id=version.content_object_id
|
|
WHERE item.change_set_id=$1 AND item.item_seq=ANY($2::bigint[])
|
|
ORDER BY item.item_seq`, request.ChangeSetID, selections)
|
|
if err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("select change set items: %w", err)
|
|
}
|
|
type changeRow struct {
|
|
seq int64
|
|
action string
|
|
newScope string
|
|
oldScope string
|
|
sourceItemID *string
|
|
versionID *string
|
|
artifactReady bool
|
|
}
|
|
changes := make([]changeRow, 0, len(selections))
|
|
for rows.Next() {
|
|
var item changeRow
|
|
var title string
|
|
var versionID *string
|
|
var artifactReady *bool
|
|
if err := rows.Scan(&item.seq, &item.action, &item.newScope, &item.oldScope, &title, &item.sourceItemID, &versionID, &artifactReady); err != nil {
|
|
rows.Close()
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("scan change set item: %w", err)
|
|
}
|
|
if versionID != nil {
|
|
item.versionID = versionID
|
|
}
|
|
if artifactReady != nil {
|
|
item.artifactReady = *artifactReady
|
|
}
|
|
changes = append(changes, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("iterate change set items: %w", err)
|
|
}
|
|
rows.Close()
|
|
if len(changes) != len(selections) {
|
|
return governance.ConfirmCatalogChangeSetResult{}, errors.New("change set confirmation references unknown items")
|
|
}
|
|
|
|
for _, item := range changes {
|
|
if item.action == "no_change" {
|
|
result.NoChangeIgnored++
|
|
continue
|
|
}
|
|
if item.action == "delete" || item.newScope == string(governance.VisibilityBlocked) {
|
|
if item.sourceItemID == nil {
|
|
result.SkippedNoArtifact++
|
|
continue
|
|
}
|
|
removals, err := retireItemPublicationsForChange(ctx, tx, *item.sourceItemID, request)
|
|
if err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, err
|
|
}
|
|
result.RemovalsRequested += removals
|
|
result.ConfirmedItems++
|
|
continue
|
|
}
|
|
targets := make([]string, 0, 2)
|
|
switch item.newScope {
|
|
case string(governance.VisibilityInternal):
|
|
if request.InternalTargetKBID == "" {
|
|
result.SkippedNoTarget++
|
|
continue
|
|
}
|
|
targets = append(targets, request.InternalTargetKBID)
|
|
case string(governance.VisibilityExternal):
|
|
if request.ExternalTargetKBID == "" {
|
|
result.SkippedNoTarget++
|
|
continue
|
|
}
|
|
targets = append(targets, request.ExternalTargetKBID)
|
|
case string(governance.VisibilityBoth):
|
|
if request.InternalTargetKBID != "" {
|
|
targets = append(targets, request.InternalTargetKBID)
|
|
}
|
|
if request.ExternalTargetKBID != "" {
|
|
targets = append(targets, request.ExternalTargetKBID)
|
|
}
|
|
if len(targets) < 2 {
|
|
result.SkippedNoTarget++
|
|
continue
|
|
}
|
|
default:
|
|
result.SkippedNoTarget++
|
|
continue
|
|
}
|
|
if item.versionID == nil || !item.artifactReady {
|
|
result.SkippedNoArtifact++
|
|
continue
|
|
}
|
|
result.ConfirmedItems++
|
|
for _, target := range targets {
|
|
scope := item.newScope
|
|
if scope == string(governance.VisibilityBoth) {
|
|
scope = string(governance.VisibilityInternal)
|
|
if target == request.ExternalTargetKBID {
|
|
scope = string(governance.VisibilityExternal)
|
|
}
|
|
}
|
|
var existingPublication string
|
|
err := tx.QueryRow(ctx, `SELECT id FROM test_publications WHERE source_item_version_id=$1 AND test_kb_id=$2`, *item.versionID, target).Scan(&existingPublication)
|
|
if err == nil {
|
|
if err := ensureChangeSetConfirmation(ctx, tx, existingPublication, request); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, err
|
|
}
|
|
result.UploadsExisting++
|
|
continue
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("inspect change set publication: %w", err)
|
|
}
|
|
publicationID, err := newOpaqueID("tp-")
|
|
if err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, err
|
|
}
|
|
tag, err := tx.Exec(ctx, `INSERT INTO test_publications(id,source_item_version_id,test_kb_id,target_scope,change_set_id,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$6)
|
|
ON CONFLICT (source_item_version_id,test_kb_id) DO NOTHING`,
|
|
publicationID, *item.versionID, target, scope, request.ChangeSetID, request.Now)
|
|
if err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("create change set publication: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
// A concurrent confirmation already owns this version+target.
|
|
var existing string
|
|
if err := tx.QueryRow(ctx, `SELECT id FROM test_publications WHERE source_item_version_id=$1 AND test_kb_id=$2`, *item.versionID, target).Scan(&existing); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("resolve concurrent publication: %w", err)
|
|
}
|
|
if err := ensureChangeSetConfirmation(ctx, tx, existing, request); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, err
|
|
}
|
|
result.UploadsExisting++
|
|
continue
|
|
}
|
|
jobID, err := newOpaqueID("job-")
|
|
if err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, err
|
|
}
|
|
payload, err := json.Marshal(map[string]string{"publication_id": publicationID, "change_set_id": request.ChangeSetID, "item_seq": strconv.FormatInt(item.seq, 10), "target_scope": scope, "target_kb_id": target})
|
|
if err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, errors.New("encode change set upload job")
|
|
}
|
|
if _, err := tx.Exec(ctx, `INSERT INTO jobs(id,type,payload,state,priority,available_at,max_attempts,idempotency_key,created_at,updated_at,business_change_set_id,business_version_id,business_target_kb_id,business_action)
|
|
VALUES($1,'test.upload',$2::jsonb,'pending',10,$3,5,$4,$3,$3,$5,$6,$7,'upload')
|
|
ON CONFLICT DO NOTHING`, jobID, payload, request.Now, "test-start:"+publicationID, request.ChangeSetID, *item.versionID, target); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("enqueue change set upload: %w", err)
|
|
}
|
|
if err := ensureChangeSetConfirmation(ctx, tx, publicationID, request); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, err
|
|
}
|
|
result.UploadsCreated++
|
|
}
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE source_catalog_change_sets SET state='confirmed',confirmed_at=$2 WHERE id=$1 AND state='planned'`, request.ChangeSetID, request.Now); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, fmt.Errorf("confirm change set: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func ensureChangeSetConfirmation(ctx context.Context, tx pgx.Tx, publicationID string, request governance.ConfirmCatalogChangeSetRequest) error {
|
|
confirmationID, err := newOpaqueID("pc-")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(ctx, `INSERT INTO processing_confirmations(id,publication_id,operator_ref,reason,confirmed_at,change_set_id) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT (publication_id) DO NOTHING`,
|
|
confirmationID, publicationID, request.OperatorRef, request.Reason, request.Now, request.ChangeSetID); err != nil {
|
|
return fmt.Errorf("record change set confirmation: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func retireItemPublicationsForChange(ctx context.Context, tx pgx.Tx, itemID string, request governance.ConfirmCatalogChangeSetRequest) (int, error) {
|
|
rows, err := tx.Query(ctx, `SELECT publication.id,publication.knowledge_id,publication.retrieval_state
|
|
FROM test_publications publication
|
|
JOIN source_item_versions version ON version.id=publication.source_item_version_id
|
|
WHERE version.source_item_id=$1 AND publication.retrieval_state NOT IN ('disabling','disabled','unpublished')
|
|
FOR UPDATE OF publication`, itemID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("inspect change set removals: %w", err)
|
|
}
|
|
type target struct{ id, knowledgeID string }
|
|
targets := make([]target, 0)
|
|
for rows.Next() {
|
|
var item target
|
|
if err := rows.Scan(&item.id, &item.knowledgeID, new(string)); err != nil {
|
|
rows.Close()
|
|
return 0, fmt.Errorf("scan change set removal: %w", err)
|
|
}
|
|
targets = append(targets, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return 0, fmt.Errorf("iterate change set removals: %w", err)
|
|
}
|
|
rows.Close()
|
|
removals := 0
|
|
for _, item := range targets {
|
|
if item.knowledgeID == "" {
|
|
if _, err := tx.Exec(ctx, `UPDATE test_publications SET ingest_state='failed',processing_state='failed',retrieval_state='disabled',last_error='blocked_by_changeset',updated_at=$2 WHERE id=$1 AND knowledge_id=''`, item.id, request.Now); err != nil {
|
|
return 0, fmt.Errorf("stop pre-upload change set removal: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE test_publications SET retrieval_state='disabling',last_error='',updated_at=$2 WHERE id=$1`, item.id, request.Now); err != nil {
|
|
return 0, fmt.Errorf("mark change set removal: %w", err)
|
|
}
|
|
jobID, err := newOpaqueID("job-")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
payload, err := json.Marshal(map[string]string{"publication_id": item.id, "change_set_id": request.ChangeSetID})
|
|
if err != nil {
|
|
return 0, errors.New("encode change set remove job")
|
|
}
|
|
// Removals key on the item (version-independent): a confirmed delete must
|
|
// not enqueue twice even if version rows rotate between deliveries.
|
|
if _, err := tx.Exec(ctx, `INSERT INTO jobs(id,type,payload,state,priority,available_at,max_attempts,idempotency_key,created_at,updated_at,business_change_set_id,business_version_id,business_target_kb_id,business_action)
|
|
VALUES($1,'test.remove',$2,'pending',20,$3,10,$4,$3,$3,$5,$6,'','remove')
|
|
ON CONFLICT DO NOTHING`, jobID, payload, request.Now, "changeset-remove:"+item.id+":"+request.ChangeSetID, request.ChangeSetID, item.id); err != nil {
|
|
return 0, fmt.Errorf("enqueue change set removal: %w", err)
|
|
}
|
|
removals++
|
|
}
|
|
return removals, nil
|
|
}
|
|
|
|
func (s *Store) ConfirmCatalogChangeSetWithOperation(ctx context.Context, request governance.ConfirmCatalogChangeSetRequest, operationID string) (governance.ConfirmCatalogChangeSetResult, bool, error) {
|
|
fingerprint, err := operationFingerprint(struct {
|
|
ChangeSetID string `json:"change_set_id"`
|
|
ItemSeqs []int64 `json:"item_seqs"`
|
|
OperatorRef string `json:"operator_ref"`
|
|
Reason string `json:"reason"`
|
|
}{request.ChangeSetID, request.ItemSeqs, request.OperatorRef, request.Reason})
|
|
if err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, false, err
|
|
}
|
|
raw, replayed, err := s.executeOperation(ctx, "changeset.confirm", operationID, fingerprint, request.Now, func(tx pgx.Tx) (json.RawMessage, error) {
|
|
result, err := NewStore(tx).ConfirmCatalogChangeSet(ctx, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(result)
|
|
})
|
|
if err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, false, err
|
|
}
|
|
var result governance.ConfirmCatalogChangeSetResult
|
|
if err := json.Unmarshal(raw, &result); err != nil {
|
|
return governance.ConfirmCatalogChangeSetResult{}, false, fmt.Errorf("decode stored change set result: %w", err)
|
|
}
|
|
return result, replayed, nil
|
|
}
|