Files
knowledge-governance/internal/platform/postgres/release_change_sets.go
T
姚章浩 acf053faf3 feat(release): 建批时冻结 + 验收通过自动批准 —— 把"每份文档 8 步"砍掉两步并去掉两次卡死
背景:发布管理把"逐条核验"的仪式强加给每一份文档。实测一份文档要 9~11 次交互,
且有两处顺序陷阱(冻结后批准 → 建批 422;生成即固化 → 批准被永久钉住)。

P0-1 建批时冻结:
- CreateReleaseBatch 在未指定版本时,自己把当前实时计划冻结成一份**已确认**版本并绑定
  (同一事务;同摘要复用最近版本,planned 或 confirmed 都可复用,因此同一份计划不堆版本)。
- 语义不变:批一旦建立,清单/摘要/验收轮次/目标库绑定全部不可变、回滚按它走;变的只是
  "谁在什么时候按固化键"——从操作员的手动两步变成建批这一下。
- 效果:去掉「生成新版本」「确认冻结」两步;消灭"批准晚于冻结 → 422"这类顺序陷阱;
  前端不再传版本 id,也不再有"所选知识不在冻结清单里"的阻断。
- 计划→清单项的映射抽成 ChangeSetItemsFromPlan 供两处共用,否则两处算出的摘要不同,
  "同一计划复用同一版本"永远不命中。

P0-2 验收通过自动批准:
- 验收三项结论达发布门槛(knowledge_exists/chunks_present=passed,retrieval_hit 与门槛同
  口径)时,由 worker 写入 reviewer_ref='system:auto' 的批准记录,替代"每份文档点一次批准"。
- 策略 TEST_AUTO_REVIEW_POLICY = off | internal(默认,仅对内)| all;值不认识拒绝启动。
  只影响审核这一步:生产授权、双闸与发布前快照门禁不受影响。界面上只读展示当前策略。
- 安全边界(写死在审核 SQL 里):系统只允许写"第一条"审核——人工一旦表过态,机器不得用一条
  更晚的 approved 把人工结论翻回去;人工随时可再审。集成测试去掉该约束即 FAIL(已实测)。
- 自动批准自身的任何失败都不影响验收结论,只上报。

顺带(P1-1 前半,已被 P0-1 取代的部分已删除):
- 写操作失败把服务端 {"error": ...} 的原因带出来,并对已知原因给出下一步(不再只给 HTTP 状态码)。
- 当前的"不在已冻结清单里"标注与就地修复按钮随 P0-1 一起移除(该失败模式已不存在)。

测试:bind_time_freeze_integration_test.go(绑定已确认版本/同计划复用/冻结后不可变/
动作不符仍 NotEligible)、auto_review_integration_test.go(系统只写第一条、人工可再审)、
validate_test.go(对内自动/对外不自动/策略关闭/验收未过不批/自身失败不影响验收)、
auto_review_test.go、config_test 及前端能力解析测试。
实测耗时:本机 Go 全量 26 包通过、前端 178 条通过。
2026-10-10 15:05:20 +08:00

304 lines
14 KiB
Go

