6440e3f679
- 迁移 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 集成
157 lines
6.2 KiB
Go
157 lines
6.2 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// RoutingBinding is the UI-managed knowledge base binding row. Empty ids mean
|
|
// "not bound in the UI"; callers fall back to their environment defaults.
|
|
type RoutingBinding struct {
|
|
InternalKBID string
|
|
ExternalKBID string
|
|
UpdatedBy string
|
|
}
|
|
|
|
// LoadRoutingKBBindings reads the single active binding row. A missing row
|
|
// reads back as unbound rather than an error.
|
|
func (s *Store) LoadRoutingKBBindings(ctx context.Context) (string, string, string, error) {
|
|
var binding RoutingBinding
|
|
err := s.db.QueryRow(ctx, `SELECT internal_kb_id,external_kb_id,updated_by FROM routing_kb_bindings WHERE id='active'`).
|
|
Scan(&binding.InternalKBID, &binding.ExternalKBID, &binding.UpdatedBy)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", "", "", nil
|
|
}
|
|
if err != nil {
|
|
return "", "", "", errors.New("load routing kb bindings")
|
|
}
|
|
return binding.InternalKBID, binding.ExternalKBID, binding.UpdatedBy, nil
|
|
}
|
|
|
|
// RoutingBindingReplanResult reports what a binding change did to publications
|
|
// that were still waiting in 待入库.
|
|
type RoutingBindingReplanResult struct {
|
|
// Replanned counts untouched pending publications that were re-pointed at
|
|
// the newly bound knowledge base for their scope.
|
|
Replanned int
|
|
// RetiredDuplicates counts pending publications that already had a twin for
|
|
// the new knowledge base, so the stale row was superseded instead of moved.
|
|
RetiredDuplicates int
|
|
}
|
|
|
|
// SaveRoutingKBBindings upserts the binding row. Each side is either empty
|
|
// (unbind, fall back to env) or a knowledge base id validated by the caller.
|
|
func (s *Store) SaveRoutingKBBindings(ctx context.Context, internalID, externalID, updatedBy string) error {
|
|
_, err := s.db.Exec(ctx, `
|
|
INSERT INTO routing_kb_bindings(id,internal_kb_id,external_kb_id,updated_by,updated_at)
|
|
VALUES('active',$1,$2,$3,NOW())
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
internal_kb_id=EXCLUDED.internal_kb_id,
|
|
external_kb_id=EXCLUDED.external_kb_id,
|
|
updated_by=EXCLUDED.updated_by,
|
|
updated_at=NOW()`, internalID, externalID, updatedBy)
|
|
if err != nil {
|
|
return errors.New("save routing kb bindings")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// replanRetireDuplicatesSQL retires a pending publication whose twin for the
|
|
// new knowledge base already exists — the unique key is
|
|
// (source_item_version_id, test_kb_id), so the row cannot simply be moved.
|
|
const replanRetireDuplicatesSQL = `
|
|
UPDATE test_publications stale
|
|
SET superseded_at = $3, updated_at = $3
|
|
WHERE stale.superseded_at IS NULL
|
|
AND stale.test_kb_id <> $1
|
|
AND stale.processing_state = 'planned'
|
|
AND stale.ingest_state = 'pending'
|
|
AND stale.knowledge_id = ''
|
|
AND stale.retrieval_state = 'unpublished'
|
|
AND stale.target_scope = $2
|
|
AND NOT EXISTS (SELECT 1 FROM jobs job WHERE job.type = 'test.upload' AND job.payload->>'publication_id' = stale.id)
|
|
AND EXISTS (
|
|
SELECT 1 FROM test_publications twin
|
|
WHERE twin.source_item_version_id = stale.source_item_version_id
|
|
AND twin.test_kb_id = $1
|
|
AND twin.superseded_at IS NULL)
|
|
`
|
|
|
|
// replanMovePendingSQL re-points the untouched remainder at the new knowledge
|
|
// base. Only rows that have never been uploaded anywhere are eligible: anything
|
|
// already in flight keeps its frozen target and stays visible in 待入库.
|
|
const replanMovePendingSQL = `
|
|
UPDATE test_publications pending
|
|
SET test_kb_id = $1, updated_at = $3
|
|
WHERE pending.superseded_at IS NULL
|
|
AND pending.test_kb_id <> $1
|
|
AND pending.processing_state = 'planned'
|
|
AND pending.ingest_state = 'pending'
|
|
AND pending.knowledge_id = ''
|
|
AND pending.retrieval_state = 'unpublished'
|
|
AND pending.target_scope = $2
|
|
AND NOT EXISTS (SELECT 1 FROM jobs job WHERE job.type = 'test.upload' AND job.payload->>'publication_id' = pending.id)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM test_publications twin
|
|
WHERE twin.source_item_version_id = pending.source_item_version_id
|
|
AND twin.test_kb_id = $1
|
|
AND twin.superseded_at IS NULL)
|
|
`
|
|
|
|
// SaveRoutingKBBindingsWithReplan saves the binding and, in the SAME
|
|
// transaction, re-points 待入库 items that were still waiting for the old
|
|
// knowledge base. Without this, changing the binding left every pending
|
|
// publication aimed at the previous (possibly deleted) KB, and re-marking the
|
|
// file created a SECOND publication instead of moving the first.
|
|
//
|
|
// effectiveInternal/effectiveExternal are the ids the platform actually routes
|
|
// to (the saved binding, or the deployment default when a side is unbound);
|
|
// an empty effective id means that scope cannot be routed and is skipped.
|
|
func (s *Store) SaveRoutingKBBindingsWithReplan(ctx context.Context, internalID, externalID, effectiveInternal, effectiveExternal, updatedBy string, now time.Time) (RoutingBindingReplanResult, error) {
|
|
var result RoutingBindingReplanResult
|
|
if now.IsZero() {
|
|
return result, errors.New("binding replan requires a timestamp")
|
|
}
|
|
tx, err := s.db.Begin(ctx)
|
|
if err != nil {
|
|
return result, fmt.Errorf("begin routing binding save: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO routing_kb_bindings(id,internal_kb_id,external_kb_id,updated_by,updated_at)
|
|
VALUES('active',$1,$2,$3,$4)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
internal_kb_id=EXCLUDED.internal_kb_id,
|
|
external_kb_id=EXCLUDED.external_kb_id,
|
|
updated_by=EXCLUDED.updated_by,
|
|
updated_at=EXCLUDED.updated_at`, internalID, externalID, updatedBy, now); err != nil {
|
|
return result, errors.New("save routing kb bindings")
|
|
}
|
|
for _, scope := range []struct{ name, kbID string }{
|
|
{"internal", effectiveInternal},
|
|
{"external", effectiveExternal},
|
|
} {
|
|
if scope.kbID == "" {
|
|
continue
|
|
}
|
|
retired, err := tx.Exec(ctx, replanRetireDuplicatesSQL, scope.kbID, scope.name, now)
|
|
if err != nil {
|
|
return result, fmt.Errorf("retire duplicate pending publications: %w", err)
|
|
}
|
|
moved, err := tx.Exec(ctx, replanMovePendingSQL, scope.kbID, scope.name, now)
|
|
if err != nil {
|
|
return result, fmt.Errorf("replan pending publications: %w", err)
|
|
}
|
|
result.RetiredDuplicates += int(retired.RowsAffected())
|
|
result.Replanned += int(moved.RowsAffected())
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return result, fmt.Errorf("commit routing binding save: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|