70131c6161
P0-3: - 批量审核:新增 POST /api/v1/review-candidates/batch-decision,逐条复用单条审核的写入 路径(同一套合法性校验与"最新验收轮次"绑定),逐条回报 decided/skipped/skipped_ids; 不可审核的条目只计入 skipped,不打断整批,其他错误整批回滚(不留半成品)。 界面新增「全选可批准」「批量批准选中」「批量驳回选中」。 - 建批即锁定:建批请求加 seal 字段,服务端在创建后立即执行既有的密封(仍逐项复核门禁, 失败整批回滚),草稿态不再单独占一步;界面按钮改为「创建并锁定清单」,流程说明从 8 步改 7 步。 - 判据修正:「可批准」原先把已驳回的算进去(review_state !== 'approved'),改为只含 pending + 门禁通过。 语义边界(写进测试):批量批准按人工身份记录,人工可推翻自己先前的驳回;system:auto 发起的 批量批准对人工驳回过的那条必须跳过(Decided=0/Skipped=1)——机器永远不覆盖人工。 测试:review_batch_integration_test.go(批量决定/跳报/重复 id 拒绝/机器不覆盖人工、 建批即锁定)、review_batch_test.go(幂等键、400/409/500/501 映射、服务端盖操作人)、 reviewRelease.test.ts(批量请求体与逐条回报、建批带 seal)。前端 181 条通过。
298 lines
15 KiB
Go
298 lines
15 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidReviewFilter = errors.New("invalid review candidate filter")
|
|
ErrInvalidPublicationReview = errors.New("invalid publication review")
|
|
ErrPublicationReviewNotEligible = errors.New("publication review is not eligible")
|
|
)
|
|
|
|
const reviewCandidatesCTE = `
|
|
WITH acceptance_run_summary AS (
|
|
SELECT publication_id, run_id, MAX(observed_at) AS observed_at,
|
|
COALESCE(MAX(state) FILTER (WHERE check_type = 'knowledge_exists'), '') AS knowledge_exists,
|
|
COALESCE(MAX(state) FILTER (WHERE check_type = 'chunks_present'), '') AS chunks_present,
|
|
COALESCE(MAX(state) FILTER (WHERE check_type = 'retrieval_hit'), '') AS retrieval_hit
|
|
FROM acceptance_results
|
|
GROUP BY publication_id, run_id
|
|
), latest_acceptance AS (
|
|
SELECT DISTINCT ON (publication_id)
|
|
publication_id, run_id, knowledge_exists, chunks_present, retrieval_hit, observed_at
|
|
FROM acceptance_run_summary
|
|
ORDER BY publication_id, observed_at DESC, run_id DESC
|
|
), candidates AS (
|
|
SELECT p.id AS publication_id, i.title, connection.source_type,
|
|
COALESCE(item_scope.visibility, directory_scope.visibility, i.visibility) AS current_visibility,
|
|
acceptance.run_id AS acceptance_run_id,
|
|
acceptance.knowledge_exists, acceptance.chunks_present, acceptance.retrieval_hit,
|
|
CASE
|
|
WHEN p.ingest_state <> 'uploaded' OR p.processing_state <> 'completed' OR p.retrieval_state <> 'unpublished' THEN 'blocked'
|
|
WHEN version.revision <> i.revision OR i.source_change IN ('deleted', 'unavailable') THEN 'blocked'
|
|
WHEN acceptance.knowledge_exists = 'failed' OR acceptance.chunks_present = 'failed' OR acceptance.retrieval_hit = 'failed' THEN 'blocked'
|
|
WHEN acceptance.knowledge_exists <> 'passed' OR acceptance.chunks_present <> 'passed'
|
|
OR acceptance.retrieval_hit NOT IN ('passed', 'manual_required') THEN 'blocked'
|
|
ELSE 'ready'
|
|
END AS gate_status,
|
|
CASE
|
|
WHEN p.ingest_state <> 'uploaded' OR p.processing_state <> 'completed' OR p.retrieval_state <> 'unpublished' THEN 'publication_not_ready'
|
|
WHEN version.revision <> i.revision OR i.source_change IN ('deleted', 'unavailable') THEN 'source_not_current'
|
|
WHEN acceptance.knowledge_exists = 'failed' OR acceptance.chunks_present = 'failed' OR acceptance.retrieval_hit = 'failed' THEN 'acceptance_failed'
|
|
WHEN acceptance.knowledge_exists <> 'passed' OR acceptance.chunks_present <> 'passed'
|
|
OR acceptance.retrieval_hit NOT IN ('passed', 'manual_required') THEN 'acceptance_incomplete'
|
|
ELSE 'ready'
|
|
END AS gate_code,
|
|
CASE
|
|
WHEN review.id IS NOT NULL AND review.acceptance_run_id = acceptance.run_id
|
|
AND review.publication_updated_at = p.updated_at THEN review.decision
|
|
ELSE 'pending'
|
|
END AS review_state,
|
|
CASE WHEN review.id IS NOT NULL AND review.acceptance_run_id = acceptance.run_id
|
|
AND review.publication_updated_at = p.updated_at THEN review.decided_visibility ELSE '' END AS decided_visibility,
|
|
CASE WHEN review.id IS NOT NULL AND review.acceptance_run_id = acceptance.run_id
|
|
AND review.publication_updated_at = p.updated_at THEN review.decision_code ELSE '' END AS decision_code,
|
|
CASE WHEN review.id IS NOT NULL AND review.acceptance_run_id = acceptance.run_id
|
|
AND review.publication_updated_at = p.updated_at THEN review.reviewer_ref ELSE '' END AS reviewer_ref,
|
|
CASE WHEN review.id IS NOT NULL AND review.acceptance_run_id = acceptance.run_id
|
|
AND review.publication_updated_at = p.updated_at THEN review.created_at ELSE NULL END AS reviewed_at,
|
|
p.updated_at
|
|
FROM test_publications AS p
|
|
JOIN source_item_versions AS version ON version.id = p.source_item_version_id
|
|
JOIN source_items AS i ON i.id = version.source_item_id
|
|
JOIN source_connections AS connection ON connection.id = i.source_connection_id
|
|
LEFT JOIN source_item_scope_overrides AS item_scope ON item_scope.source_item_id = i.id
|
|
LEFT JOIN LATERAL (
|
|
SELECT rule.visibility
|
|
FROM directory_scope_rules AS rule
|
|
WHERE rule.source_connection_id = i.source_connection_id
|
|
AND (
|
|
rule.directory_path = ''
|
|
OR i.normalized_path = rule.directory_path
|
|
OR LEFT(i.normalized_path, LENGTH(rule.directory_path) + 1) = rule.directory_path || '/'
|
|
)
|
|
ORDER BY LENGTH(rule.directory_path) DESC
|
|
LIMIT 1
|
|
) AS directory_scope ON TRUE
|
|
JOIN latest_acceptance AS acceptance ON acceptance.publication_id = p.id
|
|
LEFT JOIN LATERAL (
|
|
SELECT id, decision, decided_visibility, decision_code, reviewer_ref,
|
|
acceptance_run_id, publication_updated_at, created_at
|
|
FROM publication_reviews
|
|
WHERE publication_id = p.id
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT 1
|
|
) AS review ON TRUE
|
|
)
|
|
`
|
|
|
|
const countReviewCandidatesSQL = reviewCandidatesCTE + `
|
|
SELECT COUNT(*)::INTEGER
|
|
FROM candidates
|
|
WHERE $1 = '' OR review_state = $1
|
|
`
|
|
|
|
const listReviewCandidatesSQL = reviewCandidatesCTE + `
|
|
SELECT publication_id, title, source_type, current_visibility,
|
|
acceptance_run_id, knowledge_exists, chunks_present, retrieval_hit,
|
|
gate_status, gate_code, review_state, decided_visibility,
|
|
decision_code, reviewer_ref, reviewed_at, updated_at
|
|
FROM candidates
|
|
WHERE $1 = '' OR review_state = $1
|
|
ORDER BY updated_at DESC, publication_id
|
|
LIMIT $2 OFFSET $3
|
|
`
|
|
|
|
const reviewPublicationSQL = reviewCandidatesCTE + `
|
|
INSERT INTO publication_reviews (
|
|
id, publication_id, decision, decided_visibility, reviewer_ref,
|
|
decision_code, acceptance_run_id, publication_updated_at, created_at
|
|
)
|
|
SELECT $1::varchar, publication_id, $3::varchar, $4::varchar, $5::varchar, $6::varchar,
|
|
acceptance_run_id, updated_at, $7::timestamptz
|
|
FROM candidates
|
|
WHERE publication_id = $2::varchar
|
|
AND ($3::varchar = 'rejected' OR gate_status = 'ready')
|
|
-- 系统自动批准(reviewer_ref='system:auto')只允许写"第一条"审核:人工一旦表过态,
|
|
-- 机器不得再用一条更晚的 approved 把人工结论静默翻回去(重跑验收时尤其危险)。
|
|
-- 人工审核不受此限,随时可再审。
|
|
AND ($5::varchar <> 'system:auto' OR NOT EXISTS (
|
|
SELECT 1 FROM publication_reviews existing
|
|
WHERE existing.publication_id = candidates.publication_id))
|
|
ON CONFLICT (
|
|
publication_id, acceptance_run_id, publication_updated_at,
|
|
reviewer_ref, decision, decided_visibility, decision_code
|
|
) DO UPDATE SET id = publication_reviews.id
|
|
RETURNING id, publication_id, decision, decided_visibility, reviewer_ref,
|
|
decision_code, acceptance_run_id, publication_updated_at, created_at
|
|
`
|
|
|
|
func (s *Store) ListReviewCandidates(ctx context.Context, filter governance.ReviewCandidateFilter) (governance.ReviewCandidatePage, error) {
|
|
if filter.Limit < 1 || filter.Limit > 100 || filter.Offset < 0 || filter.Offset > 1000000 ||
|
|
filter.ReviewState != "" && filter.ReviewState != string(governance.ReviewPending) &&
|
|
filter.ReviewState != string(governance.ReviewApproved) && filter.ReviewState != string(governance.ReviewRejected) {
|
|
return governance.ReviewCandidatePage{}, ErrInvalidReviewFilter
|
|
}
|
|
page := governance.ReviewCandidatePage{Items: []governance.ReviewCandidate{}, Limit: filter.Limit, Offset: filter.Offset}
|
|
if err := s.db.QueryRow(ctx, countReviewCandidatesSQL, filter.ReviewState).Scan(&page.Total); err != nil {
|
|
return governance.ReviewCandidatePage{}, fmt.Errorf("count review candidates: %w", err)
|
|
}
|
|
rows, err := s.db.Query(ctx, listReviewCandidatesSQL, filter.ReviewState, filter.Limit, filter.Offset)
|
|
if err != nil {
|
|
return governance.ReviewCandidatePage{}, fmt.Errorf("list review candidates: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var item governance.ReviewCandidate
|
|
var sourceType, visibility, knowledgeState, chunksState, retrievalState, gateStatus, reviewState string
|
|
if err := rows.Scan(
|
|
&item.PublicationID, &item.Title, &sourceType, &visibility,
|
|
&item.AcceptanceRunID, &knowledgeState, &chunksState, &retrievalState,
|
|
&gateStatus, &item.GateCode, &reviewState, &item.DecidedVisibility,
|
|
&item.DecisionCode, &item.ReviewerRef, &item.ReviewedAt, &item.PublicationUpdated,
|
|
); err != nil {
|
|
return governance.ReviewCandidatePage{}, fmt.Errorf("scan review candidate: %w", err)
|
|
}
|
|
item.SourceType = governance.SourceType(sourceType)
|
|
item.CurrentVisibility = governance.Visibility(visibility)
|
|
item.KnowledgeExists = governance.AcceptanceState(knowledgeState)
|
|
item.ChunksPresent = governance.AcceptanceState(chunksState)
|
|
item.RetrievalHit = governance.AcceptanceState(retrievalState)
|
|
item.GateStatus = governance.ReviewGateStatus(gateStatus)
|
|
item.ReviewState = governance.ReviewState(reviewState)
|
|
if !item.SourceType.Valid() || !item.GateStatus.Valid() || !item.ReviewState.Valid() ||
|
|
!item.KnowledgeExists.Valid() || !item.ChunksPresent.Valid() || !item.RetrievalHit.Valid() ||
|
|
item.CurrentVisibility != governance.VisibilityInternal && item.CurrentVisibility != governance.VisibilityExternal {
|
|
return governance.ReviewCandidatePage{}, errors.New("invalid review candidate row")
|
|
}
|
|
page.Items = append(page.Items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return governance.ReviewCandidatePage{}, fmt.Errorf("iterate review candidates: %w", err)
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
func (s *Store) ReviewPublicationWithOperation(ctx context.Context, request governance.ReviewPublicationRequest, operationID string) (governance.PublicationReview, bool, error) {
|
|
fingerprint, err := operationFingerprint(struct {
|
|
PublicationID string `json:"publication_id"`
|
|
Decision governance.ReviewState `json:"decision"`
|
|
Visibility governance.Visibility `json:"decided_visibility"`
|
|
ReviewerRef string `json:"reviewer_ref"`
|
|
Code string `json:"decision_code"`
|
|
}{request.PublicationID, request.Decision, request.DecidedVisibility, request.ReviewerRef, request.DecisionCode})
|
|
if err != nil {
|
|
return governance.PublicationReview{}, false, err
|
|
}
|
|
raw, replayed, err := s.executeOperation(ctx, "publication.review", operationID, fingerprint, request.Now, func(tx pgx.Tx) (json.RawMessage, error) {
|
|
review, err := NewStore(tx).ReviewPublication(ctx, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(review)
|
|
})
|
|
if err != nil {
|
|
return governance.PublicationReview{}, false, err
|
|
}
|
|
var review governance.PublicationReview
|
|
if err := json.Unmarshal(raw, &review); err != nil {
|
|
return governance.PublicationReview{}, false, fmt.Errorf("decode stored review result: %w", err)
|
|
}
|
|
return review, replayed, nil
|
|
}
|
|
|
|
func (s *Store) ReviewPublication(ctx context.Context, request governance.ReviewPublicationRequest) (governance.PublicationReview, error) {
|
|
if !validPublicationReview(request) {
|
|
return governance.PublicationReview{}, ErrInvalidPublicationReview
|
|
}
|
|
reviewID, err := newOpaqueID("rv-")
|
|
if err != nil {
|
|
return governance.PublicationReview{}, err
|
|
}
|
|
var review governance.PublicationReview
|
|
var decision, visibility string
|
|
err = s.db.QueryRow(ctx, reviewPublicationSQL,
|
|
reviewID, request.PublicationID, string(request.Decision), string(request.DecidedVisibility),
|
|
request.ReviewerRef, request.DecisionCode, request.Now,
|
|
).Scan(
|
|
&review.ID, &review.PublicationID, &decision, &visibility, &review.ReviewerRef,
|
|
&review.DecisionCode, &review.AcceptanceRunID, &review.PublicationUpdated, &review.CreatedAt,
|
|
)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return governance.PublicationReview{}, ErrPublicationReviewNotEligible
|
|
}
|
|
if err != nil {
|
|
return governance.PublicationReview{}, fmt.Errorf("review publication: %w", err)
|
|
}
|
|
review.Decision = governance.ReviewState(decision)
|
|
review.DecidedVisibility = governance.Visibility(visibility)
|
|
return review, nil
|
|
}
|
|
|
|
// ReviewPublicationsBatch 逐条复用 ReviewPublication(同一套合法性校验与"最新验收轮次"绑定),
|
|
// 不做第二套 SQL。某条当时不可审核(例如人工刚驳回过)只计入 SkippedIDs,不打断整批;
|
|
// 其他错误整批回滚——批量操作不能留下"一半批了、一半没批"的模糊状态。
|
|
func (s *Store) ReviewPublicationsBatch(ctx context.Context, request governance.ReviewPublicationsBatchRequest) (governance.ReviewPublicationsBatchResult, error) {
|
|
if !validPublicationReview(governance.ReviewPublicationRequest{
|
|
PublicationID: "tp-batch-validation", Decision: request.Decision, DecidedVisibility: request.DecidedVisibility,
|
|
ReviewerRef: request.ReviewerRef, DecisionCode: request.DecisionCode, Now: request.Now,
|
|
}) {
|
|
return governance.ReviewPublicationsBatchResult{}, ErrInvalidPublicationReview
|
|
}
|
|
if len(request.PublicationIDs) == 0 || len(request.PublicationIDs) > 100 {
|
|
return governance.ReviewPublicationsBatchResult{}, ErrInvalidPublicationReview
|
|
}
|
|
seen := make(map[string]bool, len(request.PublicationIDs))
|
|
result := governance.ReviewPublicationsBatchResult{Selected: len(request.PublicationIDs), SkippedIDs: []string{}, Reviews: []governance.PublicationReview{}}
|
|
for _, publicationID := range request.PublicationIDs {
|
|
if !validShortID(publicationID) || seen[publicationID] {
|
|
return governance.ReviewPublicationsBatchResult{}, ErrInvalidPublicationReview
|
|
}
|
|
seen[publicationID] = true
|
|
review, err := s.ReviewPublication(ctx, governance.ReviewPublicationRequest{
|
|
PublicationID: publicationID, Decision: request.Decision, DecidedVisibility: request.DecidedVisibility,
|
|
ReviewerRef: request.ReviewerRef, DecisionCode: request.DecisionCode, Now: request.Now,
|
|
})
|
|
switch {
|
|
case err == nil:
|
|
result.Decided++
|
|
result.Reviews = append(result.Reviews, review)
|
|
case errors.Is(err, ErrPublicationReviewNotEligible):
|
|
result.Skipped++
|
|
result.SkippedIDs = append(result.SkippedIDs, publicationID)
|
|
default:
|
|
return governance.ReviewPublicationsBatchResult{}, err
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validPublicationReview(request governance.ReviewPublicationRequest) bool {
|
|
if !validShortID(request.PublicationID) || request.Now.IsZero() ||
|
|
request.DecidedVisibility != governance.VisibilityInternal && request.DecidedVisibility != governance.VisibilityExternal ||
|
|
!validReviewerRef(request.ReviewerRef) {
|
|
return false
|
|
}
|
|
switch request.Decision {
|
|
case governance.ReviewApproved:
|
|
return request.DecisionCode == "approved_for_release"
|
|
case governance.ReviewRejected:
|
|
return request.DecisionCode == "needs_correction" || request.DecisionCode == "classification_changed" || request.DecisionCode == "acceptance_rejected"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validReviewerRef(value string) bool {
|
|
return value == strings.TrimSpace(value) && value != "" && len(value) <= 128 && !strings.ContainsAny(value, "\x00\r\n ")
|
|
}
|