Files
knowledge-governance/internal/platform/postgres/release_audit_chain.go
T
zheromiku dcdc8d1ddd feat: 知识治理平台(含已验证发布/回滚契约)
全栈知识治理平台首次入库(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 干净
2026-09-30 10:54:06 +08:00

156 lines
6.6 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/governance"
)
// ErrReleaseAuditChainNotFound is returned for an unknown execution id.
var ErrReleaseAuditChainNotFound = errors.New("release audit chain not found")
const releaseAuditChainHeadSQL = `
SELECT e.id, e.operation, e.state, e.failure_level,
COALESCE(e.release_batch_id,''), COALESCE(b.name,''), COALESCE(b.created_by,''), COALESCE(b.sealed_by,''),
COALESCE(e.authorized_by,''), COALESCE(e.release_change_set_id::text,''),
COALESCE(rcs.name,''), COALESCE(rcs.number,0), COALESCE(rcs.state,''),
COALESCE(rcs.created_by,''), COALESCE(rcs.confirmed_by,''), rcs.confirmed_at,
COALESCE(rcs.manifest_sha256,''), COALESCE(e.prior_execution_id,''),
COALESCE((SELECT rb.id FROM release_executions AS rb
WHERE rb.prior_execution_id = e.id AND rb.operation = 'rollback'
ORDER BY rb.created_at DESC LIMIT 1),'')
FROM release_executions AS e
LEFT JOIN release_batches AS b ON b.id = e.release_batch_id
LEFT JOIN release_change_sets AS rcs ON rcs.id = e.release_change_set_id
WHERE e.id = $1
`
const releaseAuditSnapshotLinksSQL = `
SELECT snap.id, COALESCE(conn.name,''), snap.node_count, snap.file_count, snap.observed_at
FROM release_change_set_snapshot_links AS link
JOIN source_catalog_snapshots AS snap ON snap.id = link.snapshot_id
LEFT JOIN source_connections AS conn ON conn.id = snap.source_connection_id
WHERE link.change_set_id = $1
ORDER BY snap.id
`
const releaseAuditCatalogLinksSQL = `
SELECT cset.id, cset.state, cset.add_count, cset.update_count,
cset.move_count, cset.delete_count
FROM release_change_set_catalog_links AS link
JOIN source_catalog_change_sets AS cset ON cset.id = link.catalog_change_set_id
WHERE link.change_set_id = $1
ORDER BY cset.id
`
const releaseAuditItemsSQL = `
SELECT COALESCE(batch_item.publication_id, item.publication_id),
COALESCE(version_title.title, ''),
COALESCE(item.action, batch_item.action),
COALESCE(item.state, 'pending'),
COALESCE(batch_item.review_id, ''),
COALESCE(batch_item.acceptance_run_id, ''),
COALESCE(version.source_item_id, ''),
COALESCE(batch_item.source_item_version_id, ''),
COALESCE(item.production_revision, ''),
COALESCE(item.error_code, '')
FROM release_item_executions AS item
JOIN test_publications AS publication ON publication.id = item.publication_id
JOIN source_item_versions AS version ON version.id = publication.source_item_version_id
JOIN source_items AS version_title ON version_title.id = version.source_item_id
LEFT JOIN release_batch_items AS batch_item
ON batch_item.release_batch_id = $2 AND batch_item.publication_id = item.publication_id
WHERE item.release_execution_id = $1
ORDER BY item.id
`
// GetReleaseAuditChain projects the complete R6 evidence chain for one
// execution: execution -> batch -> release change set -> bound source
// snapshots and catalog change sets -> per-item review/acceptance/source
// identity, plus the compensating rollback row when one exists. Every link is
// read through real foreign keys (migration 30), not denormalized JSON. Only
// safe identifiers, labels, counts and timestamps are selected — never bundle
// paths, credentials, base URLs, or signed object URLs.
func (s *Store) GetReleaseAuditChain(ctx context.Context, executionID string) (governance.ReleaseAuditChain, error) {
if !validShortID(executionID) {
return governance.ReleaseAuditChain{}, ErrReleaseAuditChainNotFound
}
var chain governance.ReleaseAuditChain
err := s.db.QueryRow(ctx, releaseAuditChainHeadSQL, executionID).Scan(
&chain.ExecutionID, &chain.Operation, &chain.State, &chain.FailureLevel,
&chain.BatchID, &chain.BatchName, &chain.CreatedBy, &chain.SealedBy,
&chain.AuthorizedBy, &chain.ReleaseChangeSetID,
&chain.ChangeSetName, &chain.ChangeSetNumber, &chain.ChangeSetState,
&chain.ChangeSetCreatedBy, &chain.ChangeSetConfirmedBy, &chain.ChangeSetConfirmedAt,
&chain.ManifestSHA256, &chain.PriorExecutionID,
&chain.RollbackExecutionID,
)
if errors.Is(err, pgx.ErrNoRows) {
return governance.ReleaseAuditChain{}, ErrReleaseAuditChainNotFound
}
if err != nil {
return governance.ReleaseAuditChain{}, fmt.Errorf("load release audit chain: %w", err)
}
chain.Snapshots = []governance.AuditSnapshot{}
chain.CatalogChangeSets = []governance.AuditCatalogSet{}
chain.Items = []governance.AuditChainItem{}
if chain.ReleaseChangeSetID != "" {
rows, err := s.db.Query(ctx, releaseAuditSnapshotLinksSQL, chain.ReleaseChangeSetID)
if err != nil {
return governance.ReleaseAuditChain{}, fmt.Errorf("list release snapshot links: %w", err)
}
for rows.Next() {
var snap governance.AuditSnapshot
if err := rows.Scan(&snap.ID, &snap.SourceName, &snap.NodeCount, &snap.FileCount, &snap.ObservedAt); err != nil {
rows.Close()
return governance.ReleaseAuditChain{}, fmt.Errorf("scan release snapshot link: %w", err)
}
chain.Snapshots = append(chain.Snapshots, snap)
}
if err := rows.Err(); err != nil {
rows.Close()
return governance.ReleaseAuditChain{}, fmt.Errorf("iterate release snapshot links: %w", err)
}
rows.Close()
rows, err = s.db.Query(ctx, releaseAuditCatalogLinksSQL, chain.ReleaseChangeSetID)
if err != nil {
return governance.ReleaseAuditChain{}, fmt.Errorf("list release catalog links: %w", err)
}
for rows.Next() {
var set governance.AuditCatalogSet
if err := rows.Scan(&set.ID, &set.State, &set.AddCount, &set.UpdateCount, &set.MoveCount, &set.DeleteCount); err != nil {
rows.Close()
return governance.ReleaseAuditChain{}, fmt.Errorf("scan release catalog link: %w", err)
}
chain.CatalogChangeSets = append(chain.CatalogChangeSets, set)
}
if err := rows.Err(); err != nil {
rows.Close()
return governance.ReleaseAuditChain{}, fmt.Errorf("iterate release catalog links: %w", err)
}
rows.Close()
}
rows, err := s.db.Query(ctx, releaseAuditItemsSQL, executionID, chain.BatchID)
if err != nil {
return governance.ReleaseAuditChain{}, fmt.Errorf("list release audit items: %w", err)
}
defer rows.Close()
for rows.Next() {
var item governance.AuditChainItem
if err := rows.Scan(&item.PublicationID, &item.Title, &item.Action, &item.ItemState,
&item.ReviewID, &item.AcceptanceRunID, &item.SourceItemID, &item.SourceVersionID,
&item.ProductionRevision, &item.ErrorCode); err != nil {
return governance.ReleaseAuditChain{}, fmt.Errorf("scan release audit item: %w", err)
}
chain.Items = append(chain.Items, item)
}
if err := rows.Err(); err != nil {
return governance.ReleaseAuditChain{}, fmt.Errorf("iterate release audit items: %w", err)
}
return chain, nil
}