021e91eae4
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
161 lines
6.1 KiB
Go
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)
|
|
}
|