Files
knowledge-governance/internal/workflows/release/handler.go
T
姚章浩 6440e3f679 feat(release): 发布目标知识库可选(源库 → 目标库映射)
- 迁移 000044:production_kb_bindings + release_item_executions.target_knowledge_base_id
- 系统设置新增「生产知识库绑定(对内/对外)」:从生产 WeKnora 实际列表选库,
  保存时真校验(不存在拒收;生产端不可达只提示并如实回报),仅管理员可改
- 执行计划按绑定解析目标库并冻结进 release_item_executions;空目标直接拒收;
  已发布过的条目保留原目标(重试/回滚回到同一处)
- DocumentBundle.RemapKnowledgeBase:knowledges/chunks/knowledge_tags/wiki_pages/
  wiki_folders 的 knowledge_base_id 与 ES 文档字段改写为目标库,重算表/文档摘要并重新封装
- 契约比对改为「声明的映射对」(KnowledgeBaseMapping),其余字段继续 fail-closed
- 契约检查器 / 目标身份 / 验证器全部走映射后的目标库;对象通道无需改写
- Compose 拆分:数据面接线 compose.release-sim.yaml + 两道门禁 compose.release-gates.yaml
- 测试:RemapKnowledgeBase 全通道/无操作/fail-closed、映射契约、绑定接口、RBAC、真实 PG 集成
2026-10-08 15:41:13 +08:00

314 lines
10 KiB
Go

