acf053faf3
背景:发布管理把"逐条核验"的仪式强加给每一份文档。实测一份文档要 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 条通过。
192 lines
7.0 KiB
Go
192 lines
7.0 KiB
Go
package testweknora
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"knowledge-governance/internal/governance"
|
||
"knowledge-governance/internal/jobs"
|
||
postgresstore "knowledge-governance/internal/platform/postgres"
|
||
"knowledge-governance/internal/platform/weknora"
|
||
)
|
||
|
||
var (
|
||
ErrInvalidValidateJobPayload = errors.New("test_validate_invalid_payload")
|
||
ErrValidateStateConflict = errors.New("test_validate_state_conflict")
|
||
ErrValidateRecordFailed = errors.New("test_validate_record_failed")
|
||
)
|
||
|
||
type ValidateStore interface {
|
||
LoadPublicationForValidation(context.Context, string) (postgresstore.PublicationForValidation, error)
|
||
AppendAcceptanceResult(context.Context, governance.AcceptanceResult) error
|
||
// ReviewPublication 复用人工审核同一条写入路径:同一套合法性校验与"最新验收轮次"绑定,
|
||
// 自动批准不另开一条 SQL,避免两套口径。
|
||
ReviewPublication(context.Context, governance.ReviewPublicationRequest) (governance.PublicationReview, error)
|
||
}
|
||
|
||
type ValidateClient interface {
|
||
GetKnowledge(context.Context, string) (weknora.Knowledge, error)
|
||
ListChunks(context.Context, string, int, int, []string) ([]weknora.Chunk, int64, error)
|
||
}
|
||
|
||
type ValidateHandlerConfig struct {
|
||
Store ValidateStore
|
||
Client ValidateClient
|
||
RunID string
|
||
PolicyVersion string
|
||
// AutoReviewPolicy:验收通过后是否由系统写入批准。零值按"仅对内"处理(与 env 默认一致)。
|
||
AutoReviewPolicy governance.AutoReviewPolicy
|
||
// Report 只用于记录自动批准自身的失败:它绝不能把验收判成失败。
|
||
Report func(error)
|
||
Now func() time.Time
|
||
}
|
||
|
||
type ValidateHandler struct {
|
||
store ValidateStore
|
||
client ValidateClient
|
||
runID string
|
||
policyVersion string
|
||
autoReviewPolicy governance.AutoReviewPolicy
|
||
report func(error)
|
||
now func() time.Time
|
||
}
|
||
|
||
// autoReviewerRef:自动批准在审计里必须一眼可辨,绝不冒用某个人的身份。
|
||
const autoReviewerRef = "system:auto"
|
||
|
||
type validatePayload struct {
|
||
PublicationID string `json:"publication_id"`
|
||
}
|
||
|
||
func NewValidateHandler(config ValidateHandlerConfig) (*ValidateHandler, error) {
|
||
if config.Store == nil || config.Client == nil || config.PolicyVersion == "" {
|
||
return nil, errors.New("invalid test validate handler configuration")
|
||
}
|
||
if config.RunID == "" {
|
||
config.RunID = "default"
|
||
}
|
||
if config.Now == nil {
|
||
config.Now = time.Now
|
||
}
|
||
if config.AutoReviewPolicy == "" {
|
||
config.AutoReviewPolicy = governance.AutoReviewInternal
|
||
}
|
||
if config.Report == nil {
|
||
config.Report = func(error) {}
|
||
}
|
||
return &ValidateHandler{
|
||
store: config.Store, client: config.Client, runID: config.RunID,
|
||
policyVersion: config.PolicyVersion, autoReviewPolicy: config.AutoReviewPolicy,
|
||
report: config.Report, now: config.Now,
|
||
}, nil
|
||
}
|
||
|
||
func (handler *ValidateHandler) Handle(ctx context.Context, job jobs.Job) error {
|
||
var payload validatePayload
|
||
if err := decodeStrictJSON(job.Payload, &payload); err != nil || payload.PublicationID == "" {
|
||
return ErrInvalidValidateJobPayload
|
||
}
|
||
publication, err := handler.store.LoadPublicationForValidation(ctx, payload.PublicationID)
|
||
if err != nil {
|
||
return ErrValidateStateConflict
|
||
}
|
||
now := handler.now()
|
||
runID := handler.runID
|
||
if job.ID != "" {
|
||
runID = job.ID
|
||
}
|
||
|
||
knowledge, knowledgeErr := handler.client.GetKnowledge(ctx, publication.KnowledgeID)
|
||
knowledgeState := governance.AcceptancePassed
|
||
knowledgeEvidence := json.RawMessage(`{"matched":true}`)
|
||
if knowledgeErr != nil || knowledge.ID != publication.KnowledgeID || knowledge.KnowledgeBaseID != publication.TestKBID {
|
||
knowledgeState = governance.AcceptanceFailed
|
||
knowledgeEvidence = json.RawMessage(`{"matched":false}`)
|
||
}
|
||
if err := handler.append(ctx, publication.ID, runID, "knowledge_exists", knowledgeState, knowledgeEvidence, now); err != nil {
|
||
return err
|
||
}
|
||
|
||
_, chunkTotal, chunksErr := handler.client.ListChunks(ctx, publication.KnowledgeID, 1, 1, nil)
|
||
chunksState := governance.AcceptancePassed
|
||
if chunksErr != nil || chunkTotal <= 0 {
|
||
chunksState = governance.AcceptanceFailed
|
||
}
|
||
if err := handler.append(ctx, publication.ID, runID, "chunks_present", chunksState, mustJSON(map[string]any{"chunk_total": chunkTotal}), now); err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := handler.append(ctx, publication.ID, runID, "retrieval_hit", governance.AcceptanceManualRequired, json.RawMessage(`{"reason":"thresholds_unresolved"}`), now); err != nil {
|
||
return err
|
||
}
|
||
handler.autoApprove(ctx, publication, runID, knowledgeState, chunksState, now)
|
||
return nil
|
||
}
|
||
|
||
// autoApprove:验收通过的知识由系统写入批准,替代"每份文档点一次批准"。
|
||
//
|
||
// 触发条件与发布门槛同口径:knowledge_exists / chunks_present 必须 passed
|
||
// (retrieval_hit 固定为 manual_required,发布门槛同样接受)。
|
||
// 自动批准自身的任何失败都不影响验收结论——只上报,不返回错误。
|
||
func (handler *ValidateHandler) autoApprove(ctx context.Context, publication postgresstore.PublicationForValidation, runID string, knowledgeState, chunksState governance.AcceptanceState, now time.Time) {
|
||
if !handler.autoReviewPolicy.Allows(publication.TargetScope) {
|
||
return
|
||
}
|
||
if knowledgeState != governance.AcceptancePassed || chunksState != governance.AcceptancePassed {
|
||
return
|
||
}
|
||
_, err := handler.store.ReviewPublication(ctx, governance.ReviewPublicationRequest{
|
||
PublicationID: publication.ID,
|
||
Decision: governance.ReviewApproved,
|
||
DecidedVisibility: governance.Visibility(publication.TargetScope),
|
||
ReviewerRef: autoReviewerRef,
|
||
DecisionCode: "approved_for_release",
|
||
Now: now,
|
||
})
|
||
if err == nil || errors.Is(err, postgresstore.ErrPublicationReviewNotEligible) {
|
||
// 已经有人审过(或不满足审核前置):不覆盖人工结论。
|
||
return
|
||
}
|
||
handler.report(fmt.Errorf("auto review %s: %w", publication.ID, err))
|
||
}
|
||
|
||
func (handler *ValidateHandler) append(ctx context.Context, publicationID, runID, checkType string, state governance.AcceptanceState, evidence json.RawMessage, observedAt time.Time) error {
|
||
result := governance.AcceptanceResult{
|
||
ID: acceptanceResultID(publicationID, runID, checkType),
|
||
PublicationID: publicationID,
|
||
RunID: runID,
|
||
CheckType: checkType,
|
||
State: state,
|
||
PolicyVersion: handler.policyVersion,
|
||
EvidenceSummary: evidence,
|
||
ObservedAt: observedAt,
|
||
}
|
||
if err := handler.store.AppendAcceptanceResult(ctx, result); err != nil {
|
||
return ErrValidateRecordFailed
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func acceptanceResultID(publicationID, runID, checkType string) string {
|
||
digest := sha256.New()
|
||
_, _ = digest.Write([]byte(publicationID))
|
||
_, _ = digest.Write([]byte{0})
|
||
_, _ = digest.Write([]byte(runID))
|
||
_, _ = digest.Write([]byte{0})
|
||
_, _ = digest.Write([]byte(checkType))
|
||
return "acc-" + hex.EncodeToString(digest.Sum(nil))[:32]
|
||
}
|
||
|
||
func mustJSON(value any) json.RawMessage {
|
||
data, err := json.Marshal(value)
|
||
if err != nil {
|
||
return json.RawMessage(`{}`)
|
||
}
|
||
return data
|
||
}
|