Files
knowledge-governance/internal/httpapi/production_bindings.go
姚章浩 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

169 lines
6.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"context"
"net/http"
"strings"
"time"
"knowledge-governance/internal/platform/weknora"
)
// ProductionBindingStore persists the UI-managed 源库 → 目标库 mapping
// (migration 000044). An empty side means "unbound": the release then keeps the
// historical same-id semantics and fails closed at the contract check when no
// such base exists on the target.
type ProductionBindingStore interface {
LoadProductionKBBindings(context.Context) (string, string, string, error)
SaveProductionKBBindings(ctx context.Context, internalID, externalID, updatedBy string, now time.Time) error
}
// ProductionKnowledgeBaseLister enumerates the knowledge bases of the production
// WeKnora, resolved from the operator-managed service configuration.
type ProductionKnowledgeBaseLister interface {
ListProductionKnowledgeBases(ctx context.Context, baseURL string) ([]weknora.KnowledgeBase, error)
}
type productionBindingView struct {
InternalKBID string `json:"internal_kb_id"`
ExternalKBID string `json:"external_kb_id"`
InternalKBName string `json:"internal_kb_name"`
ExternalKBName string `json:"external_kb_name"`
UpdatedBy string `json:"updated_by"`
ProductionBaseURL string `json:"production_base_url"`
ProductionConnected bool `json:"production_connected"`
ProductionMessage string `json:"production_message"`
}
// productionTargetBaseURL is the production endpoint the picker reads from: the
// operator-managed 「服务配置 · 生产地址」 first, the deployment default second.
func (config Config) productionTargetBaseURL(ctx context.Context) string {
if settings, err := config.Store.GetActiveUISettings(ctx); err == nil {
if resolved := strings.TrimSpace(settings.ProductionWeKnoraBaseURL); resolved != "" {
return resolved
}
}
return strings.TrimSpace(config.ProductionWeKnoraBaseURL)
}
// productionKnowledgeBases lists the production-side bases. The second return is
// the honest reason a listing was impossible, so the page can say why instead of
// pretending there are none.
func (config Config) productionKnowledgeBases(ctx context.Context) ([]weknora.KnowledgeBase, string) {
if config.ProductionKnowledgeBases == nil {
return nil, "生产知识库列表未接入(PRODUCTION_WEKNORA_BASE_URL / PRODUCTION_WEKNORA_API_KEY_FILE)"
}
baseURL := config.productionTargetBaseURL(ctx)
if baseURL == "" {
return nil, "未配置生产地址(系统设置 → 服务配置)"
}
bases, err := config.ProductionKnowledgeBases.ListProductionKnowledgeBases(ctx, baseURL)
if err != nil {
return nil, "生产 WeKnora 不可用:" + err.Error()
}
return bases, ""
}
func (config Config) productionBindingPayload(ctx context.Context) (productionBindingView, error) {
storedInternal, storedExternal, updatedBy, err := config.ProductionBindings.LoadProductionKBBindings(ctx)
if err != nil {
return productionBindingView{}, err
}
bases, message := config.productionKnowledgeBases(ctx)
names := make(map[string]string, len(bases))
for _, base := range bases {
names[base.ID] = base.Name
}
return productionBindingView{
InternalKBID: storedInternal, ExternalKBID: storedExternal,
InternalKBName: names[storedInternal], ExternalKBName: names[storedExternal],
UpdatedBy: updatedBy, ProductionBaseURL: config.productionTargetBaseURL(ctx),
ProductionConnected: message == "", ProductionMessage: message,
}, nil
}
func (config Config) handleProductionBindings(w http.ResponseWriter, r *http.Request) {
if config.ProductionBindings == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "production bindings are not configured"})
return
}
view, err := config.productionBindingPayload(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load production bindings"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": view})
}
func (config Config) handleProductionKnowledgeBaseOptions(w http.ResponseWriter, r *http.Request) {
bases, message := config.productionKnowledgeBases(r.Context())
if message != "" {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": message})
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})
}
// handleSaveProductionBindings stores the 源库 → 目标库 mapping. When the
// production endpoint answers, the chosen base must exist there; when it cannot
// be asked, the save still succeeds but the response says so honestly (a jittery
// production endpoint must not block the operator, a wrong ID must).
func (config Config) handleSaveProductionBindings(w http.ResponseWriter, r *http.Request) {
if config.ProductionBindings == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "production bindings are not configured"})
return
}
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
}
if bases, message := config.productionKnowledgeBases(r.Context()); message == "" {
names := make(map[string]string, len(bases))
for _, base := range bases {
names[base.ID] = base.Name
}
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.ProductionBindings.SaveProductionKBBindings(r.Context(), internal, external, actorFor(r), config.Now().UTC()); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not save production bindings"})
return
}
view, err := config.productionBindingPayload(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load production bindings"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": view})
}