package release
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"os"
"strings"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
)
// debugReleaseItemErrors enables fail-closed-safe diagnostics: only the
// generic step-level cause text is logged, never payload or credentials.
var debugReleaseItemErrors = os.Getenv("RELEASE_DEBUG_ITEM_ERRORS") == "true"
const (
ErrorCodeContractMismatch = "release_contract_mismatch"
ErrorCodeBundleInvalid = "release_bundle_invalid"
ErrorCodeTargetUnavailable = "release_target_unavailable"
ErrorCodeTargetWriteFailed = "release_target_write_failed"
ErrorCodeVerificationFailed = "release_verification_failed"
ErrorCodeRollbackArtifactMissing = "release_rollback_artifact_missing"
ErrorCodeRollbackFailed = "release_rollback_failed"
ErrorCodeStoppedAfterFailure = "release_stopped_after_failure"
ErrorCodeInternal = "release_internal_error"
)
var (
ErrInvalidReleaseJob = errors.New("invalid_release_job")
ErrReleaseRetryable = errors.New("release_retryable")
ErrReleaseState = errors.New("release_state_failed")
)
type PlanItem struct {
ReleaseExecutionID string `json:"release_execution_id"`
PublicationID string `json:"publication_id"`
Action string `json:"action"`
SourceItemVersionID string `json:"source_item_version_id"`
SourceRevision string `json:"source_revision"`
ParseRevision string `json:"parse_revision"`
ReleaseRevision string `json:"release_revision"`
TestKnowledgeBaseID string `json:"test_knowledge_base_id"`
// TargetKnowledgeBaseID is the 源库 → 目标库 mapping resolved when the
// execution started (see production_kb_bindings). It equals the tested base
// when the scope is unbound, which is the historical same-id semantics.
TargetKnowledgeBaseID string `json:"target_knowledge_base_id"`
KnowledgeID string `json:"knowledge_id"`
DecidedVisibility governance.Visibility `json:"decided_visibility"`
PreviousProductionRevision string `json:"previous_production_revision"`
}
type ItemResult struct {
BundleSHA256 string
PreviousProductionRevision string
ProductionRevision string
}
// TargetBase is the knowledge base this item is written to: the resolved
// 源库 → 目标库 mapping when the release carries one, otherwise the tested base
// itself (the historical same-id semantics).
func (item PlanItem) TargetBase() string {
if trimmed := strings.TrimSpace(item.TargetKnowledgeBaseID); trimmed != "" {
return trimmed
}
return item.TestKnowledgeBaseID
}
type StartExecutionRequest struct {
ExecutionID string
JobID string
Operation governance.ReleaseOperation
Now time.Time
}
type CompleteItemRequest struct {
ExecutionID string
PublicationID string
BundleSHA256 string
PreviousProductionRevision string
ProductionRevision string
Now time.Time
}
type FailItemRequest struct {
ExecutionID string
PublicationID string
ErrorCode string
Now time.Time
}
type Store interface {
StartExecution(context.Context, StartExecutionRequest) ([]PlanItem, error)
BeginBatch(context.Context, string, int, int, time.Time) error
CompleteItem(context.Context, CompleteItemRequest) error
FailItem(context.Context, FailItemRequest) error
FinishExecution(context.Context, string, time.Time) error
ScheduleAutomaticRollback(context.Context, string, time.Time) error
}
type Publisher interface {
Publish(context.Context, PlanItem) (ItemResult, error)
Delete(context.Context, PlanItem) (ItemResult, error)
Rollback(context.Context, PlanItem) (ItemResult, error)
}
type ItemError struct {
Code string
Retryable bool
Cause error
}
func (err *ItemError) Error() string {
if err == nil || !ValidErrorCode(err.Code) {
return ErrorCodeInternal
}
return err.Code
}
func (err *ItemError) Unwrap() error {
if err == nil {
return nil
}
return err.Cause
}
type jobPayload struct {
ExecutionID string `json:"execution_id"`
ReleaseBatchID string `json:"release_batch_id"`
Operation string `json:"operation"`
PriorExecutionID string `json:"prior_execution_id"`
}
func NewHandler(store Store, publisher Publisher, now func() time.Time) (jobs.Handler, error) {
if store == nil || publisher == nil || now == nil {
return nil, errors.New("release handler dependencies are required")
}
return func(ctx context.Context, job jobs.Job) error {
payload, operation, err := parseJob(job)
if err != nil {
return err
}
items, err := store.StartExecution(ctx, StartExecutionRequest{
ExecutionID: payload.ExecutionID, JobID: job.ID, Operation: operation, Now: now().UTC(),
})
if err != nil {
return ErrReleaseState
}
retryableFailure := false
permanentFailure := false
failureIndex := -1
const batchSize = 50
totalBatches := (len(items) + batchSize - 1) / batchSize
for start := 0; start < len(items); start += batchSize {
batchNumber := start/batchSize + 1
if err := store.BeginBatch(ctx, payload.ExecutionID, batchNumber, totalBatches, now().UTC()); err != nil {
return ErrReleaseState
}
end := start + batchSize
if end > len(items) {
end = len(items)
}
for index := start; index < end; index++ {
item := items[index]
result, itemErr := publishItem(ctx, publisher, operation, item)
if itemErr != nil {
code, retryable := classifyItemError(itemErr)
if debugReleaseItemErrors {
var itemErrDetail *ItemError
causeText := ""
if errors.As(itemErr, &itemErrDetail) && itemErrDetail != nil && itemErrDetail.Cause != nil {
causeText = itemErrDetail.Cause.Error()
}
log.Printf("release item %s (%s/%s) failed code=%s cause=%s", item.PublicationID, item.Action, item.KnowledgeID, code, causeText)
}
if retryable && job.Attempt < job.MaxAttempts {
retryableFailure = true
continue
}
if err := store.FailItem(ctx, FailItemRequest{ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID, ErrorCode: code, Now: now().UTC()}); err != nil {
return ErrReleaseState
}
permanentFailure = true
failureIndex = index
break
}
if !validResult(operation, result) {
if err := store.FailItem(ctx, FailItemRequest{ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID, ErrorCode: ErrorCodeBundleInvalid, Now: now().UTC()}); err != nil {
return ErrReleaseState
}
permanentFailure = true
failureIndex = index
break
}
if err := store.CompleteItem(ctx, CompleteItemRequest{
ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID,
BundleSHA256: result.BundleSHA256, PreviousProductionRevision: result.PreviousProductionRevision,
ProductionRevision: result.ProductionRevision, Now: now().UTC(),
}); err != nil {
return ErrReleaseState
}
}
if permanentFailure {
break
}
}
if permanentFailure {
for _, item := range items[failureIndex+1:] {
if err := store.FailItem(ctx, FailItemRequest{ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID, ErrorCode: ErrorCodeStoppedAfterFailure, Now: now().UTC()}); err != nil {
return ErrReleaseState
}
}
}
if retryableFailure {
return ErrReleaseRetryable
}
if err := store.FinishExecution(ctx, payload.ExecutionID, now().UTC()); err != nil {
return ErrReleaseState
}
if permanentFailure && operation == governance.ReleaseOperationPublish {
if err := store.ScheduleAutomaticRollback(ctx, payload.ExecutionID, now().UTC()); err != nil {
return ErrReleaseState
}
}
return nil
}, nil
}
func parseJob(job jobs.Job) (jobPayload, governance.ReleaseOperation, error) {
var payload jobPayload
decoder := json.NewDecoder(strings.NewReader(string(job.Payload)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
return jobPayload{}, "", ErrInvalidReleaseJob
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return jobPayload{}, "", ErrInvalidReleaseJob
}
operation := governance.ReleaseOperation(payload.Operation)
expectedType := "release." + payload.Operation
if !validID(payload.ExecutionID, "rx-") || !validID(payload.ReleaseBatchID, "rb-") || !operation.Valid() || job.Type != expectedType || job.Attempt < 1 || job.MaxAttempts < job.Attempt {
return jobPayload{}, "", ErrInvalidReleaseJob
}
return payload, operation, nil
}
func publishItem(ctx context.Context, publisher Publisher, operation governance.ReleaseOperation, item PlanItem) (ItemResult, error) {
if operation == governance.ReleaseOperationRollback {
return publisher.Rollback(ctx, item)
}
if item.Action == "delete" {
return publisher.Delete(ctx, item)
}
return publisher.Publish(ctx, item)
}
func classifyItemError(err error) (string, bool) {
var itemErr *ItemError
if errors.As(err, &itemErr) && ValidErrorCode(itemErr.Code) {
return itemErr.Code, itemErr.Retryable
}
return ErrorCodeInternal, true
}
func validResult(operation governance.ReleaseOperation, result ItemResult) bool {
if strings.TrimSpace(result.ProductionRevision) == "" || len(result.ProductionRevision) > 128 {
return false
}
if operation == governance.ReleaseOperationPublish {
return validSHA256(result.BundleSHA256)
}
return true
}
func validSHA256(value string) bool {
if len(value) != 64 {
return false
}
for _, char := range value {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
return false
}
}
return true
}
func validID(value, prefix string) bool {
if !strings.HasPrefix(value, prefix) || len(value) > 36 || len(value) <= len(prefix) {
return false
}
for _, char := range value {
if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' {
return false
}
}
return true
}
func ValidErrorCode(code string) bool {
switch code {
case ErrorCodeContractMismatch, ErrorCodeBundleInvalid, ErrorCodeTargetUnavailable,
ErrorCodeTargetWriteFailed, ErrorCodeVerificationFailed, ErrorCodeRollbackArtifactMissing,
ErrorCodeRollbackFailed, ErrorCodeStoppedAfterFailure, ErrorCodeInternal:
return true
default:
return false
}
}