Files
knowledge-governance/internal/platform/postgres/release_plan.go
姚章浩 e9a643cc33 fix(release): 禁止导入未被算进期望策略 —— 生产知识下不了架(计划与建批两处同源缺陷)
界面验收实测(2026-10-10):把已发布到生产的 PDF 改为「禁止导入」后,发布通道既给不出
delete、也建不了 delete 批次。

根因:禁止导入只写 source_items.policy_state='blocked',而"期望策略"的解析只看
source_item_scope_overrides / directory_scope_rules / source_items.visibility——
两处实现都不看 policy_state:
  1) release_plan.go 的 source_truth:被禁止且生产侧存在的条目被算成 unchanged/present、
     can_release=false → 永远不会出现 delete 动作;
  2) release_batches.go 的 releaseDeleteReadyCTEs.source_truth:同样口径 → 计划即使给了
     delete,创建封批仍被拒 422(实测原文「创建封批失败(HTTP 422)」)。

修复:两处都把 policy_state='blocked' 算进期望策略(各一行,注释写明成因)。
新增集成测试 release_plan_policy_state_integration_test.go:用 policy_state(真实路由的写法)
造"生产存在 + 已禁止"的条目,断言①计划给出 action=delete/can_release、②delete 批次可创建;
分别回退任一处修复,该测试即 FAIL(已实测两种回退都 FAIL)。

实测闭环:重新发布使生产存在(enable_status=enabled)→ 禁止导入 → 计划 delete(present/
can_release=true)→ v8 delete_count=1 → 批次 rb-8c1ad1c9…(sealed, action=delete) → 留档
20261010T033100Z + 门禁 PASS → 执行 rx-6c19232a… completed(项级 verified/delete、
production_revision=absent)→ 目标库该行 enable_status=disabled(行保留、检索停用、可恢复)。

附带:从未导入却被标为禁止的 82 条"等待测试"噪音不再占发布计划(195 → 113 项)。
证据与说明补进 docs/USER_FLOWS_ACCEPTANCE.md(C2b + F.3h)。
2026-10-10 11:36:08 +08:00

139 lines
6.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}