package postgres
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"knowledge-governance/internal/governance"
)
// Errors for R1 release ChangeSets.
var (
ErrInvalidReleaseChangeSet = errors.New("invalid release change set request")
ErrReleaseChangeSetNotFound = errors.New("release change set not found")
ErrReleaseChangeSetStateConflict = errors.New("release change set state conflict")
ErrReleaseChangeSetEmpty = errors.New("release plan has no releasable items")
)
const releaseChangeSetColumns = `id,number,name,state,source_snapshot_ids,source_change_set_ids,acceptance_cutoff,production_baseline_ref,item_count,add_count,update_count,delete_count,blocked_count,manifest_sha256,created_by,created_at,confirmed_by,COALESCE(confirmed_at::text,'')`
func scanReleaseChangeSet(row pgx.Row) (governance.ReleaseChangeSet, error) {
var item governance.ReleaseChangeSet
var snapshots, changeSets []byte
var confirmedAt string
err := row.Scan(&item.ID, &item.Number, &item.Name, &item.State, &snapshots, &changeSets, &item.AcceptanceCutoff, &item.ProductionBaselineRef,
&item.ItemCount, &item.AddCount, &item.UpdateCount, &item.DeleteCount, &item.BlockedCount, &item.ManifestSHA256, &item.CreatedBy, &item.CreatedAt, &item.ConfirmedBy, &confirmedAt)
if err != nil {
return governance.ReleaseChangeSet{}, err
}
_ = json.Unmarshal(snapshots, &item.SourceSnapshotIDs)
_ = json.Unmarshal(changeSets, &item.SourceChangeSetIDs)
if item.SourceSnapshotIDs == nil {
item.SourceSnapshotIDs = []string{}
}
if item.SourceChangeSetIDs == nil {
item.SourceChangeSetIDs = []string{}
}
if confirmedAt != "" {
if parsed, parseErr := time.Parse(time.RFC3339Nano, confirmedAt); parseErr == nil {
item.ConfirmedAt = &parsed
}
}
return item, nil
}
// ComputeReleaseChangeSetManifest is exported for tests: the manifest seals
// every frozen decision in seq order with an exact delimiter.
func ComputeReleaseChangeSetManifest(items []governance.ReleaseChangeSetItem) string {
lines := make([]string, 0, len(items))
for _, item := range items {
lines = append(lines, strings.Join([]string{
strconv.Itoa(item.Seq), item.PublicationID, item.TargetScope, item.Action,
item.TestState, item.ProductionState, strconv.FormatBool(item.CanRelease), item.Blocker,
}, "|"))
}
digest := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return hex.EncodeToString(digest[:])
}
// ChangeSetItemsFromPlan 把实时计划的条目映射成清单项。生成版本与"建批时冻结"共用它,
// 两处必须算出字节相同的摘要,否则"同一份计划复用同一版本"的判断永远不命中。
func ChangeSetItemsFromPlan(planItems []governance.ReleasePlanItem) []governance.ReleaseChangeSetItem {
items := make([]governance.ReleaseChangeSetItem, 0, len(planItems))
for index, planItem := range planItems {
items = append(items, governance.ReleaseChangeSetItem{Seq: index + 1, PublicationID: planItem.PublicationID, Title: planItem.Title, TargetScope: planItem.TargetScope, Action: planItem.Action, TestState: planItem.TestState, ProductionState: planItem.ProductionState, CanRelease: planItem.CanRelease, Blocker: planItem.Blocker})
}
return items
}
// GenerateReleaseChangeSet freezes the current live plan into an immutable
// versioned set. Repeated generation with an identical plan reuses the latest
// PLANNED set (dedup), so concurrent generation cannot create duplicates.
func (s *Store) GenerateReleaseChangeSet(ctx context.Context, request governance.GenerateReleaseChangeSetRequest) (governance.ReleaseChangeSet, error) {
if request.Name == "" || request.Name != strings.TrimSpace(request.Name) || len(request.Name) > 200 ||
strings.ContainsAny(request.Name, "\x00\r\n") || !validReviewerRef(request.CreatedBy) || request.Now.IsZero() {
return governance.ReleaseChangeSet{}, ErrInvalidReleaseChangeSet
}
plan, err := s.ListReleasePlan(ctx)
if err != nil {
return governance.ReleaseChangeSet{}, err
}
items := ChangeSetItemsFromPlan(plan.Items)
if len(items) == 0 {
return governance.ReleaseChangeSet{}, ErrReleaseChangeSetEmpty
}
manifest := ComputeReleaseChangeSetManifest(items)
counts := map[string]int{}
for _, item := range items {
counts[item.Action]++
}
blocked := 0
for _, item := range items {
if item.Action == "blocked" {
blocked++
}
}
// Same plan twice (sequentially) reuses the latest PLANNED set instead of
// piling up identical versions. Concurrent racers fall through to the
// unique-number conflict path below.
if existing, lookupErr := s.findPlannedByManifest(ctx, manifest); lookupErr == nil && existing.ManifestSHA256 == manifest {
return existing, nil
} else if lookupErr != nil && !errors.Is(lookupErr, pgx.ErrNoRows) {
return governance.ReleaseChangeSet{}, lookupErr
}
for attempt := 0; attempt < 3; attempt++ {
result, retry, err := s.tryInsertReleaseChangeSet(ctx, request, items, manifest, counts["add"], counts["update"], counts["delete"], blocked)
if err == nil {
return result, nil
}
if !retry {
return governance.ReleaseChangeSet{}, err
}
// A concurrent identical generation won; return its set (dedup) if the
// manifest matches, otherwise loop to take the next version number.
existing, lookupErr := s.findPlannedByManifest(ctx, manifest)
if lookupErr == nil && existing.ManifestSHA256 == manifest {
return existing, nil
}
if lookupErr != nil && !errors.Is(lookupErr, ErrReleaseChangeSetNotFound) {
return governance.ReleaseChangeSet{}, lookupErr
}
}
return governance.ReleaseChangeSet{}, fmt.Errorf("release change set generation kept conflicting")
}
func (s *Store) tryInsertReleaseChangeSet(ctx context.Context, request governance.GenerateReleaseChangeSetRequest, items []governance.ReleaseChangeSetItem, manifest string, add, update, del, blocked int) (governance.ReleaseChangeSet, bool, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return governance.ReleaseChangeSet{}, false, err
}
defer func() { _ = tx.Rollback(ctx) }()
id, err := newOpaqueID("rcs-")
if err != nil {
return governance.ReleaseChangeSet{}, false, err
}
set, err := scanReleaseChangeSet(tx.QueryRow(ctx, `
INSERT INTO release_change_sets(id,name,number,state,source_snapshot_ids,source_change_set_ids,acceptance_cutoff,production_baseline_ref,item_count,add_count,update_count,delete_count,blocked_count,manifest_sha256,created_by,created_at)
SELECT $1,$2,(SELECT COALESCE(MAX(number),0)+1 FROM release_change_sets),'planned',
COALESCE((SELECT jsonb_agg(snapshot_id ORDER BY snapshot_id) FROM (SELECT DISTINCT snapshot_id FROM source_catalog_heads) s),'[]'::jsonb),
COALESCE((SELECT jsonb_agg(change_set_id ORDER BY change_set_id) FROM (SELECT DISTINCT id AS change_set_id FROM source_catalog_change_sets WHERE state IN ('confirmed','applied')) c),'[]'::jsonb),
COALESCE((SELECT MAX(updated_at) FROM test_publications),(SELECT now())),
(SELECT COALESCE(MAX(completed_at)::text,'') FROM release_executions WHERE state='completed'),
$3,$4,$5,$6,$7,$8,$9,$10
RETURNING `+releaseChangeSetColumns,
id, request.Name, len(items), add, update, del, blocked, manifest, request.CreatedBy, request.Now))
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return governance.ReleaseChangeSet{}, true, err
}
return governance.ReleaseChangeSet{}, false, fmt.Errorf("create release change set: %w", err)
}
// R6 audit chain: mirror the JSONB binding into real FK rows so the trace
// survives even if someone later edits denormalized display fields.
if _, err := tx.Exec(ctx, `
INSERT INTO release_change_set_snapshot_links(change_set_id, snapshot_id)
SELECT $1, elem.value
FROM jsonb_array_elements_text($2::jsonb) AS elem(value)
ON CONFLICT DO NOTHING`, set.ID, mustMarshalJSON(set.SourceSnapshotIDs)); err != nil {
return governance.ReleaseChangeSet{}, false, fmt.Errorf("link release snapshots: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO release_change_set_catalog_links(change_set_id, catalog_change_set_id)
SELECT $1, elem.value
FROM jsonb_array_elements_text($2::jsonb) AS elem(value)
ON CONFLICT DO NOTHING`, set.ID, mustMarshalJSON(set.SourceChangeSetIDs)); err != nil {
return governance.ReleaseChangeSet{}, false, fmt.Errorf("link release catalog sets: %w", err)
}
for _, item := range items {
if _, err := tx.Exec(ctx, `INSERT INTO release_change_set_items(change_set_id,seq,publication_id,title,target_scope,action,test_state,production_state,can_release,blocker) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
set.ID, item.Seq, item.PublicationID, item.Title, item.TargetScope, item.Action, item.TestState, item.ProductionState, item.CanRelease, item.Blocker); err != nil {
return governance.ReleaseChangeSet{}, false, fmt.Errorf("insert release change set item: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return governance.ReleaseChangeSet{}, true, err
}
return governance.ReleaseChangeSet{}, false, err
}
return set, false, nil
}
func (s *Store) findPlannedByManifest(ctx context.Context, manifest string) (governance.ReleaseChangeSet, error) {
return scanReleaseChangeSet(s.db.QueryRow(ctx, `SELECT `+releaseChangeSetColumns+` FROM release_change_sets WHERE manifest_sha256=$1 AND state='planned' ORDER BY number DESC LIMIT 1`, manifest))
}
// findReusableByManifest 找与当前计划摘要一致的最近版本(planned 或 confirmed)。
// 建批时冻结用它复用:同一份计划不必因为每个批次都新建一版。
func (s *Store) findReusableByManifest(ctx context.Context, manifest string) (governance.ReleaseChangeSet, error) {
return scanReleaseChangeSet(s.db.QueryRow(ctx, `SELECT `+releaseChangeSetColumns+` FROM release_change_sets WHERE manifest_sha256=$1 AND state IN ('planned','confirmed') ORDER BY number DESC LIMIT 1`, manifest))
}
// ConfirmReleaseChangeSet freezes a planned set for batching. Re-confirming an
// already-confirmed set replays the stored confirmation (immutability).
func (s *Store) ConfirmReleaseChangeSet(ctx context.Context, request governance.ConfirmReleaseChangeSetRequest) (governance.ReleaseChangeSet, error) {
if !validRouteID(request.ChangeSetID) || !validReviewerRef(request.ConfirmedBy) || request.Now.IsZero() {
return governance.ReleaseChangeSet{}, ErrInvalidReleaseChangeSet
}
set, err := scanReleaseChangeSet(s.db.QueryRow(ctx, `UPDATE release_change_sets SET state='confirmed',confirmed_by=$2,confirmed_at=$3 WHERE id=$1 AND state='planned' RETURNING `+releaseChangeSetColumns, request.ChangeSetID, request.ConfirmedBy, request.Now))
if errors.Is(err, pgx.ErrNoRows) {
current, lookupErr := s.GetReleaseChangeSet(ctx, request.ChangeSetID)
if lookupErr != nil {
return governance.ReleaseChangeSet{}, lookupErr
}
if current.State == "confirmed" || current.State == "consumed" {
return current.ReleaseChangeSet, nil
}
return governance.ReleaseChangeSet{}, ErrReleaseChangeSetStateConflict
}
if err != nil {
return governance.ReleaseChangeSet{}, fmt.Errorf("confirm release change set: %w", err)
}
return set, nil
}
func (s *Store) GetReleaseChangeSet(ctx context.Context, id string) (governance.ReleaseChangeSetDetail, error) {
set, err := scanReleaseChangeSet(s.db.QueryRow(ctx, `SELECT `+releaseChangeSetColumns+` FROM release_change_sets WHERE id=$1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return governance.ReleaseChangeSetDetail{}, ErrReleaseChangeSetNotFound
}
if err != nil {
return governance.ReleaseChangeSetDetail{}, err
}
detail := governance.ReleaseChangeSetDetail{ReleaseChangeSet: set, Items: []governance.ReleaseChangeSetItem{}}
rows, err := s.db.Query(ctx, `SELECT seq,publication_id,title,target_scope,action,test_state,production_state,can_release,blocker FROM release_change_set_items WHERE change_set_id=$1 ORDER BY seq`, id)
if err != nil {
return governance.ReleaseChangeSetDetail{}, fmt.Errorf("list release change set items: %w", err)
}
defer rows.Close()
for rows.Next() {
var item governance.ReleaseChangeSetItem
if err := rows.Scan(&item.Seq, &item.PublicationID, &item.Title, &item.TargetScope, &item.Action, &item.TestState, &item.ProductionState, &item.CanRelease, &item.Blocker); err != nil {
return governance.ReleaseChangeSetDetail{}, err
}
detail.Items = append(detail.Items, item)
}
return detail, rows.Err()
}
func (s *Store) ListReleaseChangeSets(ctx context.Context, filter governance.ReleaseBatchFilter) (governance.ReleaseChangeSetPage, error) {
limit, offset := filter.Limit, filter.Offset
if limit <= 0 {
limit = 20
}
if offset < 0 {
offset = 0
}
page := governance.ReleaseChangeSetPage{Items: []governance.ReleaseChangeSet{}, Limit: limit, Offset: offset}
if limit > 100 {
return page, errors.New("release change set limit must be between 1 and 100")
}
var total int
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM release_change_sets`).Scan(&total); err != nil {
return page, err
}
page.Total = total
rows, err := s.db.Query(ctx, `SELECT `+releaseChangeSetColumns+` FROM release_change_sets ORDER BY number DESC LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
return page, err
}
defer rows.Close()
for rows.Next() {
item, err := scanReleaseChangeSet(rows)
if err != nil {
return page, err
}
page.Items = append(page.Items, item)
}
return page, rows.Err()
}
func (s *Store) LatestReleaseChangeSet(ctx context.Context) (governance.ReleaseChangeSetDetail, error) {
var id string
if err := s.db.QueryRow(ctx, `SELECT id FROM release_change_sets ORDER BY number DESC LIMIT 1`).Scan(&id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return governance.ReleaseChangeSetDetail{}, ErrReleaseChangeSetNotFound
}
return governance.ReleaseChangeSetDetail{}, err
}
return s.GetReleaseChangeSet(ctx, id)
}
func mustMarshalJSON(values []string) []byte {
raw, err := json.Marshal(values)
if err != nil {
return []byte("[]")
}
return raw
}