Files
knowledge-governance/internal/httpapi/routing_bindings.go
T
姚章浩 021e91eae4 feat(settings): bind internal/external knowledge bases from the UI
Routing targets move from env-only to a UI-managed binding (migration
000034 routing_kb_bindings, single active row):
- GET/PUT /api/v1/routing-bindings + /options: env defaults act as the
  bootstrap fallback; a UI binding wins and takes effect immediately
  (per-request resolution, no restart), and clearing falls back to env
- PUT validates against the connected WeKnora instance (real knowledge
  base list via the scoped API key) and rejects same-KB bindings;
  saves require the admin role
- route/catalog capabilities now resolve through the binding so both
  annotation paths share one source of truth
- system settings > 服务配置 gains a 知识库绑定 panel: dropdown picker
  (or raw ID when WeKnora is unreachable) showing bound name, origin
  (UI vs env default) and last author
- contract tests: OpenAPI, handler matrix (fallback, validation,
  same-KB rejection, capability resolution), frontend fetch/save layer
2026-09-30 10:54:07 +08:00

161 lines
6.1 KiB
Go

package httpapi
import (
"context"
"net/http"
"strings"
"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
}
// 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"`
}
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
}
func (config Config) handleRoutingBindings(w http.ResponseWriter, r *http.Request) {
names, connected := config.routingBindingNames(r.Context())
storedInternal, storedExternal, updatedBy, err := config.RoutingBindings.LoadRoutingKBBindings(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load routing bindings"})
return
}
internal, external := config.effectiveRoutingTargets(r.Context())
view := 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,
}
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
}
}
}
if err := config.RoutingBindings.SaveRoutingKBBindings(r.Context(), internal, external, actorFor(r)); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not save routing bindings"})
return
}
config.handleRoutingBindings(w, r)
}