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 集成
197 lines
8.0 KiB
Go
197 lines
8.0 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
postgresstore "knowledge-governance/internal/platform/postgres"
|
|
"knowledge-governance/internal/platform/weknora"
|
|
)
|
|
|
|
// RoutingBindingStore persists the UI-managed knowledge base bindings
|
|
// (migration 000034). Empty stored values fall back to env defaults.
|
|
type RoutingBindingStore interface {
|
|
LoadRoutingKBBindings(context.Context) (string, string, string, error)
|
|
SaveRoutingKBBindings(context.Context, string, string, string) error
|
|
// SaveRoutingKBBindingsWithReplan also re-points 待入库 publications that
|
|
// were still waiting for the previous knowledge base (same transaction).
|
|
SaveRoutingKBBindingsWithReplan(ctx context.Context, internalID, externalID, effectiveInternal, effectiveExternal, updatedBy string, now time.Time) (postgresstore.RoutingBindingReplanResult, error)
|
|
}
|
|
|
|
// KnowledgeBaseLister enumerates candidate knowledge bases in the connected
|
|
// WeKnora so the settings page offers a picker instead of raw UUIDs.
|
|
type KnowledgeBaseLister interface {
|
|
ListKnowledgeBases(context.Context) ([]weknora.KnowledgeBase, error)
|
|
}
|
|
|
|
type routingBindingView struct {
|
|
InternalKBID string `json:"internal_kb_id"`
|
|
ExternalKBID string `json:"external_kb_id"`
|
|
InternalEnvFallback bool `json:"internal_env_fallback"`
|
|
ExternalEnvFallback bool `json:"external_env_fallback"`
|
|
InternalBoundByUI bool `json:"internal_bound_by_ui"`
|
|
ExternalBoundByUI bool `json:"external_bound_by_ui"`
|
|
InternalKBName string `json:"internal_kb_name"`
|
|
ExternalKBName string `json:"external_kb_name"`
|
|
UpdatedBy string `json:"updated_by"`
|
|
WeKnoraConnected bool `json:"weknora_connected"`
|
|
// Replanned / RetiredDuplicates describe what THIS save did to 待入库 items
|
|
// that had not been uploaded yet. A plain read reports zeros.
|
|
Replanned int `json:"replanned"`
|
|
RetiredDuplicates int `json:"retired_duplicates"`
|
|
}
|
|
|
|
type routingKnowledgeBaseOption struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
KnowledgeCount int64 `json:"knowledge_count"`
|
|
}
|
|
|
|
// effectiveRoutingTargets resolves (internal, external) WeKnora knowledge
|
|
// base ids for this request: UI bindings win, environment defaults fill the
|
|
// unbound side. Load failures behave as "unbound" (fail toward env, never
|
|
// toward a half-configured new target).
|
|
func (config Config) effectiveRoutingTargets(ctx context.Context) (string, string) {
|
|
internal, external := config.InternalRoutingKnowledgeBaseID, config.ExternalRoutingKnowledgeBaseID
|
|
if config.RoutingBindings == nil {
|
|
return internal, external
|
|
}
|
|
storedInternal, storedExternal, _, err := config.RoutingBindings.LoadRoutingKBBindings(ctx)
|
|
if err != nil {
|
|
return internal, external
|
|
}
|
|
if storedInternal != "" {
|
|
internal = storedInternal
|
|
}
|
|
if storedExternal != "" {
|
|
external = storedExternal
|
|
}
|
|
return internal, external
|
|
}
|
|
|
|
func (config Config) routingBindingNames(ctx context.Context) (map[string]string, bool) {
|
|
if config.KnowledgeBases == nil {
|
|
return nil, false
|
|
}
|
|
bases, err := config.KnowledgeBases.ListKnowledgeBases(ctx)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
names := make(map[string]string, len(bases))
|
|
for _, base := range bases {
|
|
names[base.ID] = base.Name
|
|
}
|
|
return names, true
|
|
}
|
|
|
|
// routingBindingPayload builds the settings-page projection. replan carries the
|
|
// counts of a binding change that just ran (zero on a plain read).
|
|
func (config Config) routingBindingPayload(ctx context.Context, replan postgresstore.RoutingBindingReplanResult) (routingBindingView, error) {
|
|
names, connected := config.routingBindingNames(ctx)
|
|
storedInternal, storedExternal, updatedBy, err := config.RoutingBindings.LoadRoutingKBBindings(ctx)
|
|
if err != nil {
|
|
return routingBindingView{}, err
|
|
}
|
|
internal, external := config.effectiveRoutingTargets(ctx)
|
|
return routingBindingView{
|
|
InternalKBID: internal, ExternalKBID: external,
|
|
InternalBoundByUI: storedInternal != "", ExternalBoundByUI: storedExternal != "",
|
|
InternalEnvFallback: storedInternal == "" && config.InternalRoutingKnowledgeBaseID != "",
|
|
ExternalEnvFallback: storedExternal == "" && config.ExternalRoutingKnowledgeBaseID != "",
|
|
InternalKBName: names[storedInternal], ExternalKBName: names[storedExternal],
|
|
UpdatedBy: updatedBy, WeKnoraConnected: connected,
|
|
Replanned: replan.Replanned, RetiredDuplicates: replan.RetiredDuplicates,
|
|
}, nil
|
|
}
|
|
|
|
func (config Config) handleRoutingBindings(w http.ResponseWriter, r *http.Request) {
|
|
view, err := config.routingBindingPayload(r.Context(), postgresstore.RoutingBindingReplanResult{})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load routing bindings"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": view})
|
|
}
|
|
|
|
func (config Config) handleRoutingKnowledgeBaseOptions(w http.ResponseWriter, r *http.Request) {
|
|
if config.KnowledgeBases == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "WeKnora connection is not configured"})
|
|
return
|
|
}
|
|
bases, err := config.KnowledgeBases.ListKnowledgeBases(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "could not list WeKnora knowledge bases"})
|
|
return
|
|
}
|
|
options := make([]routingKnowledgeBaseOption, 0, len(bases))
|
|
for _, base := range bases {
|
|
options = append(options, routingKnowledgeBaseOption{ID: base.ID, Name: base.Name, KnowledgeCount: base.KnowledgeCount})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": options})
|
|
}
|
|
|
|
func (config Config) handleSaveRoutingBindings(w http.ResponseWriter, r *http.Request) {
|
|
var input struct {
|
|
InternalKBID string `json:"internal_kb_id"`
|
|
ExternalKBID string `json:"external_kb_id"`
|
|
}
|
|
if err := decodeStrictJSON(r, &input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
internal := strings.TrimSpace(input.InternalKBID)
|
|
external := strings.TrimSpace(input.ExternalKBID)
|
|
for _, id := range []string{internal, external} {
|
|
if id != "" && !validCatalogID(id) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "knowledge base ID is invalid"})
|
|
return
|
|
}
|
|
}
|
|
if internal != "" && internal == external {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "对内与对外必须绑定不同的知识库"})
|
|
return
|
|
}
|
|
// When WeKnora is reachable the binding must name a real knowledge base;
|
|
// offline saves fall through to the DB so the page never hard-fails, but
|
|
// the response reports connected=false honestly.
|
|
names, connected := config.routingBindingNames(r.Context())
|
|
if connected {
|
|
if internal != "" {
|
|
if _, ok := names[internal]; !ok {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "对内绑定的知识库在该 WeKnora 实例中不存在"})
|
|
return
|
|
}
|
|
}
|
|
if external != "" {
|
|
if _, ok := names[external]; !ok {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "对外绑定的知识库在该 WeKnora 实例中不存在"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
// The saved binding re-routes FUTURE annotations; pending 待入库 items that
|
|
// have never been uploaded are re-pointed in the same transaction, otherwise
|
|
// they would keep aiming at the previous (possibly removed) knowledge base.
|
|
effectiveInternal := internal
|
|
if effectiveInternal == "" {
|
|
effectiveInternal = config.InternalRoutingKnowledgeBaseID
|
|
}
|
|
effectiveExternal := external
|
|
if effectiveExternal == "" {
|
|
effectiveExternal = config.ExternalRoutingKnowledgeBaseID
|
|
}
|
|
replan, err := config.RoutingBindings.SaveRoutingKBBindingsWithReplan(r.Context(), internal, external, effectiveInternal, effectiveExternal, actorFor(r), config.Now().UTC())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not save routing bindings"})
|
|
return
|
|
}
|
|
view, err := config.routingBindingPayload(r.Context(), replan)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load routing bindings"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": view})
|
|
}
|