e9a643cc33
界面验收实测(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)。
139 lines
6.5 KiB
Go
139 lines
6.5 KiB
Go
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
|
||
}
|