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 集成
169 lines
6.8 KiB
Go
169 lines
6.8 KiB
Go
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})
|
||
}
|