From 6440e3f679120a021f88587f5a5f3d88fd7ca010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Thu, 8 Oct 2026 15:41:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20=E5=8F=91=E5=B8=83=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E7=9F=A5=E8=AF=86=E5=BA=93=E5=8F=AF=E9=80=89=EF=BC=88?= =?UTF-8?q?=E6=BA=90=E5=BA=93=20=E2=86=92=20=E7=9B=AE=E6=A0=87=E5=BA=93?= =?UTF-8?q?=E6=98=A0=E5=B0=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 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 集成 --- api/openapi.yaml | 68 +++++++ cmd/api/main.go | 32 ++-- compose.release-gates.yaml | 18 ++ compose.release-sim.yaml | 20 +- config/release.sim.json | 2 +- docs/P8_PRODUCTION_KB_MAPPING_ACCEPTANCE.md | 62 ++++++ internal/httpapi/auth.go | 5 + internal/httpapi/auth_test.go | 11 +- internal/httpapi/production_bindings.go | 168 ++++++++++++++++ internal/httpapi/production_bindings_test.go | 180 ++++++++++++++++++ internal/httpapi/routing_bindings.go | 54 +++++- internal/httpapi/routing_bindings_test.go | 44 +++++ internal/httpapi/server.go | 34 +++- ...artifact_fetch_requeue_integration_test.go | 2 +- .../000044_production_kb_bindings.down.sql | 2 + .../000044_production_kb_bindings.up.sql | 22 +++ internal/platform/postgres/migrations_test.go | 4 +- .../platform/postgres/production_bindings.go | 77 ++++++++ .../production_bindings_integration_test.go | 72 +++++++ .../release_execution_worker_store.go | 48 ++++- .../release_execution_worker_store_test.go | 19 +- .../platform/postgres/routing_bindings.go | 108 +++++++++++ ...outing_bindings_replan_integration_test.go | 115 +++++++++++ .../releaseadapter/v071/contract_inspector.go | 11 +- .../releaseadapter/v071/document_target.go | 50 +++-- internal/releaseadapter/v071/remap.go | 139 ++++++++++++++ internal/releaseadapter/v071/remap_test.go | 175 +++++++++++++++++ internal/releaseadapter/v071/verifier.go | 4 +- internal/releasebundle/manifest.go | 30 ++- internal/releasebundle/manifest_test.go | 52 ++++- internal/serviceverify/production_bases.go | 29 +++ internal/workflows/release/handler.go | 30 ++- .../workflows/release/sidecar_publisher.go | 13 +- scripts/deploy-api-web.sh | 4 +- web/src/ProductionBindingsSection.tsx | 87 +++++++++ web/src/RoutingBindingsSection.tsx | 5 +- web/src/SystemSettings.tsx | 5 +- web/src/productionBindings.test.ts | 44 +++++ web/src/productionBindings.ts | 65 +++++++ web/src/routingBindings.ts | 5 + 40 files changed, 1823 insertions(+), 92 deletions(-) create mode 100644 compose.release-gates.yaml create mode 100644 docs/P8_PRODUCTION_KB_MAPPING_ACCEPTANCE.md create mode 100644 internal/httpapi/production_bindings.go create mode 100644 internal/httpapi/production_bindings_test.go create mode 100644 internal/platform/postgres/migrations/000044_production_kb_bindings.down.sql create mode 100644 internal/platform/postgres/migrations/000044_production_kb_bindings.up.sql create mode 100644 internal/platform/postgres/production_bindings.go create mode 100644 internal/platform/postgres/production_bindings_integration_test.go create mode 100644 internal/platform/postgres/routing_bindings_replan_integration_test.go create mode 100644 internal/releaseadapter/v071/remap.go create mode 100644 internal/releaseadapter/v071/remap_test.go create mode 100644 internal/serviceverify/production_bases.go create mode 100644 web/src/ProductionBindingsSection.tsx create mode 100644 web/src/productionBindings.test.ts create mode 100644 web/src/productionBindings.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index e18c1f0..b085012 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1289,6 +1289,74 @@ } } }, + "/api/v1/production-kb-bindings": { + "get": { + "tags": [ + "Settings" + ], + "operationId": "getProductionBindings", + "summary": "读取对内/对外生产知识库绑定(源库 → 目标库)", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "operationId": "saveProductionBindings", + "summary": "绑定发布目标的 对内/对外生产知识库(管理员)", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/production-kb-bindings/options": { + "get": { + "tags": [ + "Settings" + ], + "operationId": "listProductionBindingOptions", + "summary": "列出可绑定的生产 WeKnora 知识库", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/api/v1/routing-bindings/options": { "get": { "tags": [ diff --git a/cmd/api/main.go b/cmd/api/main.go index 8008805..ec2e477 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -101,21 +101,25 @@ func run(ctx context.Context, getenv func(string) string) error { if err != nil { return err } + serviceTargetVerifier := serviceverify.New(serviceverify.Config{ + TestAPIKeyFile: config.TestWeKnoraAPIKeyFile, + ProductionAPIKeyFile: config.ProductionWeKnoraAPIKeyFile, + TestBaseURLFallback: config.TestWeKnoraBaseURL, + ProductionBaseURLFallback: config.ProductionWeKnoraBaseURL, + TestAllowDockerHTTP: config.TestWeKnoraAllowDockerHTTP, + ProductionAllowDockerHTTP: config.ProductionWeKnoraAllowDockerHTTP, + }) handler, err := httpapi.NewHandler(httpapi.Config{ - Store: postgresstore.NewStore(pool), - Monitor: monitorProvider, - InternalRoutingKnowledgeBaseID: config.InternalRoutingKnowledgeBaseID, - ExternalRoutingKnowledgeBaseID: config.ExternalRoutingKnowledgeBaseID, - RoutingBindings: postgresstore.NewStore(pool), - KnowledgeBases: knowledgeBaseLister, - ServiceTargetVerifier: serviceverify.New(serviceverify.Config{ - TestAPIKeyFile: config.TestWeKnoraAPIKeyFile, - ProductionAPIKeyFile: config.ProductionWeKnoraAPIKeyFile, - TestBaseURLFallback: config.TestWeKnoraBaseURL, - ProductionBaseURLFallback: config.ProductionWeKnoraBaseURL, - TestAllowDockerHTTP: config.TestWeKnoraAllowDockerHTTP, - ProductionAllowDockerHTTP: config.ProductionWeKnoraAllowDockerHTTP, - }), + Store: postgresstore.NewStore(pool), + Monitor: monitorProvider, + InternalRoutingKnowledgeBaseID: config.InternalRoutingKnowledgeBaseID, + ExternalRoutingKnowledgeBaseID: config.ExternalRoutingKnowledgeBaseID, + RoutingBindings: postgresstore.NewStore(pool), + KnowledgeBases: knowledgeBaseLister, + ProductionBindings: postgresstore.NewStore(pool), + ProductionKnowledgeBases: serviceTargetVerifier, + ProductionWeKnoraBaseURL: config.ProductionWeKnoraBaseURL, + ServiceTargetVerifier: serviceTargetVerifier, ProductionReleaseRequestsEnabled: config.ProductionReleaseRequestsEnabled, ProductionBackupRestoreVerified: config.ProductionBackupRestoreVerified, ProductionReleaseAuthorizerRef: config.ProductionReleaseAuthorizerRef, diff --git a/compose.release-gates.yaml b/compose.release-gates.yaml new file mode 100644 index 0000000..39cc8a3 --- /dev/null +++ b/compose.release-gates.yaml @@ -0,0 +1,18 @@ +# 生产发布的两道门禁(请求门禁 + 执行门禁)。叠加这一份 = 真正「翻闸」。 +# 用法:docker compose -f compose.yaml -f compose.release-sim.yaml -f compose.release-gates.yaml up -d api release-worker +# 翻闸后: +# - api 开放「授权发布 / 授权回滚」接口(授权人 = PRODUCTION_RELEASE_AUTHORIZER_REF,Bearer 0600 文件) +# - release-worker 加载发布配置并连接受限的目标数据面 +# 关闭:去掉本文件重新 up 即可(门禁默认关闭)。 +services: + api: + environment: + PRODUCTION_RELEASE_REQUESTS_ENABLED: "true" + PRODUCTION_BACKUP_RESTORE_VERIFIED: "true" + PRODUCTION_RELEASE_AUTHORIZER_REF: yaozhanghao + PRODUCTION_RELEASE_AUTH_TOKEN_FILE: /run/release-secrets/production_release_auth_token + + release-worker: + environment: + RELEASE_WORKER_ENABLED: "true" + RELEASE_DEBUG_ITEM_ERRORS: "true" \ No newline at end of file diff --git a/compose.release-sim.yaml b/compose.release-sim.yaml index 4431ab6..2c27e6e 100644 --- a/compose.release-sim.yaml +++ b/compose.release-sim.yaml @@ -1,21 +1,21 @@ -# 本地模拟生产发布专用 override(target=测试服务器 weknora-test-v071) -# 用法:docker compose -f compose.yaml -f compose.release-sim.yaml up -d -# 真实生产严禁使用本文件(真实生产 = 未来独立授权 + 独立配置) +# 发布链路的「数据面接线」override(不含任何门禁开关) +# 用法:docker compose -f compose.yaml -f compose.release-sim.yaml up -d api release-worker +# 叠加顺序:compose.yaml → 本文件(接线)→ compose.release-gates.yaml(门禁,须人工授权) +# 作用: +# 1) api 拿到生产 WeKnora 凭据引用 →「生产知识库绑定」能读真实生产库列表 +# 2) release-worker 拿到发布配置、目标端点别名、归档卷(执行门禁关闭时不加载配置) services: api: environment: - PRODUCTION_RELEASE_REQUESTS_ENABLED: "true" - PRODUCTION_BACKUP_RESTORE_VERIFIED: "true" - PRODUCTION_RELEASE_AUTHORIZER_REF: yaozhanghao - PRODUCTION_RELEASE_AUTH_TOKEN_FILE: /run/release-secrets/production_release_auth_token + # 凭据只引用 0600 文件(值不入库、不回显);appconfig 要求两者同时给出 + PRODUCTION_WEKNORA_BASE_URL: https://weknora-test.dobot.cn + PRODUCTION_WEKNORA_API_KEY_FILE: /run/release-secrets/target_weknora_api_key volumes: - ./secrets/release-sim:/run/release-secrets:ro release-worker: environment: - RELEASE_WORKER_ENABLED: "true" RELEASE_CONFIG_FILE: /run/release-sim/release.sim.json - RELEASE_DEBUG_ITEM_ERRORS: "true" PRODUCTION_RELEASE_AUTHORIZER_REF: yaozhanghao extra_hosts: - "host.docker.internal:host-gateway" @@ -33,4 +33,4 @@ services: volumes: release-bundles: - name: knowledge-governance-release-bundles + name: knowledge-governance-release-bundles \ No newline at end of file diff --git a/config/release.sim.json b/config/release.sim.json index a055609..a209d5e 100644 --- a/config/release.sim.json +++ b/config/release.sim.json @@ -3,7 +3,7 @@ "routes": [ { "tenant_id": 10000, - "knowledge_base_id": "c12ff4d5-dd87-423b-ab0f-af745b9d54a7", + "knowledge_base_id": "0c84a098-d3ed-4b82-a165-e6de9691d6a8", "embedding_model": "text-embedding-v4", "source": { "database_url_file": "/run/release-secrets/source_database_url", diff --git a/docs/P8_PRODUCTION_KB_MAPPING_ACCEPTANCE.md b/docs/P8_PRODUCTION_KB_MAPPING_ACCEPTANCE.md new file mode 100644 index 0000000..8567cda --- /dev/null +++ b/docs/P8_PRODUCTION_KB_MAPPING_ACCEPTANCE.md @@ -0,0 +1,62 @@ +# P8 目标知识库可选(源库 → 目标库映射)开发与验收 + +## 缺口 + +发布链路把「测试库」与「生产库」绑成同一个 KB ID,全链路 fail-closed: + +- `contract_inspector` 用 `route.knowledge_base_id` 读目标契约,且要求它等于发布项的测试库 ID; +- `manifest.Validate` / `sidecar_publisher` / `verifier` 断言 `KnowledgeBaseID == item.TestKnowledgeBaseID`; +- `postgres_import.prepareSnapshot` 逐行断言 `knowledge_base_id == identity.KnowledgeBaseID`; +- `search_import.searchHitMatches` 断言文档 `knowledge_base_id == identity.KnowledgeBaseID`。 + +因此只能发布到与测试库同 ID 的「镜像库」,无法选择目标端已有真实库(如「对内知识库-wiki」)。 + +## 目标 + +1. 系统设置新增「生产知识库绑定(对内 / 对外)」,从**目标(生产)WeKnora 实际列表**里选目标库,保存时真校验。 +2. 发布执行时按绑定把 KB 身份从源库**改写**到目标库,三通道(PostgreSQL / Elasticsearch / 对象)与官方 API 验收一致收敛。 +3. 保持 fail-closed 语义:映射关系是**显式声明并被冻结**的,未绑定时退回同 ID 语义(目标库不存在则在契约检查阶段即失败)。 + +## 契约变更(唯一一处放宽) + +原来比较 `manifest.Contract.KnowledgeBaseID == target.KnowledgeBaseID`; +现在比较**声明的映射对**(`releasebundle.KnowledgeBaseMapping`): + +``` +manifest.Contract.KnowledgeBaseID == mapping.SourceKnowledgeBaseID (源:测试库) +target.KnowledgeBaseID == mapping.TargetKnowledgeBaseID (目标:生产库) +其余字段(版本 / migration / tenant / 检索引擎 / 模型 / 维度 / ES mapping SHA)继续必须相等 +``` + +映射来源:`release_item_executions.target_knowledge_base_id`(执行开始时按 `production_kb_bindings` 解析并落库冻结),审计可见、重试复用。已在生产发布过的条目保留原目标(重试/回滚回到同一处),未发布过的按当前绑定。 + +## 落点 + +| 层 | 改动 | +|---|---| +| 迁移 000044 | `production_kb_bindings`(单行 active,对内/对外目标库)+ `release_item_executions.target_knowledge_base_id` | +| 系统设置 | 新增「生产知识库绑定(对内 / 对外)」,从**生产 WeKnora 实际列表**选库;保存时真校验(不存在→400 拒收;生产端不可达→只提示并如实回报 `production_connected=false`) | +| API | `GET/PUT /api/v1/production-kb-bindings`、`GET /api/v1/production-kb-bindings/options` | +| 执行计划 | `StartExecution` 解析映射并冻结进 plan 与 `release_item_executions`;空目标直接拒收 | +| 快照改写 | `DocumentBundle.RemapKnowledgeBase`:5 张带 KB 列的表 + ES 文档 `knowledge_base_id` 改写为目标库,重算表/文档摘要并重新封装(`Seal`);其余字段按原字节透传(嵌入向量不动) | +| 目标身份 | `targetIdentities` / 契约检查器 / 验证器 / `PlanItem.TargetBase()` 全部走映射后的目标库 | +| 对象通道 | 不改:对象 key = `weknora///`,无 KB 段(实测穷举) | +| Compose | `compose.release-sim.yaml`(数据面接线,无门禁)+ `compose.release-gates.yaml`(两道门禁,须人工授权叠加) | + +## 改写面(实测穷举) + +源库行里 KB ID 只出现在 `knowledge_base_id` 这一个字段(每行 1 处): + +| 表 / 位置 | 是否有 KB 列 | 处理 | +|---|---|---| +| `knowledges` / `chunks` / `knowledge_tags` / `wiki_pages` / `wiki_folders` | 有(各 1 处) | 改写为目标库 ID,并重算表摘要 | +| `resources` / `resource_bindings` / `knowledge_tag_relations` | 无 | 不改(按 knowledge/handle 关联) | +| Elasticsearch `_source.knowledge_base_id` | 有 | 改写为目标库 ID,并重算文档摘要 | +| 对象存储 locator `storage:///oss:///weknora///` | 无 KB 段 | 不改(仅按目标 backend/bucket 重写) | + +## 验收点 + +1. `go test ./...`、`go vet`、前端 `tsc` + `vitest` 全绿;迁移 44 up/down 往返可用。 +2. 单元/集成:映射生效后 `knowledges/chunks/wiki_pages` 的 KB 列、ES 文档字段均为目标库;摘要随改写重算;未绑定退回同 ID。 +3. 界面:系统设置可选目标库(列真实库名),保存时对目标 WeKnora 真校验(无效 ID 拒绝);发布管理显示本次目标库。 +4. 真实跨环境:1 份文档发布到目标库 → 三通道读回一致 → 官方 API 检索命中 → 撤权后零命中 → 回滚恢复。 \ No newline at end of file diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index 257e631..ee46ab0 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -129,6 +129,11 @@ func classifyRole(method, path string) string { if path == "/api/v1/routing-bindings" && method != http.MethodGet { return roleAdmin } + // Choosing the production base a release writes into is a production + // authority change, exactly like the annotation binding above. + if path == "/api/v1/production-kb-bindings" && method != http.MethodGet { + return roleAdmin + } // Everything else that mutates (review, batch, seal, confirm, start, retry, // route visibility, disable) is engineer work; viewers cannot POST at all. return roleEngineer diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go index af1f66a..56b42dc 100644 --- a/internal/httpapi/auth_test.go +++ b/internal/httpapi/auth_test.go @@ -410,10 +410,13 @@ func TestClassifyRoleMatrix(t *testing.T) { http.MethodPost + " /api/v1/release-executions/rx-1/retry": roleAdmin, http.MethodPost + " /api/v1/users": roleAdmin, http.MethodPost + " /api/v1/source-connections/c-1/settings/revisions": roleAdmin, - http.MethodPost + " /api/v1/review-candidates/tp-1/decision": roleEngineer, - http.MethodPost + " /api/v1/release-batches/rb-1/seal": roleEngineer, - http.MethodPost + " /api/v1/test-publications/start": roleEngineer, - http.MethodGet + " /api/v1/release-plan": roleViewer, + // Choosing a release's production base is a production authority change. + http.MethodPut + " /api/v1/production-kb-bindings": roleAdmin, + http.MethodGet + " /api/v1/production-kb-bindings": roleViewer, + http.MethodPost + " /api/v1/review-candidates/tp-1/decision": roleEngineer, + http.MethodPost + " /api/v1/release-batches/rb-1/seal": roleEngineer, + http.MethodPost + " /api/v1/test-publications/start": roleEngineer, + http.MethodGet + " /api/v1/release-plan": roleViewer, } for key, want := range cases { fields := strings.Fields(key) diff --git a/internal/httpapi/production_bindings.go b/internal/httpapi/production_bindings.go new file mode 100644 index 0000000..7e72939 --- /dev/null +++ b/internal/httpapi/production_bindings.go @@ -0,0 +1,168 @@ +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}) +} diff --git a/internal/httpapi/production_bindings_test.go b/internal/httpapi/production_bindings_test.go new file mode 100644 index 0000000..db5a77f --- /dev/null +++ b/internal/httpapi/production_bindings_test.go @@ -0,0 +1,180 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "knowledge-governance/internal/governance" + "knowledge-governance/internal/platform/weknora" +) + +type productionBindingStoreFake struct { + internal, external, saver string + savedInternal, savedExternal string + saveCallCount int +} + +func (fake *productionBindingStoreFake) LoadProductionKBBindings(context.Context) (string, string, string, error) { + return fake.internal, fake.external, fake.saver, nil +} + +func (fake *productionBindingStoreFake) SaveProductionKBBindings(_ context.Context, internal, external, updatedBy string, _ time.Time) error { + fake.savedInternal, fake.savedExternal = internal, external + fake.saver = updatedBy + fake.saveCallCount++ + fake.internal, fake.external = internal, external + return nil +} + +type productionListerFake struct { + bases []weknora.KnowledgeBase + err error + baseURL string +} + +func (fake *productionListerFake) ListProductionKnowledgeBases(_ context.Context, baseURL string) ([]weknora.KnowledgeBase, error) { + fake.baseURL = baseURL + return fake.bases, fake.err +} + +func productionBindingsHandler(t *testing.T, store ProductionBindingStore, lister ProductionKnowledgeBaseLister, settingsURL string) http.Handler { + t.Helper() + jobStore := &fakeJobStore{} + jobStore.activeUISettingsResult = governance.UISettings{ProductionWeKnoraBaseURL: settingsURL} + handler, err := NewHandler(Config{ + Store: jobStore, + ProductionBindings: store, ProductionKnowledgeBases: lister, + Now: func() time.Time { return time.Date(2026, time.October, 8, 8, 0, 0, 0, time.UTC) }, + }) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + return handler +} + +func productionBases() []weknora.KnowledgeBase { + return []weknora.KnowledgeBase{ + {ID: "kb-prod-internal", Name: "对内知识库-wiki", KnowledgeCount: 2069}, + {ID: "kb-prod-external", Name: "对外知识库-wiki", KnowledgeCount: 526}, + } +} + +// The picker reads the production endpoint from 「服务配置 · 生产地址」 and reports +// the bound bases by NAME — ids never reach the page. +func TestProductionBindingsReadUsesServiceConfiguredEndpoint(t *testing.T) { + store := &productionBindingStoreFake{internal: "kb-prod-internal"} + lister := &productionListerFake{bases: productionBases()} + handler := productionBindingsHandler(t, store, lister, "https://weknora-test.dobot.cn/") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/production-kb-bindings", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String()) + } + if lister.baseURL != "https://weknora-test.dobot.cn/" { + t.Fatalf("lister baseURL = %q, want the operator-configured production address", lister.baseURL) + } + body := recorder.Body.String() + if !strings.Contains(body, "对内知识库-wiki") || !strings.Contains(body, `"production_connected":true`) { + t.Fatalf("body = %s", body) + } +} + +// A chosen base that does not exist on the production endpoint must be refused +// before anything is written. +func TestProductionBindingsSaveRejectsUnknownBase(t *testing.T) { + store := &productionBindingStoreFake{} + handler := productionBindingsHandler(t, store, &productionListerFake{bases: productionBases()}, "https://weknora-test.dobot.cn/") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/production-kb-bindings", + strings.NewReader(`{"internal_kb_id":"kb-ghost","external_kb_id":""}`))) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String()) + } + if store.saveCallCount != 0 { + t.Fatal("a rejected binding must not be stored") + } +} + +func TestProductionBindingsSaveStoresValidatedPair(t *testing.T) { + store := &productionBindingStoreFake{} + handler := productionBindingsHandler(t, store, &productionListerFake{bases: productionBases()}, "https://weknora-test.dobot.cn/") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/production-kb-bindings", + strings.NewReader(`{"internal_kb_id":"kb-prod-internal","external_kb_id":"kb-prod-external"}`))) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String()) + } + if store.savedInternal != "kb-prod-internal" || store.savedExternal != "kb-prod-external" { + t.Fatalf("saved = %q/%q", store.savedInternal, store.savedExternal) + } + var envelope struct { + Data productionBindingView `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Data.InternalKBName != "对内知识库-wiki" || !envelope.Data.ProductionConnected { + t.Fatalf("view = %#v", envelope.Data) + } +} + +// A jittery production endpoint must not block the operator, but the response +// must say the binding could not be checked. +func TestProductionBindingsSaveWarnsWhenEndpointUnavailable(t *testing.T) { + store := &productionBindingStoreFake{} + handler := productionBindingsHandler(t, store, &productionListerFake{err: errors.New("dial tcp: timeout")}, "https://weknora-test.dobot.cn/") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/production-kb-bindings", + strings.NewReader(`{"internal_kb_id":"kb-prod-internal","external_kb_id":""}`))) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String()) + } + if store.saveCallCount != 1 { + t.Fatal("an unverifiable (not invalid) binding must still be stored") + } + var envelope struct { + Data productionBindingView `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Data.ProductionConnected || !strings.Contains(envelope.Data.ProductionMessage, "不可用") { + t.Fatalf("view = %#v", envelope.Data) + } +} + +// 对内 and 对外 must not point at the same production base. +func TestProductionBindingsSaveRejectsIdenticalBases(t *testing.T) { + store := &productionBindingStoreFake{} + handler := productionBindingsHandler(t, store, &productionListerFake{bases: productionBases()}, "https://weknora-test.dobot.cn/") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/production-kb-bindings", + strings.NewReader(`{"internal_kb_id":"kb-prod-internal","external_kb_id":"kb-prod-internal"}`))) + if recorder.Code != http.StatusBadRequest || store.saveCallCount != 0 { + t.Fatalf("status = %d saves = %d", recorder.Code, store.saveCallCount) + } +} + +func TestProductionBindingOptionsReportUnavailableReason(t *testing.T) { + handler := productionBindingsHandler(t, &productionBindingStoreFake{}, &productionListerFake{err: errors.New("no key")}, "https://weknora-test.dobot.cn/") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/production-kb-bindings/options", nil)) + if recorder.Code != http.StatusBadGateway { + t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String()) + } + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/production-kb-bindings/options", nil)) + // and with a working endpoint the options carry names, not just ids + handler = productionBindingsHandler(t, &productionBindingStoreFake{}, &productionListerFake{bases: productionBases()}, "https://weknora-test.dobot.cn/") + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/production-kb-bindings/options", nil)) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "对外知识库-wiki") { + t.Fatalf("status = %d body %s", recorder.Code, recorder.Body.String()) + } +} diff --git a/internal/httpapi/routing_bindings.go b/internal/httpapi/routing_bindings.go index 6e701db..fffe52f 100644 --- a/internal/httpapi/routing_bindings.go +++ b/internal/httpapi/routing_bindings.go @@ -4,7 +4,9 @@ import ( "context" "net/http" "strings" + "time" + postgresstore "knowledge-governance/internal/platform/postgres" "knowledge-governance/internal/platform/weknora" ) @@ -13,6 +15,9 @@ import ( 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 @@ -32,6 +37,10 @@ type routingBindingView struct { 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 { @@ -77,21 +86,31 @@ func (config Config) routingBindingNames(ctx context.Context) (map[string]string 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()) +// 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 { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load routing bindings"}) - return + return routingBindingView{}, err } - internal, external := config.effectiveRoutingTargets(r.Context()) - view := routingBindingView{ + 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}) } @@ -152,9 +171,26 @@ func (config Config) handleSaveRoutingBindings(w http.ResponseWriter, r *http.Re } } } - if err := config.RoutingBindings.SaveRoutingKBBindings(r.Context(), internal, external, actorFor(r)); err != nil { + // 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 } - config.handleRoutingBindings(w, r) + 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}) } diff --git a/internal/httpapi/routing_bindings_test.go b/internal/httpapi/routing_bindings_test.go index 4e902d9..5db1f62 100644 --- a/internal/httpapi/routing_bindings_test.go +++ b/internal/httpapi/routing_bindings_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + postgresstore "knowledge-governance/internal/platform/postgres" "knowledge-governance/internal/platform/weknora" ) @@ -16,6 +17,11 @@ type bindingStoreFake struct { internal string external string saver string + // replan records what the last save asked for, so tests can assert that the + // effective (env-fallback aware) targets were used. + replanEffective [2]string + replanResult postgresstore.RoutingBindingReplanResult + replanCallCount int } func (fake *bindingStoreFake) LoadRoutingKBBindings(context.Context) (string, string, string, error) { @@ -27,6 +33,13 @@ func (fake *bindingStoreFake) SaveRoutingKBBindings(_ context.Context, internal, return nil } +func (fake *bindingStoreFake) SaveRoutingKBBindingsWithReplan(_ context.Context, internal, external, effectiveInternal, effectiveExternal, updatedBy string, _ time.Time) (postgresstore.RoutingBindingReplanResult, error) { + fake.internal, fake.external, fake.saver = internal, external, updatedBy + fake.replanEffective = [2]string{effectiveInternal, effectiveExternal} + fake.replanCallCount++ + return fake.replanResult, nil +} + type kbListerFake struct { bases []weknora.KnowledgeBase err error @@ -104,6 +117,37 @@ func TestRoutingBindingsSaveValidatesAgainstWeKnora(t *testing.T) { } } +// A binding change must also carry the 待入库 re-plan counts and pass the +// EFFECTIVE targets (env fallback included) so an unbound side still routes. +func TestRoutingBindingsSaveReportsReplanAndUsesEffectiveTargets(t *testing.T) { + store := &bindingStoreFake{replanResult: postgresstore.RoutingBindingReplanResult{Replanned: 3, RetiredDuplicates: 1}} + lister := &kbListerFake{bases: []weknora.KnowledgeBase{{ID: "kb-in", Name: "对内库"}, {ID: "kb-ex", Name: "对外库"}}} + handler := bindingsTestHandler(t, store, lister, "env-internal", "env-external") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/routing-bindings", strings.NewReader(`{"internal_kb_id":"kb-in","external_kb_id":""}`))) + if recorder.Code != http.StatusOK { + t.Fatalf("save must succeed: %d %s", recorder.Code, recorder.Body.String()) + } + if store.replanCallCount != 1 { + t.Fatalf("save must go through the replanning path once, got %d", store.replanCallCount) + } + if store.replanEffective != [2]string{"kb-in", "env-external"} { + t.Fatalf("effective targets = %#v, want the env fallback on the unbound side", store.replanEffective) + } + var envelope struct { + Data struct { + Replanned int `json:"replanned"` + RetiredDuplicates int `json:"retired_duplicates"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Data.Replanned != 3 || envelope.Data.RetiredDuplicates != 1 { + t.Fatalf("replan counts = %#v", envelope.Data) + } +} + func TestRouteCatalogFilesUsesUIBinding(t *testing.T) { store := &bindingStoreFake{internal: "ui-internal", external: "ui-external"} handler := bindingsTestHandler(t, store, &kbListerFake{}, "env-internal", "env-external") diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index ea9b4c5..8c084e1 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -100,12 +100,17 @@ type Store interface { } type Config struct { - Store Store - Monitor MonitorProvider - InternalRoutingKnowledgeBaseID string - ExternalRoutingKnowledgeBaseID string - RoutingBindings RoutingBindingStore - KnowledgeBases KnowledgeBaseLister + Store Store + Monitor MonitorProvider + InternalRoutingKnowledgeBaseID string + ExternalRoutingKnowledgeBaseID string + RoutingBindings RoutingBindingStore + KnowledgeBases KnowledgeBaseLister + // ProductionBindings is the 源库 → 目标库 mapping store; ProductionKnowledgeBases + // enumerates the production-side bases the operator can bind. + ProductionBindings ProductionBindingStore + ProductionKnowledgeBases ProductionKnowledgeBaseLister + ProductionWeKnoraBaseURL string ServiceTargetVerifier ServiceTargetVerifier Now func() time.Time RequireWeKnoraReady bool @@ -231,6 +236,23 @@ func NewHandler(config Config) (http.Handler, error) { } config.handleSaveRoutingBindings(w, r) }) + mux.HandleFunc("GET /api/v1/production-kb-bindings", func(w http.ResponseWriter, r *http.Request) { + if config.ProductionBindings == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "production bindings storage unavailable"}) + return + } + config.handleProductionBindings(w, r) + }) + mux.HandleFunc("GET /api/v1/production-kb-bindings/options", func(w http.ResponseWriter, r *http.Request) { + config.handleProductionKnowledgeBaseOptions(w, r) + }) + mux.HandleFunc("PUT /api/v1/production-kb-bindings", func(w http.ResponseWriter, r *http.Request) { + if config.ProductionBindings == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "production bindings storage unavailable"}) + return + } + config.handleSaveProductionBindings(w, r) + }) mux.HandleFunc("POST /api/v1/source-catalog/route", func(w http.ResponseWriter, r *http.Request) { routeCatalogFiles(w, r, config) }) diff --git a/internal/platform/postgres/artifact_fetch_requeue_integration_test.go b/internal/platform/postgres/artifact_fetch_requeue_integration_test.go index deea2fb..9cc62ec 100644 --- a/internal/platform/postgres/artifact_fetch_requeue_integration_test.go +++ b/internal/platform/postgres/artifact_fetch_requeue_integration_test.go @@ -132,4 +132,4 @@ VALUES('conn-fetch','node-1','internal','tester','技术文档/环境部署/关 } else if !errors.Is(err, ErrJobFailureNotRequeueable) { t.Fatalf("unexpected error: %v", err) } -} \ No newline at end of file +} diff --git a/internal/platform/postgres/migrations/000044_production_kb_bindings.down.sql b/internal/platform/postgres/migrations/000044_production_kb_bindings.down.sql new file mode 100644 index 0000000..eb5d73e --- /dev/null +++ b/internal/platform/postgres/migrations/000044_production_kb_bindings.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE release_item_executions DROP COLUMN IF EXISTS target_knowledge_base_id; +DROP TABLE IF EXISTS production_kb_bindings; \ No newline at end of file diff --git a/internal/platform/postgres/migrations/000044_production_kb_bindings.up.sql b/internal/platform/postgres/migrations/000044_production_kb_bindings.up.sql new file mode 100644 index 0000000..24814c7 --- /dev/null +++ b/internal/platform/postgres/migrations/000044_production_kb_bindings.up.sql @@ -0,0 +1,22 @@ +-- Cross-environment release needs an explicit 源库 → 目标库 mapping. Until now +-- the production KB was forced to carry the same id as the tested KB, which +-- meant only same-id "mirror" bases could ever be published to. One row stores +-- which (production-side) knowledge base receives 对内 and which receives 对外 +-- releases; an empty side keeps the historical same-id semantics (and fails +-- closed later if that id does not exist on the target). +CREATE TABLE production_kb_bindings ( + id TEXT PRIMARY KEY DEFAULT 'active' CHECK (id = 'active'), + internal_kb_id VARCHAR(64) NOT NULL DEFAULT '', + external_kb_id VARCHAR(64) NOT NULL DEFAULT '', + updated_by VARCHAR(64) NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO production_kb_bindings(id,internal_kb_id,external_kb_id,updated_by) +VALUES('active','','','system:migration'); + +-- The resolved target base is frozen per release item when the execution +-- starts, so an audit shows exactly where each knowledge went and a retry +-- reuses the same target instead of re-resolving a binding that may have moved. +ALTER TABLE release_item_executions + ADD COLUMN target_knowledge_base_id VARCHAR(64) NOT NULL DEFAULT ''; \ No newline at end of file diff --git a/internal/platform/postgres/migrations_test.go b/internal/platform/postgres/migrations_test.go index 551a416..f0670e1 100644 --- a/internal/platform/postgres/migrations_test.go +++ b/internal/platform/postgres/migrations_test.go @@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) { if err != nil { t.Fatalf("loadMigrations() error = %v", err) } - if len(migrations) != 43 { - t.Fatalf("migration count = %d, want 43", len(migrations)) + if len(migrations) != 44 { + t.Fatalf("migration count = %d, want 44", len(migrations)) } if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") { t.Fatalf("migration 1 = %#v", migrations[0]) diff --git a/internal/platform/postgres/production_bindings.go b/internal/platform/postgres/production_bindings.go new file mode 100644 index 0000000..b31b786 --- /dev/null +++ b/internal/platform/postgres/production_bindings.go @@ -0,0 +1,77 @@ +package postgres + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +// ProductionBinding is the UI-managed 源库 → 目标库 mapping row. The left side +// of the release (the tested knowledge base) is fixed by the publication; the +// right side is the production-side base this table chooses. An empty side +// keeps the historical same-id semantics. +type ProductionBinding struct { + InternalKBID string + ExternalKBID string + UpdatedBy string +} + +// LoadProductionKBBindings reads the single active mapping row. A missing row +// reads back as unbound rather than an error. +func (s *Store) LoadProductionKBBindings(ctx context.Context) (string, string, string, error) { + var binding ProductionBinding + err := s.db.QueryRow(ctx, `SELECT internal_kb_id,external_kb_id,updated_by FROM production_kb_bindings WHERE id='active'`). + Scan(&binding.InternalKBID, &binding.ExternalKBID, &binding.UpdatedBy) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", "", nil + } + if err != nil { + return "", "", "", errors.New("load production kb bindings") + } + return binding.InternalKBID, binding.ExternalKBID, binding.UpdatedBy, nil +} + +// SaveProductionKBBindings upserts the mapping row. The knowledge base ids must +// already have been validated against the live production WeKnora by the caller. +func (s *Store) SaveProductionKBBindings(ctx context.Context, internalID, externalID, updatedBy string, now time.Time) error { + if now.IsZero() { + return errors.New("production binding save requires a timestamp") + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO production_kb_bindings(id,internal_kb_id,external_kb_id,updated_by,updated_at) +VALUES('active',$1,$2,$3,$4) +ON CONFLICT(id) DO UPDATE SET + internal_kb_id=EXCLUDED.internal_kb_id, + external_kb_id=EXCLUDED.external_kb_id, + updated_by=EXCLUDED.updated_by, + updated_at=EXCLUDED.updated_at`, internalID, externalID, updatedBy, now); err != nil { + return errors.New("save production kb bindings") + } + return nil +} + +// ResolveProductionTargetKB maps a publication scope to the production base the +// release should write into. An unbound scope falls back to the tested base id, +// which is the historical same-id behaviour (and fails closed at the contract +// check when no such base exists on the target). +func (s *Store) ResolveProductionTargetKB(ctx context.Context, scope, testKBID string) (string, error) { + internal, external, _, err := s.LoadProductionKBBindings(ctx) + if err != nil { + return "", err + } + bound := "" + switch scope { + case "internal": + bound = internal + case "external": + bound = external + default: + return "", errors.New("unknown release scope") + } + if bound == "" { + return testKBID, nil + } + return bound, nil +} diff --git a/internal/platform/postgres/production_bindings_integration_test.go b/internal/platform/postgres/production_bindings_integration_test.go new file mode 100644 index 0000000..c39267f --- /dev/null +++ b/internal/platform/postgres/production_bindings_integration_test.go @@ -0,0 +1,72 @@ +package postgres + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// The 源库 → 目标库 mapping has to survive a round trip and, when a scope is +// unbound, fall back to the tested base (the historical same-id semantics). +func TestProductionKBBindingsResolveAndFallBack(t *testing.T) { + databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + if err = ApplyMigrations(ctx, pool); err != nil { + t.Fatal(err) + } + store := NewStore(pool) + now := time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC) + + internal, external, _, err := store.LoadProductionKBBindings(ctx) + if err != nil { + t.Fatal(err) + } + if internal != "" || external != "" { + t.Fatalf("fresh mapping = %q/%q, want unbound", internal, external) + } + if target, err := store.ResolveProductionTargetKB(ctx, "internal", "test-kb"); err != nil || target != "test-kb" { + t.Fatalf("unbound resolve = %q/%v, want the tested base", target, err) + } + + if err := store.SaveProductionKBBindings(ctx, "prod-internal", "prod-external", "admin", now); err != nil { + t.Fatal(err) + } + if target, err := store.ResolveProductionTargetKB(ctx, "internal", "test-kb"); err != nil || target != "prod-internal" { + t.Fatalf("internal resolve = %q/%v", target, err) + } + if target, err := store.ResolveProductionTargetKB(ctx, "external", "test-kb"); err != nil || target != "prod-external" { + t.Fatalf("external resolve = %q/%v", target, err) + } + if _, err := store.ResolveProductionTargetKB(ctx, "sideways", "test-kb"); err == nil { + t.Fatal("an unknown scope must not resolve to anything") + } + + // Unbinding one side returns that side to the same-id semantics only. + if err := store.SaveProductionKBBindings(ctx, "", "prod-external", "admin", now); err != nil { + t.Fatal(err) + } + if target, err := store.ResolveProductionTargetKB(ctx, "internal", "test-kb"); err != nil || target != "test-kb" { + t.Fatalf("unbound side = %q/%v", target, err) + } + if target, _ := store.ResolveProductionTargetKB(ctx, "external", "test-kb"); target != "prod-external" { + t.Fatalf("bound side regressed = %q", target) + } + _, _, saver, err := store.LoadProductionKBBindings(ctx) + if err != nil || saver != "admin" { + t.Fatalf("updated_by = %q (%v)", saver, err) + } + if err := store.SaveProductionKBBindings(ctx, "prod-internal", "", "admin", time.Time{}); err == nil { + t.Fatal("a save without a timestamp must be rejected") + } +} diff --git a/internal/platform/postgres/release_execution_worker_store.go b/internal/platform/postgres/release_execution_worker_store.go index 8a5d696..5e131df 100644 --- a/internal/platform/postgres/release_execution_worker_store.go +++ b/internal/platform/postgres/release_execution_worker_store.go @@ -63,14 +63,45 @@ execution_gate AS ( AND (gate_version.revision = '' OR gate_publication.parser_generation = '' OR gate_publication.index_generation = '') ) RETURNING execution.id, execution.release_batch_id, execution.operation, execution.prior_execution_id -), seeded AS ( - INSERT INTO release_item_executions ( - id, release_execution_id, publication_id, action, state, created_at, updated_at - ) - SELECT 'ri-' || SUBSTR(MD5(started.id || ':' || item.publication_id), 1, 32), - started.id, item.publication_id, item.action, 'pending', $4::TIMESTAMPTZ, $4::TIMESTAMPTZ +), target_base AS ( + -- 源库 → 目标库 mapping, frozen per item. A publication that was already + -- verified keeps the base it went to (retry / rollback return to the same + -- place); otherwise the UI binding decides, and an unbound scope keeps the + -- historical same-id semantics so existing routings keep working. + SELECT item.publication_id, + COALESCE( + NULLIF(prior_item.target_knowledge_base_id, ''), + NULLIF(CASE publication.target_scope + WHEN 'internal' THEN binding.internal_kb_id + WHEN 'external' THEN binding.external_kb_id + END, ''), + publication.test_kb_id) AS target_kb_id FROM started JOIN release_batch_items AS item ON item.release_batch_id = started.release_batch_id + JOIN test_publications AS publication ON publication.id = item.publication_id + LEFT JOIN production_kb_bindings AS binding ON binding.id = 'active' + LEFT JOIN LATERAL ( + SELECT candidate.target_knowledge_base_id + FROM release_item_executions AS candidate + JOIN release_executions AS candidate_execution ON candidate_execution.id = candidate.release_execution_id + WHERE candidate.publication_id = item.publication_id + AND candidate.target_knowledge_base_id <> '' + AND candidate.state = 'verified' + AND candidate_execution.operation = 'publish' + AND candidate_execution.state IN ('completed', 'failed') + ORDER BY candidate.completed_at DESC NULLS LAST, candidate.id DESC + LIMIT 1 + ) AS prior_item ON TRUE +), seeded AS ( + INSERT INTO release_item_executions ( + id, release_execution_id, publication_id, action, state, created_at, updated_at, target_knowledge_base_id + ) + SELECT 'ri-' || SUBSTR(MD5(started.id || ':' || item.publication_id), 1, 32), + started.id, item.publication_id, item.action, 'pending', $4::TIMESTAMPTZ, $4::TIMESTAMPTZ, + COALESCE(target_base.target_kb_id, '') + FROM started + JOIN release_batch_items AS item ON item.release_batch_id = started.release_batch_id + LEFT JOIN target_base ON target_base.publication_id = item.publication_id WHERE started.operation = 'publish' OR EXISTS ( SELECT 1 FROM release_item_executions AS prior_seed @@ -97,6 +128,7 @@ execution_gate AS ( publication.parser_generation || '@' || publication.index_generation AS parse_revision, item.review_id AS release_revision, publication.test_kb_id, + COALESCE(target_base.target_kb_id, '') AS target_kb_id, publication.knowledge_id, item.decided_visibility, COALESCE(previous.production_revision, '') AS previous_production_revision @@ -105,6 +137,7 @@ execution_gate AS ( JOIN active_items ON active_items.publication_id = item.publication_id JOIN test_publications AS publication ON publication.id = item.publication_id + LEFT JOIN target_base ON target_base.publication_id = item.publication_id JOIN source_item_versions AS current_version ON current_version.id = item.source_item_version_id LEFT JOIN LATERAL ( SELECT CASE @@ -146,6 +179,7 @@ SELECT COUNT(DISTINCT started.id)::INTEGER, 'parse_revision', plan.parse_revision, 'release_revision', plan.release_revision, 'test_knowledge_base_id', plan.test_kb_id, + 'target_knowledge_base_id', plan.target_kb_id, 'knowledge_id', plan.knowledge_id, 'decided_visibility', plan.decided_visibility, 'previous_production_revision', plan.previous_production_revision @@ -271,7 +305,7 @@ func (s *Store) StartExecution(ctx context.Context, request releaseworkflow.Star } for _, item := range items { if !validShortID(item.PublicationID) || !validShortID(item.SourceItemVersionID) || - !validRemoteIdentifier(item.TestKnowledgeBaseID) || !validRemoteIdentifier(item.KnowledgeID) || + !validRemoteIdentifier(item.TestKnowledgeBaseID) || !validRemoteIdentifier(item.TargetKnowledgeBaseID) || !validRemoteIdentifier(item.KnowledgeID) || (item.DecidedVisibility != governance.VisibilityInternal && item.DecidedVisibility != governance.VisibilityExternal) || len(item.PreviousProductionRevision) > 128 { return nil, errors.New("invalid release execution plan") diff --git a/internal/platform/postgres/release_execution_worker_store_test.go b/internal/platform/postgres/release_execution_worker_store_test.go index 71c3542..b61b212 100644 --- a/internal/platform/postgres/release_execution_worker_store_test.go +++ b/internal/platform/postgres/release_execution_worker_store_test.go @@ -13,7 +13,7 @@ import ( ) func TestStartReleaseExecutionSeedsItemsAndReturnsOnlyPendingPlan(t *testing.T) { - payload := `[{"release_execution_id":"rx-safe","publication_id":"tp-safe","action":"delete","source_item_version_id":"sv-safe","source_revision":"source-v1","parse_revision":"parser-v1@index-v1","release_revision":"review-v1","test_knowledge_base_id":"kb-safe","knowledge_id":"knowledge-safe","decided_visibility":"internal","previous_production_revision":"prod-old"}]` + payload := `[{"release_execution_id":"rx-safe","publication_id":"tp-safe","action":"delete","source_item_version_id":"sv-safe","source_revision":"source-v1","parse_revision":"parser-v1@index-v1","release_revision":"review-v1","test_knowledge_base_id":"kb-safe","target_knowledge_base_id":"prod-kb","knowledge_id":"knowledge-safe","decided_visibility":"internal","previous_production_revision":"prod-old"}]` db := &recordingDB{row: startExecutionRow{executionCount: 1, payload: []byte(payload)}} items, err := NewStore(db).StartExecution(context.Background(), releaseworkflow.StartExecutionRequest{ ExecutionID: "rx-safe", JobID: "job-safe", Operation: "publish", Now: time.Now(), @@ -24,6 +24,9 @@ func TestStartReleaseExecutionSeedsItemsAndReturnsOnlyPendingPlan(t *testing.T) if len(items) != 1 || items[0].PublicationID != "tp-safe" || items[0].Action != "delete" || items[0].KnowledgeID != "knowledge-safe" || items[0].PreviousProductionRevision != "prod-old" { t.Fatalf("items=%#v", items) } + if items[0].TargetBase() != "prod-kb" { + t.Fatalf("target base=%q, want the mapped 源库 → 目标库 target", items[0].TargetBase()) + } sql := strings.ToLower(db.queryRowSQL) for _, required := range []string{ "update release_executions", "then 'rolling_back' else 'running' end", "job_id = $2", "operation = $3", @@ -34,6 +37,8 @@ func TestStartReleaseExecutionSeedsItemsAndReturnsOnlyPendingPlan(t *testing.T) "started.operation = 'publish' or exists", "prior_seed.state = 'verified'", "prior_execution.state in ('completed', 'failed')", "release_delete_ready", "item.action = 'delete'", "execution_gate.valid_items = execution_gate.item_count", + // the 源库 → 目标库 mapping is resolved into the plan and frozen per item + "production_kb_bindings", "target_knowledge_base_id", "target_base.target_kb_id", } { if !strings.Contains(sql, required) { t.Fatalf("start execution SQL missing %q", required) @@ -41,6 +46,18 @@ func TestStartReleaseExecutionSeedsItemsAndReturnsOnlyPendingPlan(t *testing.T) } } +// An empty resolved target must be rejected rather than silently published to +// an unknown base. +func TestStartReleaseExecutionRejectsEmptyTargetBase(t *testing.T) { + payload := `[{"release_execution_id":"rx-safe","publication_id":"tp-safe","action":"delete","source_item_version_id":"sv-safe","source_revision":"source-v1","parse_revision":"parser-v1@index-v1","release_revision":"review-v1","test_knowledge_base_id":"kb-safe","target_knowledge_base_id":"","knowledge_id":"knowledge-safe","decided_visibility":"internal"}]` + _, err := NewStore(&recordingDB{row: startExecutionRow{executionCount: 1, payload: []byte(payload)}}).StartExecution(context.Background(), releaseworkflow.StartExecutionRequest{ + ExecutionID: "rx-safe", JobID: "job-safe", Operation: "publish", Now: time.Now(), + }) + if err == nil { + t.Fatal("an unmapped, empty target base must fail closed") + } +} + func TestStartReleaseExecutionRejectsUnknownOrMismatchedExecution(t *testing.T) { _, err := NewStore(&recordingDB{row: startExecutionRow{executionCount: 0, payload: []byte(`[]`)}}).StartExecution(context.Background(), releaseworkflow.StartExecutionRequest{ ExecutionID: "rx-safe", JobID: "job-safe", Operation: "publish", Now: time.Now(), diff --git a/internal/platform/postgres/routing_bindings.go b/internal/platform/postgres/routing_bindings.go index 084aa59..fba7bfb 100644 --- a/internal/platform/postgres/routing_bindings.go +++ b/internal/platform/postgres/routing_bindings.go @@ -3,6 +3,8 @@ package postgres import ( "context" "errors" + "fmt" + "time" "github.com/jackc/pgx/v5" ) @@ -30,6 +32,17 @@ func (s *Store) LoadRoutingKBBindings(ctx context.Context) (string, string, stri return binding.InternalKBID, binding.ExternalKBID, binding.UpdatedBy, nil } +// RoutingBindingReplanResult reports what a binding change did to publications +// that were still waiting in 待入库. +type RoutingBindingReplanResult struct { + // Replanned counts untouched pending publications that were re-pointed at + // the newly bound knowledge base for their scope. + Replanned int + // RetiredDuplicates counts pending publications that already had a twin for + // the new knowledge base, so the stale row was superseded instead of moved. + RetiredDuplicates int +} + // SaveRoutingKBBindings upserts the binding row. Each side is either empty // (unbind, fall back to env) or a knowledge base id validated by the caller. func (s *Store) SaveRoutingKBBindings(ctx context.Context, internalID, externalID, updatedBy string) error { @@ -46,3 +59,98 @@ ON CONFLICT(id) DO UPDATE SET } return nil } + +// replanRetireDuplicatesSQL retires a pending publication whose twin for the +// new knowledge base already exists — the unique key is +// (source_item_version_id, test_kb_id), so the row cannot simply be moved. +const replanRetireDuplicatesSQL = ` +UPDATE test_publications stale +SET superseded_at = $3, updated_at = $3 +WHERE stale.superseded_at IS NULL + AND stale.test_kb_id <> $1 + AND stale.processing_state = 'planned' + AND stale.ingest_state = 'pending' + AND stale.knowledge_id = '' + AND stale.retrieval_state = 'unpublished' + AND stale.target_scope = $2 + AND NOT EXISTS (SELECT 1 FROM jobs job WHERE job.type = 'test.upload' AND job.payload->>'publication_id' = stale.id) + AND EXISTS ( + SELECT 1 FROM test_publications twin + WHERE twin.source_item_version_id = stale.source_item_version_id + AND twin.test_kb_id = $1 + AND twin.superseded_at IS NULL) +` + +// replanMovePendingSQL re-points the untouched remainder at the new knowledge +// base. Only rows that have never been uploaded anywhere are eligible: anything +// already in flight keeps its frozen target and stays visible in 待入库. +const replanMovePendingSQL = ` +UPDATE test_publications pending +SET test_kb_id = $1, updated_at = $3 +WHERE pending.superseded_at IS NULL + AND pending.test_kb_id <> $1 + AND pending.processing_state = 'planned' + AND pending.ingest_state = 'pending' + AND pending.knowledge_id = '' + AND pending.retrieval_state = 'unpublished' + AND pending.target_scope = $2 + AND NOT EXISTS (SELECT 1 FROM jobs job WHERE job.type = 'test.upload' AND job.payload->>'publication_id' = pending.id) + AND NOT EXISTS ( + SELECT 1 FROM test_publications twin + WHERE twin.source_item_version_id = pending.source_item_version_id + AND twin.test_kb_id = $1 + AND twin.superseded_at IS NULL) +` + +// SaveRoutingKBBindingsWithReplan saves the binding and, in the SAME +// transaction, re-points 待入库 items that were still waiting for the old +// knowledge base. Without this, changing the binding left every pending +// publication aimed at the previous (possibly deleted) KB, and re-marking the +// file created a SECOND publication instead of moving the first. +// +// effectiveInternal/effectiveExternal are the ids the platform actually routes +// to (the saved binding, or the deployment default when a side is unbound); +// an empty effective id means that scope cannot be routed and is skipped. +func (s *Store) SaveRoutingKBBindingsWithReplan(ctx context.Context, internalID, externalID, effectiveInternal, effectiveExternal, updatedBy string, now time.Time) (RoutingBindingReplanResult, error) { + var result RoutingBindingReplanResult + if now.IsZero() { + return result, errors.New("binding replan requires a timestamp") + } + tx, err := s.db.Begin(ctx) + if err != nil { + return result, fmt.Errorf("begin routing binding save: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, ` +INSERT INTO routing_kb_bindings(id,internal_kb_id,external_kb_id,updated_by,updated_at) +VALUES('active',$1,$2,$3,$4) +ON CONFLICT(id) DO UPDATE SET + internal_kb_id=EXCLUDED.internal_kb_id, + external_kb_id=EXCLUDED.external_kb_id, + updated_by=EXCLUDED.updated_by, + updated_at=EXCLUDED.updated_at`, internalID, externalID, updatedBy, now); err != nil { + return result, errors.New("save routing kb bindings") + } + for _, scope := range []struct{ name, kbID string }{ + {"internal", effectiveInternal}, + {"external", effectiveExternal}, + } { + if scope.kbID == "" { + continue + } + retired, err := tx.Exec(ctx, replanRetireDuplicatesSQL, scope.kbID, scope.name, now) + if err != nil { + return result, fmt.Errorf("retire duplicate pending publications: %w", err) + } + moved, err := tx.Exec(ctx, replanMovePendingSQL, scope.kbID, scope.name, now) + if err != nil { + return result, fmt.Errorf("replan pending publications: %w", err) + } + result.RetiredDuplicates += int(retired.RowsAffected()) + result.Replanned += int(moved.RowsAffected()) + } + if err := tx.Commit(ctx); err != nil { + return result, fmt.Errorf("commit routing binding save: %w", err) + } + return result, nil +} diff --git a/internal/platform/postgres/routing_bindings_replan_integration_test.go b/internal/platform/postgres/routing_bindings_replan_integration_test.go new file mode 100644 index 0000000..b51d0c6 --- /dev/null +++ b/internal/platform/postgres/routing_bindings_replan_integration_test.go @@ -0,0 +1,115 @@ +package postgres + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Changing the knowledge-base binding used to re-route only FUTURE annotations: +// every publication already sitting in 待入库 kept its frozen test_kb_id, so a +// re-pointed binding left pending items aimed at the previous (deleted) KB, and +// re-marking the file created a SECOND publication instead of moving the first. +func TestSaveRoutingKBBindingsReplansPendingPublications(t *testing.T) { + databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + if err = ApplyMigrations(ctx, pool); err != nil { + t.Fatal(err) + } + if _, err = pool.Exec(ctx, ` +INSERT INTO source_connections(id,source_type,name,enabled) VALUES('conn-replan','manual','Replan',true); +INSERT INTO source_items(id,source_connection_id,stable_external_id,canonical_uri_hash,normalized_path,title,item_type,revision,visibility,visibility_origin) VALUES + ('item-in','conn-replan','n-in',repeat('b',64),'docs/in.docx','in.docx','document','locator:r1','internal','configured_scope'), + ('item-ext','conn-replan','n-ext',repeat('b',64),'docs/ext.docx','ext.docx','document','locator:r2','external','configured_scope'), + ('item-sent','conn-replan','n-sent',repeat('b',64),'docs/sent.docx','sent.docx','document','locator:r3','internal','configured_scope'), + ('item-dup','conn-replan','n-dup',repeat('b',64),'docs/dup.docx','dup.docx','document','locator:r4','internal','configured_scope'); +INSERT INTO source_item_versions(id,source_item_id,revision,content_object_id,content_state) VALUES + ('v-in','item-in','locator:r1',NULL,'unknown'), + ('v-ext','item-ext','locator:r2',NULL,'unknown'), + ('v-sent','item-sent','locator:r3',NULL,'unknown'), + ('v-dup','item-dup','locator:r4',NULL,'unknown'); +INSERT INTO test_publications(id,source_item_version_id,test_kb_id,processing_state,ingest_state,target_scope) VALUES + ('pub-in','v-in','kb-old-internal','planned','pending','internal'), + ('pub-ext','v-ext','kb-old-external','planned','pending','external'), + ('pub-sent','v-sent','kb-old-internal','pending','uploaded','internal'), + ('pub-dup-old','v-dup','kb-old-internal','planned','pending','internal'), + ('pub-dup-new','v-dup','kb-new-internal','planned','pending','internal'); +INSERT INTO jobs(id,type,payload,state,idempotency_key) VALUES + ('job-sent','test.upload','{"publication_id":"pub-sent"}'::jsonb,'completed','test-start:pub-sent'); +`); err != nil { + t.Fatal(err) + } + store := NewStore(pool) + now := time.Date(2026, time.October, 8, 7, 0, 0, 0, time.UTC) + + result, err := store.SaveRoutingKBBindingsWithReplan(ctx, "kb-new-internal", "kb-new-external", "kb-new-internal", "kb-new-external", "tester", now) + if err != nil { + t.Fatalf("SaveRoutingKBBindingsWithReplan() error = %v", err) + } + // pub-in and pub-ext move; pub-sent already has an upload job (untouched); + // pub-dup-old already has a twin for the new KB so it is retired instead. + if result.Replanned != 2 || result.RetiredDuplicates != 1 { + t.Fatalf("replan result = %#v, want 2 moved / 1 retired", result) + } + states := map[string]struct { + kb string + superseded bool + }{} + rows, err := pool.Query(ctx, `SELECT id, test_kb_id, superseded_at IS NOT NULL FROM test_publications`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var id, kb string + var superseded bool + if err := rows.Scan(&id, &kb, &superseded); err != nil { + t.Fatal(err) + } + states[id] = struct { + kb string + superseded bool + }{kb, superseded} + } + for id, want := range map[string]string{ + "pub-in": "kb-new-internal", "pub-ext": "kb-new-external", "pub-dup-new": "kb-new-internal", + } { + if states[id].kb != want || states[id].superseded { + t.Fatalf("%s = %#v, want kb %s and not superseded", id, states[id], want) + } + } + if states["pub-sent"].kb != "kb-old-internal" || states["pub-sent"].superseded { + t.Fatalf("an already-uploaded publication must keep its target: %#v", states["pub-sent"]) + } + if states["pub-dup-old"].kb != "kb-old-internal" || !states["pub-dup-old"].superseded { + t.Fatalf("the duplicate stale row must be retired: %#v", states["pub-dup-old"]) + } + + // Re-saving the same binding is a no-op: nothing left to move or retire. + again, err := store.SaveRoutingKBBindingsWithReplan(ctx, "kb-new-internal", "kb-new-external", "kb-new-internal", "kb-new-external", "tester", now) + if err != nil { + t.Fatal(err) + } + if again.Replanned != 0 || again.RetiredDuplicates != 0 { + t.Fatalf("replay must be a no-op, got %#v", again) + } + var internal, external, updatedBy string + if err = pool.QueryRow(ctx, `SELECT internal_kb_id, external_kb_id, updated_by FROM routing_kb_bindings WHERE id='active'`). + Scan(&internal, &external, &updatedBy); err != nil { + t.Fatal(err) + } + if internal != "kb-new-internal" || external != "kb-new-external" || updatedBy != "tester" { + t.Fatalf("binding row = %s / %s / %s", internal, external, updatedBy) + } +} diff --git a/internal/releaseadapter/v071/contract_inspector.go b/internal/releaseadapter/v071/contract_inspector.go index ebe5043..93c1290 100644 --- a/internal/releaseadapter/v071/contract_inspector.go +++ b/internal/releaseadapter/v071/contract_inspector.go @@ -64,6 +64,13 @@ func (inspector *TargetContractInspector) Contract(ctx context.Context, item rel if item.TestKnowledgeBaseID != inspector.config.KnowledgeBaseID { return releasebundle.TargetContract{}, errors.New("release plan knowledge base does not match target contract") } + // The route is keyed by the TESTED base (where the content was verified); + // the contract is read for the base this release actually writes into — + // the 源库 → 目标库 mapping when the item carries one, else the same base. + targetKnowledgeBaseID := item.TargetBase() + if !validSearchIdentifier(targetKnowledgeBaseID) { + return releasebundle.TargetContract{}, errors.New("invalid release target knowledge base") + } var ( migrationVersion int dirty bool @@ -73,7 +80,7 @@ func (inspector *TargetContractInspector) Contract(ctx context.Context, item rel engineType string indexName string ) - if err := inspector.config.DB.QueryRow(ctx, targetContractSQL, inspector.config.KnowledgeBaseID).Scan( + if err := inspector.config.DB.QueryRow(ctx, targetContractSQL, targetKnowledgeBaseID).Scan( &migrationVersion, &dirty, &tenantID, &embeddingModel, &dimension, &engineType, &indexName, ); err != nil { return releasebundle.TargetContract{}, errors.New("read target WeKnora contract") @@ -92,7 +99,7 @@ func (inspector *TargetContractInspector) Contract(ctx context.Context, item rel WeKnoraVersion: releasebundle.WeKnoraVersionV071, MigrationVersion: releasebundle.WeKnoraMigrationV071, TenantID: tenantID, - KnowledgeBaseID: inspector.config.KnowledgeBaseID, + KnowledgeBaseID: targetKnowledgeBaseID, RetrieverEngine: "elasticsearch_v8", EmbeddingModel: embeddingModel, EmbeddingDimension: dimension, diff --git a/internal/releaseadapter/v071/document_target.go b/internal/releaseadapter/v071/document_target.go index 61b6cd0..e390273 100644 --- a/internal/releaseadapter/v071/document_target.go +++ b/internal/releaseadapter/v071/document_target.go @@ -70,6 +70,21 @@ func (target *DocumentTarget) Contract(ctx context.Context, item releaseworkflow return contract, nil } +// loadBundle loads an archived bundle and applies the item's 源库 → 目标库 +// mapping, so activate / compensate / restore / verify all write and read the +// same target base. The archive itself keeps the canonical source-form bundle: +// the remap is deterministic, so a rollback re-derives the same target form. +func (target *DocumentTarget) loadBundle(item releaseworkflow.PlanItem, revision string) (*DocumentBundle, error) { + bundle, err := target.config.Archive.Load(revision) + if err != nil { + return nil, err + } + if err := bundle.RemapKnowledgeBase(item.TargetBase()); err != nil { + return nil, err + } + return bundle, nil +} + func (target *DocumentTarget) Prepare(ctx context.Context, item releaseworkflow.PlanItem, exported releaseworkflow.ExportedBundle) (releaseworkflow.PreparedChange, error) { bundle, ok := exported.(*DocumentBundle) if !ok || validateDocumentBundle(bundle) != nil || bundle.Manifest.ReleaseRevision != item.ReleaseRevision { @@ -93,7 +108,7 @@ func (target *DocumentTarget) Prepare(ctx context.Context, item releaseworkflow. } func (target *DocumentTarget) Activate(ctx context.Context, item releaseworkflow.PlanItem, change releaseworkflow.PreparedChange) error { - bundle, err := target.config.Archive.Load(change.ProductionRevision) + bundle, err := target.loadBundle(item, change.ProductionRevision) if err != nil || bundle.Manifest.ReleaseRevision != item.ReleaseRevision { return errors.New("load staged v0.7.1 release bundle") } @@ -104,7 +119,7 @@ func (target *DocumentTarget) Activate(ctx context.Context, item releaseworkflow if err := target.config.Objects.Promote(ctx, stage); err != nil { return errors.New("promote v0.7.1 release assets") } - postgresIdentity, searchIdentity := targetIdentities(bundle.Manifest) + postgresIdentity, searchIdentity := targetIdentities(item, bundle.Manifest) if err := target.config.Postgres.Replace(ctx, postgresIdentity, bundle.Postgres); err != nil { return errors.New("activate v0.7.1 PostgreSQL release") } @@ -115,7 +130,7 @@ func (target *DocumentTarget) Activate(ctx context.Context, item releaseworkflow } func (target *DocumentTarget) Verify(ctx context.Context, item releaseworkflow.PlanItem, change releaseworkflow.PreparedChange) error { - bundle, err := target.config.Archive.Load(change.ProductionRevision) + bundle, err := target.loadBundle(item, change.ProductionRevision) if err != nil { return errors.New("load v0.7.1 release for verification") } @@ -130,11 +145,11 @@ func (target *DocumentTarget) Verify(ctx context.Context, item releaseworkflow.P func (target *DocumentTarget) Compensate(ctx context.Context, item releaseworkflow.PlanItem, change releaseworkflow.PreparedChange) error { if change.PreviousRevision == "" || change.PreviousRevision == "absent" { - manifestBundle, err := target.config.Archive.Load(change.ProductionRevision) + manifestBundle, err := target.loadBundle(item, change.ProductionRevision) if err != nil { return errors.New("load failed first release bundle") } - postgresIdentity, searchIdentity := targetIdentities(manifestBundle.Manifest) + postgresIdentity, searchIdentity := targetIdentities(item, manifestBundle.Manifest) if err := target.config.Postgres.Disable(ctx, postgresIdentity); err != nil { return errors.New("disable failed first PostgreSQL release") } @@ -146,16 +161,16 @@ func (target *DocumentTarget) Compensate(ctx context.Context, item releaseworkfl } return nil } - return target.restoreBundle(ctx, change.PreviousRevision) + return target.restoreBundle(ctx, item, change.PreviousRevision) } func (target *DocumentTarget) Restore(ctx context.Context, item releaseworkflow.PlanItem, revision string) (string, error) { if revision == "absent" { - current, err := target.config.Archive.Load(item.ReleaseRevision) + current, err := target.loadBundle(item, item.ReleaseRevision) if err != nil { return "", errors.New("load current release for disable") } - postgresIdentity, searchIdentity := targetIdentities(current.Manifest) + postgresIdentity, searchIdentity := targetIdentities(item, current.Manifest) if err := target.config.Postgres.Disable(ctx, postgresIdentity); err != nil { return "", errors.New("disable current PostgreSQL release") } @@ -171,7 +186,7 @@ func (target *DocumentTarget) Restore(ctx context.Context, item releaseworkflow. } return "absent", nil } - if err := target.restoreBundle(ctx, revision); err != nil { + if err := target.restoreBundle(ctx, item, revision); err != nil { return "", err } return revision, nil @@ -182,7 +197,7 @@ func (target *DocumentTarget) VerifyRestored(ctx context.Context, item releasewo if err := target.config.Verifier.VerifyDisabled(ctx, item); err != nil { return errors.New("verify disabled production release") } - current, err := target.config.Archive.Load(item.ReleaseRevision) + current, err := target.loadBundle(item, item.ReleaseRevision) if err != nil { return errors.New("load withdrawn v0.7.1 release for wiki verification") } @@ -191,7 +206,7 @@ func (target *DocumentTarget) VerifyRestored(ctx context.Context, item releasewo } return nil } - bundle, err := target.config.Archive.Load(revision) + bundle, err := target.loadBundle(item, revision) if err != nil { return errors.New("load restored v0.7.1 release") } @@ -204,8 +219,8 @@ func (target *DocumentTarget) VerifyRestored(ctx context.Context, item releasewo return nil } -func (target *DocumentTarget) restoreBundle(ctx context.Context, revision string) error { - bundle, err := target.config.Archive.Load(revision) +func (target *DocumentTarget) restoreBundle(ctx context.Context, item releaseworkflow.PlanItem, revision string) error { + bundle, err := target.loadBundle(item, revision) if err != nil { return errors.New("load prior v0.7.1 release bundle") } @@ -216,7 +231,7 @@ func (target *DocumentTarget) restoreBundle(ctx context.Context, revision string if err := target.config.Objects.Promote(ctx, stage); err != nil { return errors.New("promote prior v0.7.1 release assets") } - postgresIdentity, searchIdentity := targetIdentities(bundle.Manifest) + postgresIdentity, searchIdentity := targetIdentities(item, bundle.Manifest) if err := target.config.Postgres.Replace(ctx, postgresIdentity, bundle.Postgres); err != nil { return errors.New("restore prior v0.7.1 PostgreSQL release") } @@ -226,8 +241,11 @@ func (target *DocumentTarget) restoreBundle(ctx context.Context, revision string return nil } -func targetIdentities(manifest releasebundle.Manifest) (PostgresIdentity, SearchIdentity) { - postgresIdentity := PostgresIdentity{TenantID: manifest.Contract.TenantID, KnowledgeBaseID: manifest.Contract.KnowledgeBaseID, KnowledgeID: manifest.Contract.KnowledgeID} +// targetIdentities is where a release item is written: the 源库 → 目标库 mapped +// base (item.TargetBase()), the tenant and the knowledge id that travel with the +// bundle. The bundle's own contract keeps the SOURCE base it was exported from. +func targetIdentities(item releaseworkflow.PlanItem, manifest releasebundle.Manifest) (PostgresIdentity, SearchIdentity) { + postgresIdentity := PostgresIdentity{TenantID: manifest.Contract.TenantID, KnowledgeBaseID: item.TargetBase(), KnowledgeID: manifest.Contract.KnowledgeID} return postgresIdentity, SearchIdentity(postgresIdentity) } diff --git a/internal/releaseadapter/v071/remap.go b/internal/releaseadapter/v071/remap.go new file mode 100644 index 0000000..7a1e01b --- /dev/null +++ b/internal/releaseadapter/v071/remap.go @@ -0,0 +1,139 @@ +package v071 + +import ( + "encoding/json" + "errors" + + "knowledge-governance/internal/releasebundle" +) + +// knowledgeBaseField is the single place a knowledge-base identity lives inside +// an exported artefact. Verified by scanning every exported row of a real +// knowledge: the base id appears exactly once per row, in this field. +const knowledgeBaseField = "knowledge_base_id" + +// remappableTables are the exported tables whose rows carry that identity. +// resources / resource_bindings / knowledge_tag_relations carry none: they hang +// off the knowledge id, the resource handle and the tag id respectively. +var remappableTables = []string{"knowledges", "chunks", "knowledge_tags", "wiki_pages", "wiki_folders"} + +// RemapKnowledgeBase rewrites the bundle's knowledge-base identity from the +// tested base to the target base and re-seals it. Without this, a cross-base +// release would be rejected by the fail-closed guards that assert every row and +// document sits inside the identity scope it is written under. +// +// The manifest keeps the SOURCE base on purpose — that is where the content came +// from, and it is what the release item's tested base is checked against. The +// target base travels with the release item and is asserted separately against +// the target contract. +func (bundle *DocumentBundle) RemapKnowledgeBase(targetKnowledgeBaseID string) error { + if bundle == nil { + return errors.New("release bundle is required") + } + if !validSearchIdentifier(targetKnowledgeBaseID) { + return errors.New("invalid remap target knowledge base") + } + source := bundle.Manifest.Contract.KnowledgeBaseID + if targetKnowledgeBaseID == source { + return nil + } + digests := make(map[string]releasebundle.TableDigest, len(remappableTables)) + for _, name := range remappableTables { + table := bundle.Postgres.Table(name) + if len(table.Rows) == 0 { + continue + } + rows := make([]json.RawMessage, 0, len(table.Rows)) + for _, row := range table.Rows { + rewritten, err := remapKnowledgeBaseField(row, source, targetKnowledgeBaseID) + if err != nil { + return err + } + rows = append(rows, rewritten) + } + remapped := newTableSnapshot(name, rows) + digests[name] = releasebundle.TableDigest{Name: remapped.Name, RowCount: remapped.RowCount, SHA256: remapped.SHA256} + bundle.Postgres = replaceTable(bundle.Postgres, remapped) + } + searchDigest := bundle.Search.DocumentsSHA256 + if len(bundle.Search.Documents) > 0 { + documents := make([]SearchDocument, 0, len(bundle.Search.Documents)) + for _, document := range bundle.Search.Documents { + rewritten, err := remapKnowledgeBaseField(document.Source, source, targetKnowledgeBaseID) + if err != nil { + return err + } + documents = append(documents, SearchDocument{ID: document.ID, Source: rewritten}) + } + recomputed, err := searchDocumentsDigest(documents) + if err != nil { + return err + } + bundle.Search.Documents = documents + bundle.Search.DocumentsSHA256 = recomputed + searchDigest = recomputed + } + + manifest := bundle.Manifest + tables := make([]releasebundle.TableDigest, 0, len(manifest.Tables)) + for _, table := range manifest.Tables { + if remapped, ok := digests[table.Name]; ok { + tables = append(tables, remapped) + continue + } + tables = append(tables, table) + } + manifest.Tables = tables + manifest.Search = releasebundle.SearchDigest{DocumentCount: len(bundle.Search.Documents), SHA256: searchDigest} + sealed, err := releasebundle.Seal(manifest) + if err != nil { + return errors.New("reseal remapped release bundle") + } + bundle.Manifest = sealed + return nil +} + +// remapKnowledgeBaseField rewrites one JSON object's base identity. Every other +// field is carried through as its original bytes (RawMessage values are never +// re-encoded), so embeddings and other payloads cannot be perturbed by a remap. +func remapKnowledgeBaseField(raw json.RawMessage, source, target string) (json.RawMessage, error) { + var object map[string]json.RawMessage + if err := json.Unmarshal(raw, &object); err != nil { + return nil, errors.New("invalid release row for knowledge base remap") + } + encoded, ok := object[knowledgeBaseField] + if !ok { + return nil, errors.New("release row is missing its knowledge base identity") + } + var current string + if err := json.Unmarshal(encoded, ¤t); err != nil || current != source { + return nil, errors.New("release row is outside the remapped knowledge base") + } + targetEncoded, err := json.Marshal(target) + if err != nil { + return nil, errors.New("encode remapped knowledge base") + } + object[knowledgeBaseField] = targetEncoded + payload, err := json.Marshal(object) + if err != nil { + return nil, errors.New("encode remapped release row") + } + return json.RawMessage(payload), nil +} + +func replaceTable(snapshot PostgresSnapshot, replacement TableSnapshot) PostgresSnapshot { + tables := make([]TableSnapshot, 0, len(snapshot.Tables)) + replaced := false + for _, table := range snapshot.Tables { + if table.Name == replacement.Name { + tables = append(tables, replacement) + replaced = true + continue + } + tables = append(tables, table) + } + if !replaced { + tables = append(tables, replacement) + } + return PostgresSnapshot{Tables: tables} +} diff --git a/internal/releaseadapter/v071/remap_test.go b/internal/releaseadapter/v071/remap_test.go new file mode 100644 index 0000000..137486c --- /dev/null +++ b/internal/releaseadapter/v071/remap_test.go @@ -0,0 +1,175 @@ +package v071 + +import ( + "encoding/json" + "strings" + "testing" + + releaseworkflow "knowledge-governance/internal/workflows/release" +) + +// crossBaseBundle is an export fixture whose rows and documents carry the base +// identity the way a real v0.7.1 export does (to_jsonb of every column), which +// is what a 源库 → 目标库 remap has to rewrite. +func crossBaseBundle(t *testing.T) *DocumentBundle { + t.Helper() + pg := PostgresSnapshot{Tables: []TableSnapshot{ + newTableSnapshot("knowledges", mustCanonicalRows(t, `{"id":"knowledge-safe","knowledge_base_id":"kb-safe","title":"synthetic release marker"}`)), + newTableSnapshot("chunks", mustCanonicalRows(t, + `{"id":"chunk-safe","knowledge_base_id":"kb-safe","embedding":[0.5,-1,1024]}`, + `{"id":"chunk-two","knowledge_base_id":"kb-safe","embedding":[0.25]}`)), + newTableSnapshot("knowledge_tags", mustCanonicalRows(t, `{"id":"tag-safe","knowledge_base_id":"kb-safe"}`)), + newTableSnapshot("wiki_pages", mustCanonicalRows(t, `{"id":"page-safe","knowledge_base_id":"kb-safe"}`)), + newTableSnapshot("wiki_folders", mustCanonicalRows(t, `{"id":"folder-safe","knowledge_base_id":"kb-safe"}`)), + newTableSnapshot("resources", mustCanonicalRows(t, `{"id":"res-safe","physical_path":"storage://backend/oss://bucket/weknora/10000/knowledge-safe/asset"}`)), + }} + search := SearchSnapshot{Documents: []SearchDocument{{ + ID: "es-safe", + Source: mustCanonicalRows(t, `{"tenant_id":10000,"knowledge_base_id":"kb-safe","knowledge_id":"knowledge-safe","embedding":[0.5,-1,1024]}`)[0], + }}} + search.DocumentsSHA256, _ = searchDocumentsDigest(search.Documents) + search.MappingSHA256 = strings.Repeat("a", 64) + exporter, err := NewDocumentExporter(DocumentExporterConfig{ + Contract: &stubSourceContract{contract: sourceContractFixture(search.MappingSHA256)}, + Postgres: &stubPGExporter{snapshot: pg}, Search: &stubSearchExporter{snapshot: search}, + Objects: &stubObjectExporter{snapshot: ObjectSnapshot{SHA256: objectSnapshotDigest(nil)}}, + TenantID: 10000, EmbeddingModel: "bge-m3", Now: fixedArchiveTime, + }) + if err != nil { + t.Fatal(err) + } + value, err := exporter.Export(t.Context(), releasePlanForArchive()) + if err != nil { + t.Fatal(err) + } + return value.(*DocumentBundle) +} + +func rowField(t *testing.T, row json.RawMessage, field string) string { + t.Helper() + var object map[string]json.RawMessage + if err := json.Unmarshal(row, &object); err != nil { + t.Fatal(err) + } + var value string + if err := json.Unmarshal(object[field], &value); err != nil { + t.Fatal(err) + } + return value +} + +// The whole point of the production binding: a knowledge verified in one base +// can be written into a DIFFERENT one, with every row and document moved over +// and the bundle re-sealed so the fail-closed checks still hold. +func TestRemapKnowledgeBaseRewritesAllChannelsAndReseals(t *testing.T) { + bundle := crossBaseBundle(t) + sourceBundleSHA := bundle.Manifest.BundleSHA256 + chunkBefore := string(bundle.Postgres.Table("chunks").Rows[0]) + resourceBefore := string(bundle.Postgres.Table("resources").Rows[0]) + + if err := bundle.RemapKnowledgeBase("prod-kb"); err != nil { + t.Fatalf("remap: %v", err) + } + // The manifest keeps the SOURCE base: that is where the content came from and + // what the release item's tested base is checked against. + if got := bundle.Manifest.Contract.KnowledgeBaseID; got != "kb-safe" { + t.Fatalf("manifest base = %q, want the source base", got) + } + for _, name := range remappableTables { + table := bundle.Postgres.Table(name) + if len(table.Rows) == 0 { + t.Fatalf("table %s lost its rows", name) + } + for _, row := range table.Rows { + if got := rowField(t, row, knowledgeBaseField); got != "prod-kb" { + t.Fatalf("%s row base = %q, want prod-kb", name, got) + } + if strings.Contains(string(row), "kb-safe") { + t.Fatalf("%s row still mentions the source base: %s", name, row) + } + } + } + // Tables without a base column are carried through untouched. + if got := string(bundle.Postgres.Table("resources").Rows[0]); got != resourceBefore { + t.Fatalf("resources row changed: %s", got) + } + // Embeddings must survive byte-for-byte: only the identity field is rewritten. + var before, after map[string]json.RawMessage + if err := json.Unmarshal([]byte(chunkBefore), &before); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(bundle.Postgres.Table("chunks").Rows[0], &after); err != nil { + t.Fatal(err) + } + if string(before["embedding"]) != string(after["embedding"]) { + t.Fatalf("embedding payload was re-encoded: %s -> %s", before["embedding"], after["embedding"]) + } + document := bundle.Search.Documents[0] + if got := rowField(t, document.Source, knowledgeBaseField); got != "prod-kb" { + t.Fatalf("document base = %q, want prod-kb", got) + } + // Every digest the target verifies against is recomputed and re-sealed. + if err := validateDocumentBundle(bundle); err != nil { + t.Fatalf("remapped bundle must stay self-consistent: %v", err) + } + recomputed, err := searchDocumentsDigest(bundle.Search.Documents) + if err != nil || recomputed != bundle.Search.DocumentsSHA256 || recomputed != bundle.Manifest.Search.SHA256 { + t.Fatalf("search digest not coherent: %v %s %s", err, recomputed, bundle.Manifest.Search.SHA256) + } + if bundle.Manifest.BundleSHA256 == sourceBundleSHA { + t.Fatal("bundle digest must change when the content identity changes") + } +} + +// A same-base release (the historical semantics) must not touch the bundle at all. +func TestRemapKnowledgeBaseIsNoOpForSameBase(t *testing.T) { + bundle := crossBaseBundle(t) + before := bundle.Manifest.BundleSHA256 + if err := bundle.RemapKnowledgeBase("kb-safe"); err != nil { + t.Fatal(err) + } + if bundle.Manifest.BundleSHA256 != before { + t.Fatalf("same-base remap changed the bundle digest") + } +} + +func TestRemapKnowledgeBaseFailsClosed(t *testing.T) { + tests := []struct { + name string + mutate func(*DocumentBundle) + target string + }{ + {name: "invalid target", target: "prod/kb", mutate: func(*DocumentBundle) {}}, + {name: "empty target", target: "", mutate: func(*DocumentBundle) {}}, + {name: "row outside the source base", target: "prod-kb", mutate: func(bundle *DocumentBundle) { + bundle.Postgres = replaceTable(bundle.Postgres, newTableSnapshot("chunks", mustCanonicalRows(t, `{"id":"chunk-safe","knowledge_base_id":"other-kb"}`))) + }}, + {name: "row without an identity", target: "prod-kb", mutate: func(bundle *DocumentBundle) { + bundle.Postgres = replaceTable(bundle.Postgres, newTableSnapshot("knowledges", mustCanonicalRows(t, `{"id":"knowledge-safe"}`))) + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bundle := crossBaseBundle(t) + tt.mutate(bundle) + if err := bundle.RemapKnowledgeBase(tt.target); err == nil { + t.Fatal("RemapKnowledgeBase() error=nil") + } + }) + } + if err := (*DocumentBundle)(nil).RemapKnowledgeBase("prod-kb"); err == nil { + t.Fatal("nil bundle must be rejected") + } +} + +// TargetBase is what every target-side write keys on. +func TestPlanItemTargetBaseFallsBackToTestedBase(t *testing.T) { + mapped := releaseworkflow.PlanItem{TestKnowledgeBaseID: "kb-safe", TargetKnowledgeBaseID: "prod-kb"} + if mapped.TargetBase() != "prod-kb" { + t.Fatalf("mapped TargetBase = %q", mapped.TargetBase()) + } + unbound := releaseworkflow.PlanItem{TestKnowledgeBaseID: "kb-safe"} + if unbound.TargetBase() != "kb-safe" { + t.Fatalf("unbound TargetBase = %q, want the tested base", unbound.TargetBase()) + } +} diff --git a/internal/releaseadapter/v071/verifier.go b/internal/releaseadapter/v071/verifier.go index d1017f2..be5122d 100644 --- a/internal/releaseadapter/v071/verifier.go +++ b/internal/releaseadapter/v071/verifier.go @@ -34,7 +34,7 @@ func (verifier *WeKnoraVerifier) Verify(ctx context.Context, item releaseworkflo return errors.New("invalid WeKnora verification bundle") } knowledge, err := verifier.client.GetKnowledge(ctx, item.KnowledgeID) - if err != nil || knowledge.ID != item.KnowledgeID || knowledge.KnowledgeBaseID != item.TestKnowledgeBaseID || knowledge.ParseStatus != "completed" { + if err != nil || knowledge.ID != item.KnowledgeID || knowledge.KnowledgeBaseID != item.TargetBase() || knowledge.ParseStatus != "completed" { return errors.New("production WeKnora knowledge verification failed") } _, chunkTotal, err := verifier.client.ListChunks(ctx, item.KnowledgeID, 1, 1, bundleChunkTypes(bundle)) @@ -167,7 +167,7 @@ func (verifier *WeKnoraVerifier) VerifyWiki(ctx context.Context, item releasewor seen := map[string]bool{} scanned := 0 for page := 1; ; page++ { - pages, total, err := verifier.client.ListWikiPages(ctx, item.TestKnowledgeBaseID, page, pageSize) + pages, total, err := verifier.client.ListWikiPages(ctx, item.TargetBase(), page, pageSize) if err != nil { return errors.New("production WeKnora wiki verification failed") } diff --git a/internal/releasebundle/manifest.go b/internal/releasebundle/manifest.go index bd43f46..5ceb474 100644 --- a/internal/releasebundle/manifest.go +++ b/internal/releasebundle/manifest.go @@ -99,7 +99,27 @@ func Seal(manifest Manifest) (Manifest, error) { return manifest, nil } -func Validate(manifest Manifest, target TargetContract) error { +// KnowledgeBaseMapping is the declared 源库 → 目标库 pair for a release item. +// An empty TargetKnowledgeBaseID means the historical same-id semantics: the +// production base must carry exactly the tested base's id. +type KnowledgeBaseMapping struct { + SourceKnowledgeBaseID string + TargetKnowledgeBaseID string +} + +// EffectiveTarget is the base the release writes into. +func (mapping KnowledgeBaseMapping) EffectiveTarget() string { + if trimmed := strings.TrimSpace(mapping.TargetKnowledgeBaseID); trimmed != "" { + return trimmed + } + return mapping.SourceKnowledgeBaseID +} + +// Validate proves the bundle contract against the target data plane. The only +// thing a cross-base release relaxes is the base identity itself: the bundle +// keeps the SOURCE base and the target must match the DECLARED mapping, so an +// undeclared (or stale) mapping still fails closed. +func Validate(manifest Manifest, target TargetContract, mapping KnowledgeBaseMapping) error { if err := validateManifestFields(manifest); err != nil { return err } @@ -114,9 +134,15 @@ func Validate(manifest Manifest, target TargetContract) error { return errors.New("bundle digest mismatch") } contract := manifest.Contract + if mapping.SourceKnowledgeBaseID != contract.KnowledgeBaseID { + return errors.New("release mapping does not match bundle") + } + if target.KnowledgeBaseID != mapping.EffectiveTarget() { + return errors.New("target release contract mismatch") + } if target.WeKnoraVersion != WeKnoraVersionV071 || target.MigrationVersion != WeKnoraMigrationV071 || contract.WeKnoraVersion != target.WeKnoraVersion || contract.MigrationVersion != target.MigrationVersion || - contract.TenantID != target.TenantID || contract.KnowledgeBaseID != target.KnowledgeBaseID || + contract.TenantID != target.TenantID || contract.RetrieverEngine != target.RetrieverEngine || contract.EmbeddingModel != target.EmbeddingModel || contract.EmbeddingDimension != target.EmbeddingDimension || contract.ElasticsearchMappingSHA256 != target.ElasticsearchMappingSHA256 { diff --git a/internal/releasebundle/manifest_test.go b/internal/releasebundle/manifest_test.go index 0c85b64..892b765 100644 --- a/internal/releasebundle/manifest_test.go +++ b/internal/releasebundle/manifest_test.go @@ -40,7 +40,7 @@ func TestSealAndValidateV071Manifest(t *testing.T) { EmbeddingModel: "bge-m3", EmbeddingDimension: 1024, ElasticsearchMappingSHA256: strings.Repeat("a", 64), } - if err := Validate(sealed, target); err != nil { + if err := Validate(sealed, target, sameIDMapping()); err != nil { t.Fatal(err) } encoded, err := json.Marshal(sealed) @@ -96,7 +96,7 @@ func TestValidateRejectsContractDriftAndUnknownTables(t *testing.T) { candidate, contract := manifest, target candidate.Tables = append([]TableDigest(nil), manifest.Tables...) tt.mutate(&candidate, &contract) - if err := Validate(candidate, contract); err == nil { + if err := Validate(candidate, contract, sameIDMapping()); err == nil { t.Fatal("Validate() error=nil") } }) @@ -117,3 +117,51 @@ func validManifest() Manifest { func validTarget() TargetContract { return TargetContract{WeKnoraVersion: "v0.7.1", MigrationVersion: 74, TenantID: 10000, KnowledgeBaseID: "kb-safe", RetrieverEngine: "elasticsearch_v8", EmbeddingModel: "bge-m3", EmbeddingDimension: 1024, ElasticsearchMappingSHA256: strings.Repeat("a", 64)} } + +// sameIDMapping is the historical semantics: no declared target, so the +// production base must carry the tested base's id. +func sameIDMapping() KnowledgeBaseMapping { + return KnowledgeBaseMapping{SourceKnowledgeBaseID: "kb-safe"} +} + +// A declared 源库 → 目标库 mapping lets the bundle land in a DIFFERENT base, +// which is the whole point of the production binding setting. +func TestValidateAcceptsDeclaredCrossBaseMapping(t *testing.T) { + manifest, err := Seal(validManifest()) + if err != nil { + t.Fatal(err) + } + target := validTarget() + target.KnowledgeBaseID = "prod-kb" + mapping := KnowledgeBaseMapping{SourceKnowledgeBaseID: "kb-safe", TargetKnowledgeBaseID: "prod-kb"} + if err := Validate(manifest, target, mapping); err != nil { + t.Fatalf("declared cross-base release must validate: %v", err) + } +} + +// An undeclared, stale or self-inconsistent mapping must fail closed: neither +// "target drifted somewhere else" nor "mapping points at the source" passes. +func TestValidateRejectsUndeclaredOrDriftedMapping(t *testing.T) { + manifest, err := Seal(validManifest()) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + mapping KnowledgeBaseMapping + target string + }{ + {name: "mapping source does not match bundle", mapping: KnowledgeBaseMapping{SourceKnowledgeBaseID: "other"}, target: "other"}, + {name: "target differs from declared mapping", mapping: KnowledgeBaseMapping{SourceKnowledgeBaseID: "kb-safe", TargetKnowledgeBaseID: "prod-kb"}, target: "another-kb"}, + {name: "mapping target differs while target keeps source id", mapping: KnowledgeBaseMapping{SourceKnowledgeBaseID: "kb-safe", TargetKnowledgeBaseID: "prod-kb"}, target: "kb-safe"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target := validTarget() + target.KnowledgeBaseID = tt.target + if err := Validate(manifest, target, tt.mapping); err == nil { + t.Fatal("Validate() error=nil") + } + }) + } +} diff --git a/internal/serviceverify/production_bases.go b/internal/serviceverify/production_bases.go new file mode 100644 index 0000000..b0f770d --- /dev/null +++ b/internal/serviceverify/production_bases.go @@ -0,0 +1,29 @@ +package serviceverify + +import ( + "context" + "errors" + + "knowledge-governance/internal/platform/weknora" +) + +// ListProductionKnowledgeBases enumerates the bases a release could be written +// into. It asks the operator-managed production endpoint with the production +// credential file, so the settings page can offer real bases instead of asking +// the operator to paste an id. A missing endpoint or credential is an error the +// caller reports honestly rather than a silently empty picker. +func (verifier *Verifier) ListProductionKnowledgeBases(ctx context.Context, baseURL string) ([]weknora.KnowledgeBase, error) { + resolved := firstNonEmpty(baseURL, verifier.config.ProductionBaseURLFallback) + if resolved == "" { + return nil, errors.New("production WeKnora endpoint is not configured") + } + apiKey := verifier.apiKey(verifier.config.ProductionAPIKeyFile) + if apiKey == "" { + return nil, errors.New("production WeKnora API key file is not available") + } + client, err := verifier.client(resolved, apiKey, verifier.config.ProductionAllowDockerHTTP) + if err != nil { + return nil, err + } + return client.ListKnowledgeBases(ctx) +} diff --git a/internal/workflows/release/handler.go b/internal/workflows/release/handler.go index 6db264c..befe200 100644 --- a/internal/workflows/release/handler.go +++ b/internal/workflows/release/handler.go @@ -37,14 +37,18 @@ var ( ) type PlanItem struct { - ReleaseExecutionID string `json:"release_execution_id"` - PublicationID string `json:"publication_id"` - Action string `json:"action"` - SourceItemVersionID string `json:"source_item_version_id"` - SourceRevision string `json:"source_revision"` - ParseRevision string `json:"parse_revision"` - ReleaseRevision string `json:"release_revision"` - TestKnowledgeBaseID string `json:"test_knowledge_base_id"` + ReleaseExecutionID string `json:"release_execution_id"` + PublicationID string `json:"publication_id"` + Action string `json:"action"` + SourceItemVersionID string `json:"source_item_version_id"` + SourceRevision string `json:"source_revision"` + ParseRevision string `json:"parse_revision"` + ReleaseRevision string `json:"release_revision"` + TestKnowledgeBaseID string `json:"test_knowledge_base_id"` + // TargetKnowledgeBaseID is the 源库 → 目标库 mapping resolved when the + // execution started (see production_kb_bindings). It equals the tested base + // when the scope is unbound, which is the historical same-id semantics. + TargetKnowledgeBaseID string `json:"target_knowledge_base_id"` KnowledgeID string `json:"knowledge_id"` DecidedVisibility governance.Visibility `json:"decided_visibility"` PreviousProductionRevision string `json:"previous_production_revision"` @@ -56,6 +60,16 @@ type ItemResult struct { ProductionRevision string } +// TargetBase is the knowledge base this item is written to: the resolved +// 源库 → 目标库 mapping when the release carries one, otherwise the tested base +// itself (the historical same-id semantics). +func (item PlanItem) TargetBase() string { + if trimmed := strings.TrimSpace(item.TargetKnowledgeBaseID); trimmed != "" { + return trimmed + } + return item.TestKnowledgeBaseID +} + type StartExecutionRequest struct { ExecutionID string JobID string diff --git a/internal/workflows/release/sidecar_publisher.go b/internal/workflows/release/sidecar_publisher.go index d701204..2a66368 100644 --- a/internal/workflows/release/sidecar_publisher.go +++ b/internal/workflows/release/sidecar_publisher.go @@ -43,6 +43,15 @@ func NewSidecarPublisher(exporter BundleExporter, target SidecarTarget) (*Sideca return &SidecarPublisher{exporter: exporter, target: target}, nil } +// releaseItemMapping declares which production base this item must land in: the +// tested base it came from, and the 源库 → 目标库 target the execution resolved. +func releaseItemMapping(item PlanItem) releasebundle.KnowledgeBaseMapping { + return releasebundle.KnowledgeBaseMapping{ + SourceKnowledgeBaseID: item.TestKnowledgeBaseID, + TargetKnowledgeBaseID: item.TargetKnowledgeBaseID, + } +} + func (publisher *SidecarPublisher) Publish(ctx context.Context, item PlanItem) (ItemResult, error) { bundle, err := publisher.exporter.Export(ctx, item) if err != nil { @@ -62,7 +71,7 @@ func (publisher *SidecarPublisher) Publish(ctx context.Context, item PlanItem) ( if err != nil { return ItemResult{}, newSidecarError(ErrorCodeTargetUnavailable, true, err) } - if err := releasebundle.Validate(manifest, targetContract); err != nil { + if err := releasebundle.Validate(manifest, targetContract, releaseItemMapping(item)); err != nil { return ItemResult{}, newSidecarError(ErrorCodeContractMismatch, false, err) } change, err := publisher.target.Prepare(ctx, item, bundle) @@ -121,7 +130,7 @@ func (publisher *SidecarPublisher) Delete(ctx context.Context, item PlanItem) (I if err != nil { return ItemResult{}, newSidecarError(ErrorCodeTargetUnavailable, true, err) } - if err := releasebundle.Validate(manifest, targetContract); err != nil { + if err := releasebundle.Validate(manifest, targetContract, releaseItemMapping(item)); err != nil { return ItemResult{}, newSidecarError(ErrorCodeContractMismatch, false, err) } change, err := publisher.target.Prepare(ctx, item, bundle) diff --git a/scripts/deploy-api-web.sh b/scripts/deploy-api-web.sh index ccf0d85..a607bf9 100644 --- a/scripts/deploy-api-web.sh +++ b/scripts/deploy-api-web.sh @@ -16,7 +16,9 @@ echo "=== 3. 构建 web" docker build --network host "${PROXY_ARGS[@]}" -f Dockerfile.web -t knowledge-governance-p1-web:latest . || { echo "WEB_BUILD_FAILED"; exit 1; } echo "=== 4. 上线 api + web(不动依赖)" -docker compose up -d --no-build --no-deps api web || { echo "UP_FAILED"; exit 1; } +# COMPOSE_FILES 让调用方叠加发布接线 / 门禁 override,默认只有基础 compose.yaml。 +# 例:COMPOSE_FILES="-f compose.yaml -f compose.release-sim.yaml" bash scripts/deploy-api-web.sh +docker compose ${COMPOSE_FILES:--f compose.yaml} up -d --no-build --no-deps api web || { echo "UP_FAILED"; exit 1; } echo "=== 5. 状态" docker ps --filter name=knowledge-governance-p1 --format '{{.Names}} {{.Status}} {{.Image}}' diff --git a/web/src/ProductionBindingsSection.tsx b/web/src/ProductionBindingsSection.tsx new file mode 100644 index 0000000..3d02a66 --- /dev/null +++ b/web/src/ProductionBindingsSection.tsx @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useState } from 'react' + +import { useOptionalAuth } from './auth' +import { fetchProductionBindingOptions, fetchProductionBindings, saveProductionBindings } from './productionBindings' +import type { ProductionBindings } from './productionBindings' +import type { KnowledgeBaseOption } from './routingBindings' + +// 「生产知识库绑定」:发布链路的 源库 → 目标库 映射。这里选的是发布要写入的 +// 生产(目标)知识库,与上面的「知识库绑定」(测试端收件库)是两件事。 +export function ProductionBindingsSection() { + const auth = useOptionalAuth() + const isAdmin = auth?.identity?.role === 'admin' + const [bindings, setBindings] = useState(null) + const [options, setOptions] = useState([]) + const [internalDraft, setInternalDraft] = useState('') + const [externalDraft, setExternalDraft] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [message, setMessage] = useState('') + + const load = useCallback(async () => { + setLoading(true); setError(null) + try { + const next = await fetchProductionBindings() + setBindings(next) + setInternalDraft(next.internal_kb_id) + setExternalDraft(next.external_kb_id) + try { setOptions(await fetchProductionBindingOptions()) } catch { setOptions([]) } + } catch (cause) { setError(cause instanceof Error ? cause.message : '读取生产知识库绑定失败') } + finally { setLoading(false) } + }, []) + useEffect(() => { void load() }, [load]) + + const dirty = bindings !== null && (internalDraft !== bindings.internal_kb_id || externalDraft !== bindings.external_kb_id) + const sameSelection = internalDraft !== '' && internalDraft === externalDraft + + const save = async () => { + setSaving(true); setError(null); setMessage('') + try { + const next = await saveProductionBindings({ internal_kb_id: internalDraft, external_kb_id: externalDraft }) + setBindings(next) + const where = `对内 ${next.internal_kb_name || next.internal_kb_id.slice(0, 8) || '未绑定'} · 对外 ${next.external_kb_name || next.external_kb_id.slice(0, 8) || '未绑定'}` + setMessage(next.production_connected + ? `发布目标已更新:${where}。之后新发布的“对内/对外”内容会写入这两个生产库(已发布过的条目仍回到原目标)。` + : `发布目标已保存:${where};但当前核对不了生产端(${next.production_message}),下次发布前请确认。`) + } catch (cause) { setError(cause instanceof Error ? cause.message : '保存生产知识库绑定失败') } + finally { setSaving(false) } + } + + const optionLabel = (base: KnowledgeBaseOption) => `${base.name}(${base.knowledge_count} 文档)` + const renderSide = (label: string, boundID: string, boundName: string) => ( +
+ {label} + {boundID ? (boundName || `${boundID.slice(0, 8)}…`) : '未绑定 · 按同 ID 语义发布(目标端须存在同 ID 库)'} + {boundID ? 'UI 绑定 · 发布写入此库' : '未绑定:沿用测试库 ID'} +
+ ) + const editorFor = (value: string, onChange: (next: string) => void, placeholder: string) => ( + options.length > 0 + ? + : onChange(event.target.value.trim())} placeholder={`${placeholder}(输入生产知识库 ID)`} aria-label={placeholder} /> + ) + + return
+

生产知识库绑定

发布链路的“源库 → 目标库”:新发布的对内 / 对外内容按这里的绑定写入生产 WeKnora 的对应知识库。留空表示未绑定,按同 ID 语义发布。

+ {error ?

{error}

: null} + {bindings ?
+ {renderSide('对内生产知识库', bindings.internal_kb_id, bindings.internal_kb_name)} + {renderSide('对外生产知识库', bindings.external_kb_id, bindings.external_kb_name)} +
: null} + {bindings && !bindings.production_connected ?

生产端核对不可用:{bindings.production_message}。{bindings.production_base_url ? `(生产地址:${bindings.production_base_url})` : ''}

: null} + {bindings && bindings.production_connected && bindings.production_base_url ?

生产端:{bindings.production_base_url}{bindings.updated_by ? ` · 最近修改:${bindings.updated_by}` : ''}

: null} + {isAdmin && bindings ?
+ + +

保存时会向生产端核对所选的库是否真实存在;对不上会拒收,生产端不可达则只提示。

+ {sameSelection ?

对内与对外必须绑定不同的生产知识库

: null} + + {message ?

{message}

: null} +
: null} + {!isAdmin ?

仅管理员可修改发布目标。

: null} +
+} \ No newline at end of file diff --git a/web/src/RoutingBindingsSection.tsx b/web/src/RoutingBindingsSection.tsx index 961eb17..1dc8252 100644 --- a/web/src/RoutingBindingsSection.tsx +++ b/web/src/RoutingBindingsSection.tsx @@ -37,7 +37,10 @@ export function RoutingBindingsSection() { try { const next = await saveRoutingBindings({ internal_kb_id: internalDraft, external_kb_id: externalDraft }) setBindings(next) - setMessage(`绑定已生效:对内 ${next.internal_kb_name || next.internal_kb_id.slice(0, 8) || '未绑定'} · 对外 ${next.external_kb_name || next.external_kb_id.slice(0, 8) || '未绑定'}。此后“来源与目录”的标记按新绑定路由。`) + const replan = next.replanned || next.retired_duplicates + ? `;同时把 ${next.replanned} 项还没上传的待入库重新指向新库${next.retired_duplicates ? `,${next.retired_duplicates} 项因新库里已有同篇而被退休` : ''}` + : '' + setMessage(`绑定已生效:对内 ${next.internal_kb_name || next.internal_kb_id.slice(0, 8) || '未绑定'} · 对外 ${next.external_kb_name || next.external_kb_id.slice(0, 8) || '未绑定'}。此后“来源与目录”的标记按新绑定路由${replan}。`) } catch (cause) { setError(cause instanceof Error ? cause.message : '保存知识库绑定失败') } finally { setSaving(false) } } diff --git a/web/src/SystemSettings.tsx b/web/src/SystemSettings.tsx index f53aedf..07b0bc0 100644 --- a/web/src/SystemSettings.tsx +++ b/web/src/SystemSettings.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { MembersSection } from './MembersSection' import { DingtalkSessionControl } from './DingtalkSessionControl' import { RoutingBindingsSection } from './RoutingBindingsSection' +import { ProductionBindingsSection } from './ProductionBindingsSection' import { DingtalkSpaceRegistrar } from './DingtalkSpaceRegistrar' import { FilenameFilterEditor } from './FilenameFilterEditor' import { useOptionalAuth } from './auth' @@ -35,12 +36,13 @@ interface Props { initialSection?: SectionKey } -type SectionKey = 'sources' | 'services' | 'bindings' | 'storage' | 'refresh' | 'members' +type SectionKey = 'sources' | 'services' | 'bindings' | 'production-bindings' | 'storage' | 'refresh' | 'members' const sections: { key: SectionKey; label: string; adminOnly?: boolean }[] = [ { key: 'sources', label: '数据源配置' }, { key: 'services', label: '服务配置' }, { key: 'bindings', label: '知识库绑定' }, + { key: 'production-bindings', label: '生产知识库绑定' }, { key: 'storage', label: '存储与备份' }, { key: 'refresh', label: '刷新策略' }, { key: 'members', label: '成员与角色', adminOnly: true }, @@ -261,6 +263,7 @@ export function SystemSettingsView({ sources, connections, monitor, settings, pr : 服务配置仅管理员可修改;当前值:测试地址 {settings.test_weknora_base_url || '未配置'} · 生产地址 {settings.production_weknora_base_url || '未配置'} · 生产 ID {settings.production_tenant_id || '未配置'} · 知识库 ID {settings.production_knowledge_base_id || '未配置'}。} : null} {section === 'bindings' ? : null} + {section === 'production-bindings' ? : null} {section === 'storage' ?

存储与备份

文件资源与生产发布前快照使用独立受限配置。

Endpoint / Bucket
尚未配置
AccessKey / Secret
仅保存凭据引用,不可回读
CDN / 公开访问域名
尚未配置
临时签名有效期
待配置
测试备份前缀
/backup/weknora/test/
生产备份前缀
/backup/weknora/prod/
快照保留
最近 30 个生产快照
恢复验证
由生产门禁读回
  • PostgreSQL 未配置
  • Elasticsearch 未配置
  • 对象存储 未配置
  • Neo4j 未配置
  • 生产发布请求 {productionEnabled ? '等待管理员核对' : '关闭'}
  • Release Worker 网页不可启用
diff --git a/web/src/productionBindings.test.ts b/web/src/productionBindings.test.ts new file mode 100644 index 0000000..4c1ffb2 --- /dev/null +++ b/web/src/productionBindings.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' + +import { fetchProductionBindingOptions, fetchProductionBindings, saveProductionBindings } from './productionBindings' + +const binding = { + internal_kb_id: '0b217e8a-2a65-40a3-ab8f-34ac5c7a6f05', external_kb_id: '', + internal_kb_name: '对内知识库-wiki', external_kb_name: '', updated_by: 'admin', + production_base_url: 'https://weknora-test.dobot.cn/', production_connected: true, production_message: '', +} + +describe('production bindings API', () => { + it('reads the 源库 → 目标库 projection without leaking extra fields', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { ...binding, api_key: 'drop' } }), { status: 200 }))) + const result = await fetchProductionBindings() + expect(result.internal_kb_name).toBe('对内知识库-wiki') + expect(result.production_connected).toBe(true) + expect(result).not.toHaveProperty('api_key') + vi.unstubAllGlobals() + }) + it('lists production knowledge base options for the picker', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'kb-1', name: '对内知识库-wiki', knowledge_count: 2069 }, { id: '', name: '坏行' }] }), { status: 200 }))) + expect(await fetchProductionBindingOptions()).toEqual([{ id: 'kb-1', name: '对内知识库-wiki', knowledge_count: 2069 }]) + vi.unstubAllGlobals() + }) + it('saves with an idempotency key and surfaces a rejected base', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ data: binding }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ error: '对内绑定的生产知识库在该 WeKnora 实例中不存在' }), { status: 400 })) + vi.stubGlobal('fetch', fetchMock) + await expect(saveProductionBindings({ internal_kb_id: 'kb-1', external_kb_id: '' })).resolves.toMatchObject({ internal_kb_name: '对内知识库-wiki' }) + const [, init] = fetchMock.mock.calls[0] + expect(init.method).toBe('PUT') + expect(init.headers['Idempotency-Key']).toBeTruthy() + await expect(saveProductionBindings({ internal_kb_id: 'kb-ghost', external_kb_id: '' })).rejects.toThrow('不存在') + vi.unstubAllGlobals() + }) + it('reports an unverified save honestly instead of pretending it was checked', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { ...binding, production_connected: false, production_message: '生产 WeKnora 不可用:dial tcp: timeout' } }), { status: 200 }))) + const result = await saveProductionBindings({ internal_kb_id: 'kb-1', external_kb_id: '' }) + expect(result.production_connected).toBe(false) + expect(result.production_message).toContain('不可用') + vi.unstubAllGlobals() + }) +}) \ No newline at end of file diff --git a/web/src/productionBindings.ts b/web/src/productionBindings.ts new file mode 100644 index 0000000..5702fa6 --- /dev/null +++ b/web/src/productionBindings.ts @@ -0,0 +1,65 @@ +import { newOperationID } from './operationKey' +import type { KnowledgeBaseOption } from './routingBindings' + +// 源库 → 目标库(生产)映射:系统设置里选择发布要写入的生产知识库。 +export interface ProductionBindings { + internal_kb_id: string + external_kb_id: string + internal_kb_name: string + external_kb_name: string + updated_by: string + production_base_url: string + production_connected: boolean + production_message: string +} + +const record = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +function projection(item: Record): ProductionBindings { + return { + internal_kb_id: typeof item.internal_kb_id === 'string' ? item.internal_kb_id : '', + external_kb_id: typeof item.external_kb_id === 'string' ? item.external_kb_id : '', + internal_kb_name: typeof item.internal_kb_name === 'string' ? item.internal_kb_name : '', + external_kb_name: typeof item.external_kb_name === 'string' ? item.external_kb_name : '', + updated_by: typeof item.updated_by === 'string' ? item.updated_by : '', + production_base_url: typeof item.production_base_url === 'string' ? item.production_base_url : '', + production_connected: item.production_connected === true, + production_message: typeof item.production_message === 'string' ? item.production_message : '', + } +} + +export async function fetchProductionBindings(signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/production-kb-bindings', { signal, headers: { Accept: 'application/json' } }) + if (!response.ok) throw new Error(`读取生产知识库绑定失败(HTTP ${response.status})`) + const value = await response.json() as unknown + if (!record(value) || !record(value.data)) throw new Error('生产知识库绑定响应无效') + return projection(value.data) +} + +export async function fetchProductionBindingOptions(signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/production-kb-bindings/options', { signal, headers: { Accept: 'application/json' } }) + if (!response.ok) throw new Error(`读取生产知识库列表失败(HTTP ${response.status})`) + const value = await response.json() as unknown + if (!record(value) || !Array.isArray(value.data)) throw new Error('生产知识库列表响应无效') + return value.data.filter(record).map((item) => ({ + id: typeof item.id === 'string' ? item.id : '', + name: typeof item.name === 'string' ? item.name : '', + knowledge_count: typeof item.knowledge_count === 'number' ? item.knowledge_count : 0, + })).filter((item) => item.id) +} + +export async function saveProductionBindings(input: { internal_kb_id: string; external_kb_id: string }, signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/production-kb-bindings', { + method: 'PUT', signal, + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'Idempotency-Key': newOperationID() }, + body: JSON.stringify(input), + }) + if (!response.ok) { + let reason = `保存生产知识库绑定失败(HTTP ${response.status})` + try { const body = await response.json() as unknown; if (record(body) && typeof body.error === 'string') reason = body.error } catch { /* keep status text */ } + throw new Error(reason) + } + const value = await response.json() as unknown + if (!record(value) || !record(value.data)) throw new Error('生产知识库绑定响应无效') + return projection(value.data) +} \ No newline at end of file diff --git a/web/src/routingBindings.ts b/web/src/routingBindings.ts index 27da567..b360455 100644 --- a/web/src/routingBindings.ts +++ b/web/src/routingBindings.ts @@ -11,6 +11,9 @@ export interface RoutingBindings { external_kb_name: string updated_by: string weknora_connected: boolean + // 本次保存把多少条「还没上传」的待入库项重新指向了新库(读取时为 0)。 + replanned: number + retired_duplicates: number } export interface KnowledgeBaseOption { @@ -33,6 +36,8 @@ function bindingProjection(item: Record): RoutingBindings { external_kb_name: typeof item.external_kb_name === 'string' ? item.external_kb_name : '', updated_by: typeof item.updated_by === 'string' ? item.updated_by : '', weknora_connected: item.weknora_connected === true, + replanned: typeof item.replanned === 'number' ? item.replanned : 0, + retired_duplicates: typeof item.retired_duplicates === 'number' ? item.retired_duplicates : 0, } }