package postgres import ( "context" "errors" "fmt" "knowledge-governance/internal/governance" ) const listReleasePlanSQL = ` WITH ` + releaseGateCTEs + `, source_truth AS ( SELECT source.id AS source_item_id, source.title, source.revision, source.source_change, -- 「禁止导入」记录在 source_items.policy_state 上;这里必须把它算进期望策略, -- 否则被禁止的条目会被当成"仍然保留"→ 生产侧已存在的它只会算出 unchanged, -- 永远不产生 delete 动作,生产知识无法通过发布通道下架 -- (2026-10-10 实测:PDF 已 enabled 于生产库、policy_state=blocked,计划给的却是 -- unchanged/present、can_release=false)。 CASE WHEN source.policy_state='blocked' THEN 'blocked' ELSE COALESCE(item_rule.visibility, directory_rule.visibility, source.visibility) END AS desired_policy FROM source_items source LEFT JOIN source_item_scope_overrides item_rule ON item_rule.source_item_id=source.id LEFT JOIN LATERAL ( SELECT rule.visibility FROM directory_scope_rules rule WHERE rule.source_connection_id=source.source_connection_id AND (rule.directory_path='' OR source.normalized_path=rule.directory_path OR source.normalized_path LIKE rule.directory_path||'/%') ORDER BY LENGTH(rule.directory_path) DESC LIMIT 1 ) directory_rule ON TRUE ), desired AS ( SELECT truth.source_item_id, truth.title, scope.target_scope FROM source_truth truth CROSS JOIN LATERAL unnest( CASE WHEN truth.source_change='deleted' OR truth.desired_policy='blocked' THEN ARRAY[]::TEXT[] WHEN truth.desired_policy='both' THEN ARRAY['internal','external']::TEXT[] ELSE ARRAY[truth.desired_policy]::TEXT[] END ) scope(target_scope) ), test_ready AS ( SELECT DISTINCT ON (version.source_item_id, publication.target_scope) version.source_item_id, publication.target_scope, publication.id AS publication_id, ready.review_id AS release_revision FROM release_ready ready JOIN test_publications publication ON publication.id=ready.publication_id JOIN source_item_versions version ON version.id=publication.source_item_version_id ORDER BY version.source_item_id, publication.target_scope, publication.updated_at DESC, publication.id DESC ), latest_production AS ( SELECT DISTINCT ON (version.source_item_id, publication.target_scope) version.source_item_id, publication.target_scope, publication.id AS publication_id, item.production_revision FROM release_item_executions item JOIN release_executions execution ON execution.id=item.release_execution_id JOIN test_publications publication ON publication.id=item.publication_id JOIN source_item_versions version ON version.id=publication.source_item_version_id WHERE execution.state IN ('completed','rolled_back') AND item.state IN ('verified','rolled_back') ORDER BY version.source_item_id, publication.target_scope, execution.completed_at DESC NULLS LAST, execution.id DESC ), production AS ( SELECT * FROM latest_production WHERE production_revision<>'' AND production_revision<>'absent' ), all_scopes AS ( SELECT source_item_id,target_scope FROM desired UNION SELECT source_item_id,target_scope FROM production ), plan AS ( SELECT COALESCE(test.publication_id, production.publication_id, '') AS publication_id, truth.title, truth.source_change, scope.target_scope, CASE WHEN truth.source_change='unavailable' THEN 'blocked' WHEN desired.source_item_id IS NULL AND production.source_item_id IS NOT NULL THEN 'delete' WHEN desired.source_item_id IS NOT NULL AND test.publication_id IS NULL THEN 'blocked' WHEN production.source_item_id IS NULL THEN 'add' WHEN production.production_revision<>test.release_revision THEN 'update' ELSE 'unchanged' END AS action, CASE WHEN desired.source_item_id IS NULL THEN 'not_required' WHEN test.publication_id IS NULL THEN 'not_ready' ELSE 'ready' END AS test_state, CASE WHEN production.source_item_id IS NULL THEN 'absent' ELSE 'present' END AS production_state FROM all_scopes scope JOIN source_truth truth ON truth.source_item_id=scope.source_item_id LEFT JOIN desired ON desired.source_item_id=scope.source_item_id AND desired.target_scope=scope.target_scope LEFT JOIN test_ready test ON test.source_item_id=scope.source_item_id AND test.target_scope=scope.target_scope LEFT JOIN production ON production.source_item_id=scope.source_item_id AND production.target_scope=scope.target_scope ) SELECT publication_id,title,target_scope,action,test_state,production_state, action IN ('add', 'update', 'delete') AS can_release, CASE WHEN action='blocked' AND source_change='unavailable' THEN 'source_unavailable' WHEN action='blocked' THEN 'test_not_ready' ELSE '' END AS blocker FROM plan ORDER BY CASE action WHEN 'delete' THEN 0 WHEN 'blocked' THEN 1 WHEN 'update' THEN 2 WHEN 'add' THEN 3 ELSE 4 END, title, target_scope ` func (s *Store) ListReleasePlan(ctx context.Context) (governance.ReleasePlan, error) { rows, err := s.db.Query(ctx, listReleasePlanSQL) if err != nil { return governance.ReleasePlan{}, fmt.Errorf("list release plan: %w", err) } defer rows.Close() plan := governance.ReleasePlan{Items: []governance.ReleasePlanItem{}} for rows.Next() { var item governance.ReleasePlanItem if err := rows.Scan(&item.PublicationID, &item.Title, &item.TargetScope, &item.Action, &item.TestState, &item.ProductionState, &item.CanRelease, &item.Blocker); err != nil { return governance.ReleasePlan{}, fmt.Errorf("scan release plan: %w", err) } if (item.TargetScope != "internal" && item.TargetScope != "external") || (item.Action != "add" && item.Action != "update" && item.Action != "delete" && item.Action != "blocked" && item.Action != "unchanged") { return governance.ReleasePlan{}, errors.New("invalid release plan row") } switch item.Action { case "add": plan.Summary.Add++ case "update": plan.Summary.Update++ case "delete": plan.Summary.Delete++ case "unchanged": plan.Summary.Unchanged++ continue } if !item.CanRelease { plan.Summary.Blocked++ } plan.Items = append(plan.Items, item) } if err := rows.Err(); err != nil { return governance.ReleasePlan{}, fmt.Errorf("iterate release plan: %w", err) } return plan, nil }