diff --git a/api/openapi.yaml b/api/openapi.yaml index 97cd4d6..a2fe499 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,3695 +1,3808 @@ { - "openapi": "3.1.0", - "info": { - "title": "Knowledge Governance API", - "version": "v1", - "description": "独立知识治理控制平面API。所有响应使用安全投影;不返回来源凭据、签名下载地址、原始内容、内部目标连接或生产密钥。" + "openapi": "3.1.0", + "info": { + "title": "Knowledge Governance API", + "version": "v1", + "description": "独立知识治理控制平面API。所有响应使用安全投影;不返回来源凭据、签名下载地址、原始内容、内部目标连接或生产密钥。" + }, + "servers": [ + { + "url": "/", + "description": "当前治理控制平面" + } + ], + "tags": [ + { + "name": "Health" }, - "servers": [ - { - "url": "/", - "description": "当前治理控制平面" - } - ], - "tags": [ - { - "name": "Health" - }, - { - "name": "Jobs" - }, - { - "name": "Source catalog" - }, - { - "name": "Monitoring" - }, - { - "name": "Scope" - }, - { - "name": "Source connections" - }, - { - "name": "Review" - }, - { - "name": "Release" - }, - { - "name": "Test processing" - }, - { - "name": "Settings" - } - ], - "paths": { - "/api/v1/backup-schedule": { - "get": { - "tags": [ - "Settings" - ], - "operationId": "getBackupSchedule", - "summary": "读取整库逻辑备份的定时计划(不探测主机)", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - } - } - }, - "put": { - "tags": [ - "Settings" - ], - "operationId": "saveBackupSchedule", - "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" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v1/backup-schedule/host": { - "get": { - "tags": [ - "Settings" - ], - "operationId": "getBackupScheduleHost", - "summary": "读取目标主机上生效的 cron 计划与最近一次备份结果", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - } - } - } - }, - "/api/v1/backup-schedule/apply": { - "post": { - "tags": [ - "Settings" - ], - "operationId": "applyBackupSchedule", - "summary": "把当前设置重新下发到目标主机(管理员)", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v1/backup-schedule/run": { - "post": { - "tags": [ - "Settings" - ], - "operationId": "runBackupNow", - "summary": "在目标主机上立即执行一次整库备份(管理员,异步)", - "responses": { - "202": { - "$ref": "#/components/responses/Envelope" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/health": { - "get": { - "tags": [ - "Health" - ], - "operationId": "getHealth", - "summary": "读取服务健康状态", - "responses": { - "200": { - "description": "服务可响应", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Health" - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/jobs": { - "get": { - "tags": [ - "Jobs" - ], - "operationId": "listJobs", - "summary": "列出最近治理任务", - "parameters": [ - { - "$ref": "#/components/parameters/Limit" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "post": { - "tags": [ - "Jobs" - ], - "operationId": "createJob", - "summary": "创建幂等后台任务", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JobCreateRequest" - } - } - } - }, - "responses": { - "201": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-items": { - "get": { - "tags": [ - "Source catalog" - ], - "operationId": "listSourceItems", - "summary": "列出安全来源文件投影", - "parameters": [ - { - "$ref": "#/components/parameters/State" - }, - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-catalog/sources": { - "get": { - "tags": [ - "Source catalog" - ], - "operationId": "listCatalogSources", - "summary": "列出当前可浏览来源快照", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-catalog/nodes": { - "get": { - "tags": [ - "Source catalog" - ], - "operationId": "listCatalogNodes", - "summary": "按父目录分页列出当前快照直接子项", - "parameters": [ - { - "$ref": "#/components/parameters/ConnectionID" - }, - { - "name": "parent_id", - "in": "query", - "required": true, - "schema": { - "type": "string", - "maxLength": 36 - } - }, - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-catalog/change-sets/latest": { - "get": { - "tags": [ - "Source catalog" - ], - "operationId": "getLatestCatalogChangeSet", - "summary": "读取某来源最新持久ChangeSet", - "parameters": [ - { - "$ref": "#/components/parameters/ConnectionID" - }, - { - "name": "action", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "add", - "update", - "move", - "move_scope", - "delete", - "no_change" - ] - } - }, - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-catalog/routing-capabilities": { - "get": { - "tags": [ - "Source catalog" - ], - "operationId": "getCatalogRoutingCapabilities", - "summary": "读取对内和对外测试路由是否已配置", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-catalog/route": { - "post": { - "tags": [ - "Source catalog" - ], - "operationId": "setCatalogPolicy", - "summary": "写入目录或文件的期望处理策略", - "description": "当前接口只修改治理策略和审计记录,不代表已经上传、解析或发布。调用方必须为每次用户动作生成新的 Idempotency-Key;网络重试复用原键会返回原结果且 operation=replayed,同键不同请求返回409。", - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CatalogRouteRequest" - } - } - } - }, - "responses": { - "202": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-catalog/sources/{id}/refresh": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "post": { - "tags": [ - "Source catalog" - ], - "operationId": "refreshCatalogSource", - "summary": "创建来源目录刷新任务", - "description": "调用方必须为每次用户动作生成新的 Idempotency-Key;网络重试复用原键会返回同一刷新任务且 operation=replayed。", - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "responses": { - "202": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/monitor/overview": { - "get": { - "tags": [ - "Monitoring" - ], - "operationId": "getMonitorOverview", - "summary": "读取主机、服务和知识库处理概览", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/monitor/knowledge-bases/{id}/files": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "get": { - "tags": [ - "Monitoring" - ], - "operationId": "listKnowledgeBaseFiles", - "summary": "按处理状态分页列出知识库文件", - "parameters": [ - { - "name": "status", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "pending", - "processing", - "finalizing", - "completed", - "failed", - "cancelled" - ] - } - }, - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/filename-filters": { - "get": { - "tags": ["Source connections"], - "operationId": "listFilenameFilters", - "summary": "列出来源和目录的文件扩展名筛选规则", - "parameters": [{"name":"connection_id","in":"query","required":true,"schema":{"type":"string"}}], - "responses": {"200":{"description":"筛选规则"},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"$ref":"#/components/responses/InternalError"}} - }, - "put": { - "tags": ["Source connections"], - "operationId": "setFilenameFilter", - "summary": "设置来源或目录文件扩展名筛选", - "parameters": [{"$ref":"#/components/parameters/IdempotencyKey"}], - "responses": {"200":{"description":"筛选规则已生效"},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/InternalError"}} - }, - "delete": { - "tags": ["Source connections"], - "operationId": "clearFilenameFilter", - "summary": "移除本级筛选并恢复继承", - "parameters": [{"$ref":"#/components/parameters/IdempotencyKey"}], - "responses": {"200":{"description":"本级筛选已移除"},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/InternalError"}} - } - }, - "/api/v1/scope-rules": { - "get": { - "tags": [ - "Scope" - ], - "operationId": "listScopeRules", - "summary": "读取来源范围规则", - "parameters": [ - { - "$ref": "#/components/parameters/ConnectionID" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "put": { - "tags": [ - "Scope" - ], - "operationId": "setScopeRule", - "summary": "创建或更新范围规则", - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScopeRuleMutation" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "delete": { - "tags": [ - "Scope" - ], - "operationId": "clearScopeRule", - "summary": "清除范围规则", - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ScopeRuleMutation" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/dingtalk-session": { - "post": { - "tags": [ - "Source connections" - ], - "operationId": "startDingtalkSession", - "summary": "发起钉钉扫码登录会话(管理员)", - "responses": { - "201": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/dingtalk-session/{id}": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "get": { - "tags": [ - "Source connections" - ], - "operationId": "getDingtalkSession", - "summary": "轮询钉钉扫码会话状态(管理员)", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "delete": { - "tags": [ - "Source connections" - ], - "operationId": "cancelDingtalkSession", - "summary": "取消钉钉扫码会话(管理员)", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/dingtalk-spaces": { - "post": { - "tags": [ - "Source connections" - ], - "operationId": "registerDingtalkSpace", - "summary": "解析并登记钉钉知识库空间(管理员)", - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "responses": { - "201": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-connections": { - "get": { - "tags": [ - "Source connections" - ], - "operationId": "listSourceConnections", - "summary": "列出安全来源连接投影", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-connections/{id}/scan": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "post": { - "tags": [ - "Source connections" - ], - "operationId": "triggerSourceScan", - "summary": "创建旧PP来源扫描任务", - "responses": { - "202": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "parameters": [ - { - "$ref": "#/components/parameters/ID" - }, - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ] - } - }, - "/api/v1/source-connections/{id}/scans": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "get": { - "tags": [ - "Source connections" - ], - "operationId": "listSourceScans", - "summary": "分页读取来源扫描记录", - "parameters": [ - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-connections/{id}/scans/{scan_id}": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - }, - { - "name": "scan_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "maxLength": 64 - } - } - ], - "get": { - "tags": [ - "Source connections" - ], - "operationId": "getSourceScan", - "summary": "读取单次扫描及其安全变更投影", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/review-candidates": { - "get": { - "tags": [ - "Review" - ], - "operationId": "listReviewCandidates", - "summary": "分页列出可审核测试知识", - "parameters": [ - { - "name": "review_state", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "pending", - "approved", - "rejected", - "not_required" - ] - } - }, - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/review-candidates/{id}/decision": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "post": { - "tags": [ - "Review" - ], - "operationId": "reviewCandidate", - "summary": "提交人工审核决定", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReviewDecisionRequest" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "parameters": [ - { - "$ref": "#/components/parameters/ID" - }, - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ] - } - }, - "/api/v1/release-plan": { - "get": { - "tags": [ - "Release" - ], - "operationId": "getReleasePlan", - "summary": "预览当前测试到生产的发布计划", - "description": "当前是实时预览,不是不可变Release ChangeSet;R1会替换为持久发布ChangeSet。", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/release-batches": { - "get": { - "tags": [ - "Release" - ], - "operationId": "listReleaseBatches", - "summary": "分页列出草稿或已封存发布批次", - "parameters": [ - { - "name": "state", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "draft", - "sealed", - "cancelled" - ] - } - }, - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "post": { - "tags": [ - "Release" - ], - "operationId": "createReleaseBatch", - "summary": "创建草稿发布批次", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReleaseBatchCreateRequest" - } - } - } - }, - "responses": { - "201": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ] - } - }, - "/api/v1/release-batches/{id}": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "get": { - "tags": [ - "Release" - ], - "operationId": "getReleaseBatch", - "summary": "读取发布批次详情", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/release-batches/{id}/seal": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "post": { - "tags": [ - "Release" - ], - "operationId": "sealReleaseBatch", - "summary": "封存草稿发布批次,不触发生产发布", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReleaseBatchSealRequest" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "parameters": [ - { - "$ref": "#/components/parameters/ID" - }, - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ] - } - }, - "/api/v1/release-batches/{id}/authorize-production": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "post": { - "tags": [ - "Release" - ], - "operationId": "authorizeProductionRelease", - "summary": "受门禁保护的生产发布授权(页面按钮或运维脚本)", - "description": "默认关闭。调用方不得提交审核人身份。两条通道:运维脚本用受限配置的 Bearer token(审计记固定授权人);页面按钮用管理员会话 + 固定确认语句(审计记该管理员)。请求门禁、备份恢复门禁与 Release Worker 执行门禁不变。 确认语句:publish-to-production。", - "responses": { - "202": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "parameters": [ - { - "$ref": "#/components/parameters/ID" - }, - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ] - } - }, - "/api/v1/release-executions": { - "get": { - "tags": [ - "Release" - ], - "operationId": "listReleaseExecutions", - "summary": "分页读取生产发布和回滚审计", - "parameters": [ - { - "name": "operation", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "publish", - "rollback" - ] - } - }, - { - "name": "state", - "in": "query", - "schema": { - "type": "string", - "enum": [ - "pending", - "running", - "rolling_back", - "completed", - "failed", - "rolled_back", - "rollback_failed" - ] - } - }, - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/release-executions/{id}": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "get": { - "tags": [ - "Release" - ], - "operationId": "getReleaseExecution", - "summary": "读取单次生产发布或回滚详情", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/release-executions/{id}/authorize-rollback": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "post": { - "tags": [ - "Release" - ], - "operationId": "authorizeProductionRollback", - "summary": "受门禁保护的生产回滚授权(页面按钮或运维脚本)", - "description": "两条通道:运维脚本用受限配置的 Bearer token(审计记固定授权人);页面按钮用管理员会话 + 固定确认语句(审计记该管理员)。请求门禁、备份恢复门禁与 Release Worker 执行门禁不变。 确认语句:rollback-production-release。", - "responses": { - "202": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "parameters": [ - { - "$ref": "#/components/parameters/ID" - }, - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ] - } - }, - "/api/v1/release-executions/{id}/retry": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "post": { - "tags": [ - "Release" - ], - "operationId": "retryProductionRelease", - "summary": "受门禁保护地重排失败发布(页面按钮或运维脚本)", - "description": "两条通道:运维脚本用受限配置的 Bearer token(审计记固定授权人);页面按钮用管理员会话 + 固定确认语句(审计记该管理员)。请求门禁、备份恢复门禁与 Release Worker 执行门禁不变。 确认语句:retry-production-release。重排沿用原执行的授权人记录。", - "responses": { - "202": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "parameters": [ - { - "$ref": "#/components/parameters/ID" - }, - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ] - } - }, - "/api/v1/release-capabilities": { - "get": { - "tags": [ - "Release" - ], - "operationId": "getReleaseCapabilities", - "summary": "读取不含敏感信息的生产门禁能力", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/routing-bindings": { - "get": { - "tags": [ - "Settings" - ], - "operationId": "getRoutingBindings", - "summary": "读取对内/对外知识库绑定", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "put": { - "tags": [ - "Settings" - ], - "operationId": "saveRoutingBindings", - "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": { - "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": [ - "Settings" - ], - "operationId": "listRoutingBindingOptions", - "summary": "列出可绑定的 WeKnora 知识库", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/ui-settings": { - "get": { - "tags": [ - "Settings" - ], - "operationId": "getActiveUISettings", - "summary": "读取当前活动的安全刷新和告警设置", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/ui-settings/candidates": { - "post": { - "tags": [ - "Settings" - ], - "operationId": "createUISettingsCandidate", - "summary": "创建待验证的安全设置版本", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UISettingsCandidateRequest" - } - } - } - }, - "responses": { - "201": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ] - } - }, - "/api/v1/ui-settings/{id}/activate": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "post": { - "tags": [ - "Settings" - ], - "operationId": "activateUISettings", - "summary": "验证并原子启用设置版本", - "responses": { - "200": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "parameters": [ - { - "$ref": "#/components/parameters/ID" - }, - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ] - } - }, - "/api/v1/test-publications": { - "get": { - "tags": [ - "Test processing" - ], - "operationId": "listTestPublications", - "summary": "按处理状态分页读取测试发布记录", - "parameters": [ - { - "name": "processing_state", - "in": "query", - "description": "解析状态过滤。可传单个状态,或用逗号拼接多个状态(例如 failed,stalled)一次取出全部失败项。", - "schema": { - "type": "string", - "pattern": "^(planned|pending|processing|finalizing|completed|failed|stalled|cancelled)(,(planned|pending|processing|finalizing|completed|failed|stalled|cancelled))*$" - } - }, - { - "in": "query", - "name": "target_scope", - "description": "Filter the test queue by routing target: internal or external (P4).", - "required": false, - "schema": { - "type": "string", - "enum": [ - "internal", - "external" - ] - } - }, - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/test-publications/start": { - "post": { - "tags": [ - "Test processing" - ], - "operationId": "startTestPublications", - "summary": "人工确认后启动测试解析任务", - "description": "当前按TestPublication确认;P3会把该操作改为按Catalog ChangeSet确认。网络重试复用原 Idempotency-Key。", - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StartTestPublicationsRequest" - } - } - } - }, - "responses": { - "202": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/test-publications/{id}/acceptance": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "get": { - "tags": [ - "Test processing" - ], - "operationId": "listTestPublicationAcceptance", - "summary": "读取测试发布的安全验收记录", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/test-publications/{id}/retry": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "post": { - "tags": [ - "Test processing" - ], - "operationId": "retryTestPublication", - "summary": "重新排队一份可重试测试发布", - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-catalog/snapshots": { - "get": { - "tags": [ - "Source catalog" - ], - "operationId": "listCatalogSnapshots", - "summary": "按来源分页读取历史快照清单", - "description": "Head当前生效快照排在最前;只返回安全投影,不返回snapshot hash或locator hash。", - "parameters": [ - { - "$ref": "#/components/parameters/ConnectionID" - }, - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-catalog/snapshots/{id}/nodes": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "get": { - "tags": [ - "Source catalog" - ], - "operationId": "listCatalogSnapshotNodes", - "summary": "只读分页查看历史快照中的目录节点", - "description": "该接口只读,绝不切换当前Head。content_sha256仅在来源实际提供时返回;未知即null,不伪造。", - "parameters": [ - { - "$ref": "#/components/parameters/ConnectionID" - }, - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-catalog/change-sets/{id}/confirm": { - "parameters": [ - { - "$ref": "#/components/parameters/ID" - } - ], - "post": { - "tags": [ - "Source catalog" - ], - "operationId": "confirmCatalogChangeSet", - "summary": "确认 ChangeSet 中选中的变更项并创建解析任务", - "description": "仅执行选中的 item_seqs;NO_CHANGE 永不入队;禁止/删除项形成测试库移除任务。重复确认返回第一次结果 operation=replayed;同键不同请求 409。绝不返回内部 locator hash。", - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConfirmChangeSetRequest" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/job-failures/{id}/requeue": { - "post": { - "operationId": "requeueJobFailure", - "summary": "重排一条终端失败的“源头取件”死信", - "description": "取件任务的幂等键绑在来源版本上,失败那次已占住它,所以重新标注同一文件不会再入队。本接口按新轮次重新入队并复位意图取件状态,同时把死信标记为 requeued。仅限 artifact.fetch 类型且仍为 open 的记录。", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "reason" - ], - "properties": { - "reason": { - "type": "string", - "minLength": 1, - "maxLength": 500 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "已重新入队;replayed=true 表示幂等重放" - }, - "400": { - "description": "请求无效或缺少 Idempotency-Key" - }, - "409": { - "description": "该失败记录不可重排,或幂等键与不同请求冲突" - }, - "500": { - "description": "服务端错误" - } - } - } - }, - "/api/v1/job-failures": { - "get": { - "summary": "分页列出解析死信(DLQ)投影", - "description": "终端失败尝试的只读投影;原 jobs 与发布审计行永不移动或删除。", - "parameters": [ - { - "name": "state", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "open", - "requeued", - "abandoned" - ] - }, - "description": "默认 open" - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - } - }, - { - "name": "offset", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 0, - "default": 0 - } - } - ], - "responses": { - "200": { - "description": "死信投影分页", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "job_id": { - "type": "string" - }, - "job_type": { - "type": "string" - }, - "publication_id": { - "type": "string" - }, - "source_item_id": { - "type": "string" - }, - "error_code": { - "type": "string" - }, - "attempt": { - "type": "integer" - }, - "max_attempts_at_failure": { - "type": "integer" - }, - "change_set_id": { - "type": "string" - }, - "action": { - "type": "string" - }, - "target_kb_id": { - "type": "string" - }, - "state": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - } - }, - "total": { - "type": "integer" - }, - "limit": { - "type": "integer" - }, - "offset": { - "type": "integer" - } - } - } - }, - "required": [ - "data" - ] - } - } - } - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - } - }, - "operationId": "listJobFailures" - } - }, - "/api/v1/test-publications/retry-batch": { - "post": { - "summary": "服务端批量重试失败发布", - "description": "一次事务内逐项返回 started/already_running/not_retryable;批量操作人、原因与幂等信封原子记录。最多100项,前端不得循环调用单项接口。", - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "publication_ids": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "items": { - "type": "string" - } - }, - "operator_ref": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "reason": { - "type": "string", - "minLength": 1, - "maxLength": 500 - } - }, - "required": [ - "publication_ids", - "operator_ref", - "reason" - ] - } - } - } - }, - "responses": { - "200": { - "description": "批量重试结果(含逐项outcome)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "batch_key": { - "type": "string" - }, - "total": { - "type": "integer" - }, - "started": { - "type": "integer" - }, - "already_running": { - "type": "integer" - }, - "not_retryable": { - "type": "integer" - }, - "outcomes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "publication_id": { - "type": "string" - }, - "result": { - "type": "string", - "enum": [ - "started", - "already_running", - "not_retryable" - ] - }, - "detail": { - "type": "string" - } - } - } - } - }, - "required": [ - "batch_key", - "total", - "started", - "already_running", - "not_retryable", - "outcomes" - ] - }, - "operation": { - "type": "string", - "enum": [ - "created", - "replayed" - ] - } - }, - "required": [ - "data", - "operation" - ] - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "operationId": "retryPublicationsBatch" - } - }, - "/api/v1/test-publications/cancel-batch": { - "post": { - "summary": "服务端批量取消失败发布(不再自动重试)", - "description": "用于源头即空文档等永久性失败:一次事务内把 failed/stalled 发布置为 cancelled,撤销排队中的上传任务并放弃对应死信;逐项返回 cancelled/already_running/not_retryable。原始记录与暂存文件保留,重新标记入库可再次处理。最多100项,操作人与原因由服务端会话注入并原子审计。", - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "publication_ids": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "items": { - "type": "string" - } - }, - "reason": { - "type": "string", - "minLength": 1, - "maxLength": 500 - } - }, - "required": [ - "publication_ids", - "reason" - ] - } - } - } - }, - "responses": { - "200": { - "description": "批量取消结果(含逐项outcome)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "batch_key": { - "type": "string" - }, - "total": { - "type": "integer" - }, - "started": { - "type": "integer", - "description": "成功取消的项数" - }, - "already_running": { - "type": "integer" - }, - "not_retryable": { - "type": "integer", - "description": "不可取消的项数(如已完成或不在失败态)" - }, - "outcomes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "publication_id": { - "type": "string" - }, - "result": { - "type": "string", - "enum": [ - "cancelled", - "already_running", - "not_retryable" - ] - }, - "detail": { - "type": "string" - } - } - } - } - }, - "required": [ - "batch_key", - "total", - "started", - "already_running", - "not_retryable", - "outcomes" - ] - }, - "operation": { - "type": "string", - "enum": [ - "created", - "replayed" - ] - } - }, - "required": [ - "data", - "operation" - ] - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "operationId": "cancelPublicationsBatch" - } - }, - "/api/v1/monitor/metrics": { - "get": { - "operationId": "getMonitorMetrics", - "summary": "真实运行指标(Worker/队列/吞吐/成功率/耗时分布)", - "description": "冻结1小时与24小时统计窗口;样本不足的比率与分位数返回null而非0;活跃Worker按PostgreSQL新鲜租约去重计数,不按容器数猜测。模拟模式声明worker_source=simulation。", - "parameters": [], - "responses": { - "200": { - "description": "运行指标快照", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "properties": { - "observed_at": { - "type": "string", - "format": "date-time" - }, - "active_workers": { - "type": "integer" - }, - "worker_source": { - "type": "string", - "enum": [ - "pg_lease", - "simulation" - ] - }, - "queue": { - "type": "object", - "properties": { - "pending": { - "type": "integer" - }, - "running": { - "type": "integer" - }, - "retry": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "oldest_pending_age_seconds": { - "type": [ - "number", - "null" - ] - } - } - }, - "short_window": { - "$ref": "#/components/schemas/JobWindowMetrics" - }, - "long_window": { - "$ref": "#/components/schemas/JobWindowMetrics" - } - } - } - } - } - } - } - }, - "503": { - "description": "指标暂不可用", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/livez": { - "get": { - "operationId": "getLivez", - "summary": "存活探针(Liveness)", - "description": "仅回答“本进程能否服务HTTP”。绝不依赖PostgreSQL、WeKnora、MinerU或Worker:第三方故障时本端点必须保持200。", - "responses": { - "200": { - "description": "进程存活", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "observed_at": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "status", - "observed_at" - ] - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "tags": [ - "Health" - ] - } - }, - "/readyz": { - "get": { - "operationId": "getReadyz", - "summary": "就绪探针(Readiness)", - "description": "逐服务独立评估Liveness与Readiness(P10)。只有“已部署且已配置但当前不可工作”的服务阻塞就绪并返回503;未部署=not_applicable、缺配置=not_configured 仅警告不阻塞;结果绝不泄漏URL、密钥或主机。", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "503": { - "description": "存在已配置依赖当前不可接单", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "blocked": { - "type": "array", - "items": { - "type": "string" - } - }, - "not_configured": { - "type": "array", - "items": { - "type": "string" - } - }, - "observed_at": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "status", - "blocked", - "not_configured", - "observed_at" - ] - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - }, - "tags": [ - "Health" - ] - } - }, - "/api/v1/release-change-sets": { - "get": { - "tags": [ - "Release" - ], - "operationId": "listReleaseChangeSets", - "summary": "分页列出版本化发布计划集", - "parameters": [ - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer" - }, - "description": "每页数量,默认20上限100" - }, - { - "in": "query", - "name": "offset", - "schema": { - "type": "integer" - }, - "description": "偏移" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "post": { - "tags": [ - "Release" - ], - "operationId": "generateReleaseChangeSet", - "summary": "把当前实时发布计划固化为不可变版本化ChangeSet", - "description": "生成即冻结:绑定当前head快照集与确认过的目录ChangeSet集,按精确字段顺序与分隔符计算清单摘要。相同计划并发去重(唯一索引冲突则复用最新planned或取下一版本)。来源刷新绝不可能改变已固化清单。", - "parameters": [ - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "created_by": { - "type": "string" - } - }, - "required": [ - "name", - "created_by" - ] - } - } - } - }, - "responses": { - "201": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/release-change-sets/latest": { - "get": { - "tags": [ - "Release" - ], - "operationId": "getLatestReleaseChangeSet", - "summary": "最新发布计划集(含冻结逐项清单)", - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/release-change-sets/{id}": { - "get": { - "tags": [ - "Release" - ], - "operationId": "getReleaseChangeSet", - "summary": "按ID读取发布计划集与清单", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/Envelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/release-change-sets/{id}/confirm": { - "post": { - "tags": [ - "Release" - ], - "operationId": "confirmReleaseChangeSet", - "summary": "冻结planned发布计划集供正式批次引用", - "description": "planned转confirmed幂等重放;非法状态迁移返回409。确认后清单与摘要永不改变。", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - }, - { - "$ref": "#/components/parameters/IdempotencyKey" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "confirmed_by": { - "type": "string" - } - }, - "required": [ - "confirmed_by" - ] - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/OperationEnvelope" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/release-executions/{id}/audit-chain": { - "get": { - "tags": [ - "Release" - ], - "operationId": "getReleaseAuditChain", - "summary": "发布审计链(R6 只读投影)", - "description": "一次执行可追溯到批次、已确认发布计划集、绑定的来源快照与目录变更集、逐项审核/验收/来源身份,以及补偿回滚行。所有链路通过真实外键读取(迁移30),只返回安全标识、名称、计数与时间戳,绝不返回Bundle内部路径、凭据、Base URL或对象签名URL。", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "完整证据链", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object" - } - }, - "required": [ - "data" - ] - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-connections/{id}/settings/revisions": { - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - }, - "description": "来源连接 ID" - } - ], - "get": { - "operationId": "listSourceSettingsRevisions", - "summary": "来源设置修订历史(V1)", - "description": "validated→active→retired 完整历史;仅安全投影:credential_ref 是凭据引用名,真实凭据不可回读。", - "tags": [ - "governance" - ], - "responses": { - "200": { - "description": "修订列表", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "revision_number": { - "type": "integer" - }, - "state": { - "enum": [ - "validated", - "active", - "retired", - "failed" - ] - }, - "name": { - "type": "string" - }, - "endpoint": { - "type": "string" - }, - "credential_ref": { - "type": "string", - "description": "引用名,非密钥本身" - }, - "enabled": { - "type": "boolean" - }, - "refresh_interval_seconds": { - "type": "string" - }, - "created_by": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "validated_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "activated_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "retired_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - } - } - } - }, - "operation": { - "enum": [ - "created", - "replayed" - ] - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "post": { - "operationId": "createSourceSettingsRevision", - "summary": "创建来源设置草稿修订(V1)", - "description": "校验通过落为 validated 修订,不影响运行态;需要 X-Governance-Action: source-settings 与 Idempotency-Key。", - "tags": [ - "governance" - ], - "parameters": [ - { - "in": "header", - "name": "Idempotency-Key", - "required": true, - "schema": { - "type": "string", - "maxLength": 128 - } - }, - { - "in": "header", - "name": "X-Governance-Action", - "required": true, - "schema": { - "type": "string", - "enum": [ - "source-settings" - ] - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "source_type", - "name", - "operator", - "schedule" - ], - "additionalProperties": false, - "properties": { - "source_type": { - "enum": [ - "pp", - "alist", - "dingtalk", - "manual" - ] - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "endpoint": { - "type": "string", - "maxLength": 2048 - }, - "credential_ref": { - "type": "string", - "maxLength": 255, - "description": "密钥引用名;真实凭据永不入库、永不回显" - }, - "enabled": { - "type": "boolean" - }, - "schedule": { - "type": "object", - "additionalProperties": false, - "properties": { - "refresh_interval_seconds": { - "type": "integer", - "nullable": true, - "description": "缺省/0=仅手动;非零最低300秒" - } - } - }, - "operator": { - "type": "string", - "minLength": 1, - "maxLength": 128 - } - } - } - } - } - }, - "responses": { - "201": { - "description": "修订已创建", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "type": "string" - } - } - }, - "operation": { - "enum": [ - "created", - "replayed" - ] - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v1/source-connections/{id}/settings/revisions/{revision_id}/activate": { - "post": { - "operationId": "activateSourceSettingsRevision", - "summary": "激活来源设置修订(V1)", - "description": "原子切换:退休旧 active、同步运行态快照、由服务端按周期推导下次刷新时间;停用立即清空到期时间(停止新扫描与新任务),历史全部保留。", - "tags": [ - "governance" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "revision_id", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "header", - "name": "Idempotency-Key", - "required": true, - "schema": { - "type": "string", - "maxLength": 128 - } - }, - { - "in": "header", - "name": "X-Governance-Action", - "required": true, - "schema": { - "type": "string", - "enum": [ - "source-settings" - ] - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "maxProperties": 0 - } - } - } - }, - "responses": { - "200": { - "description": "已激活", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "properties": { - "connection_id": { - "type": "string" - }, - "revision_id": { - "type": "string" - } - } - }, - "operation": { - "enum": [ - "created", - "replayed" - ] - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - } + { + "name": "Jobs" }, - "components": { - "parameters": { - "ID": { - "name": "id", - "in": "path", - "required": true, + { + "name": "Source catalog" + }, + { + "name": "Monitoring" + }, + { + "name": "Scope" + }, + { + "name": "Source connections" + }, + { + "name": "Review" + }, + { + "name": "Release" + }, + { + "name": "Test processing" + }, + { + "name": "Settings" + } + ], + "paths": { + "/api/v1/backup-schedule": { + "get": { + "tags": [ + "Settings" + ], + "operationId": "getBackupSchedule", + "summary": "读取整库逻辑备份的定时计划(不探测主机)", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "operationId": "saveBackupSchedule", + "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" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v1/backup-schedule/host": { + "get": { + "tags": [ + "Settings" + ], + "operationId": "getBackupScheduleHost", + "summary": "读取目标主机上生效的 cron 计划与最近一次备份结果", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, + "/api/v1/backup-schedule/apply": { + "post": { + "tags": [ + "Settings" + ], + "operationId": "applyBackupSchedule", + "summary": "把当前设置重新下发到目标主机(管理员)", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v1/backup-schedule/run": { + "post": { + "tags": [ + "Settings" + ], + "operationId": "runBackupNow", + "summary": "在目标主机上立即执行一次整库备份(管理员,异步)", + "responses": { + "202": { + "$ref": "#/components/responses/Envelope" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/health": { + "get": { + "tags": [ + "Health" + ], + "operationId": "getHealth", + "summary": "读取服务健康状态", + "responses": { + "200": { + "description": "服务可响应", + "content": { + "application/json": { "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/jobs": { + "get": { + "tags": [ + "Jobs" + ], + "operationId": "listJobs", + "summary": "列出最近治理任务", + "parameters": [ + { + "$ref": "#/components/parameters/Limit" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "tags": [ + "Jobs" + ], + "operationId": "createJob", + "summary": "创建幂等后台任务", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobCreateRequest" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-items": { + "get": { + "tags": [ + "Source catalog" + ], + "operationId": "listSourceItems", + "summary": "列出安全来源文件投影", + "parameters": [ + { + "$ref": "#/components/parameters/State" + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Offset" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-catalog/sources": { + "get": { + "tags": [ + "Source catalog" + ], + "operationId": "listCatalogSources", + "summary": "列出当前可浏览来源快照", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-catalog/nodes": { + "get": { + "tags": [ + "Source catalog" + ], + "operationId": "listCatalogNodes", + "summary": "按父目录分页列出当前快照直接子项", + "parameters": [ + { + "$ref": "#/components/parameters/ConnectionID" + }, + { + "name": "parent_id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "maxLength": 36 + } + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Offset" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-catalog/change-sets/latest": { + "get": { + "tags": [ + "Source catalog" + ], + "operationId": "getLatestCatalogChangeSet", + "summary": "读取某来源最新持久ChangeSet", + "parameters": [ + { + "$ref": "#/components/parameters/ConnectionID" + }, + { + "name": "action", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "add", + "update", + "move", + "move_scope", + "delete", + "no_change" + ] + } + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Offset" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-catalog/routing-capabilities": { + "get": { + "tags": [ + "Source catalog" + ], + "operationId": "getCatalogRoutingCapabilities", + "summary": "读取对内和对外测试路由是否已配置", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-catalog/route": { + "post": { + "tags": [ + "Source catalog" + ], + "operationId": "setCatalogPolicy", + "summary": "写入目录或文件的期望处理策略", + "description": "当前接口只修改治理策略和审计记录,不代表已经上传、解析或发布。调用方必须为每次用户动作生成新的 Idempotency-Key;网络重试复用原键会返回原结果且 operation=replayed,同键不同请求返回409。", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CatalogRouteRequest" + } + } + } + }, + "responses": { + "202": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-catalog/sources/{id}/refresh": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "post": { + "tags": [ + "Source catalog" + ], + "operationId": "refreshCatalogSource", + "summary": "创建来源目录刷新任务", + "description": "调用方必须为每次用户动作生成新的 Idempotency-Key;网络重试复用原键会返回同一刷新任务且 operation=replayed。", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "202": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/monitor/overview": { + "get": { + "tags": [ + "Monitoring" + ], + "operationId": "getMonitorOverview", + "summary": "读取主机、服务和知识库处理概览", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/monitor/knowledge-bases/{id}/files": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "get": { + "tags": [ + "Monitoring" + ], + "operationId": "listKnowledgeBaseFiles", + "summary": "按处理状态分页列出知识库文件", + "parameters": [ + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "pending", + "processing", + "finalizing", + "completed", + "failed", + "cancelled" + ] + } + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Offset" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/filename-filters": { + "get": { + "tags": [ + "Source connections" + ], + "operationId": "listFilenameFilters", + "summary": "列出来源和目录的文件扩展名筛选规则", + "parameters": [ + { + "name": "connection_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "筛选规则" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Source connections" + ], + "operationId": "setFilenameFilter", + "summary": "设置来源或目录文件扩展名筛选", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "筛选规则已生效" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "tags": [ + "Source connections" + ], + "operationId": "clearFilenameFilter", + "summary": "移除本级筛选并恢复继承", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "description": "本级筛选已移除" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/scope-rules": { + "get": { + "tags": [ + "Scope" + ], + "operationId": "listScopeRules", + "summary": "读取来源范围规则", + "parameters": [ + { + "$ref": "#/components/parameters/ConnectionID" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Scope" + ], + "operationId": "setScopeRule", + "summary": "创建或更新范围规则", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScopeRuleMutation" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "tags": [ + "Scope" + ], + "operationId": "clearScopeRule", + "summary": "清除范围规则", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScopeRuleMutation" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/dingtalk-session": { + "post": { + "tags": [ + "Source connections" + ], + "operationId": "startDingtalkSession", + "summary": "发起钉钉扫码登录会话(管理员)", + "responses": { + "201": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/dingtalk-session/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "get": { + "tags": [ + "Source connections" + ], + "operationId": "getDingtalkSession", + "summary": "轮询钉钉扫码会话状态(管理员)", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "tags": [ + "Source connections" + ], + "operationId": "cancelDingtalkSession", + "summary": "取消钉钉扫码会话(管理员)", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/dingtalk-spaces": { + "post": { + "tags": [ + "Source connections" + ], + "operationId": "registerDingtalkSpace", + "summary": "解析并登记钉钉知识库空间(管理员)", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "201": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-connections": { + "get": { + "tags": [ + "Source connections" + ], + "operationId": "listSourceConnections", + "summary": "列出安全来源连接投影", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-connections/{id}/scan": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "post": { + "tags": [ + "Source connections" + ], + "operationId": "triggerSourceScan", + "summary": "创建旧PP来源扫描任务", + "responses": { + "202": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/ID" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ] + } + }, + "/api/v1/source-connections/{id}/scans": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "get": { + "tags": [ + "Source connections" + ], + "operationId": "listSourceScans", + "summary": "分页读取来源扫描记录", + "parameters": [ + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Offset" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-connections/{id}/scans/{scan_id}": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + }, + { + "name": "scan_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64 + } + } + ], + "get": { + "tags": [ + "Source connections" + ], + "operationId": "getSourceScan", + "summary": "读取单次扫描及其安全变更投影", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/review-candidates": { + "get": { + "tags": [ + "Review" + ], + "operationId": "listReviewCandidates", + "summary": "分页列出可审核测试知识", + "parameters": [ + { + "name": "review_state", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "pending", + "approved", + "rejected", + "not_required" + ] + } + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Offset" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/review-candidates/{id}/decision": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "post": { + "tags": [ + "Review" + ], + "operationId": "reviewCandidate", + "summary": "提交人工审核决定", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewDecisionRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/ID" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ] + } + }, + "/api/v1/release-plan": { + "get": { + "tags": [ + "Release" + ], + "operationId": "getReleasePlan", + "summary": "预览当前测试到生产的发布计划", + "description": "当前是实时预览,不是不可变Release ChangeSet;R1会替换为持久发布ChangeSet。", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/release-batches": { + "get": { + "tags": [ + "Release" + ], + "operationId": "listReleaseBatches", + "summary": "分页列出草稿或已封存发布批次", + "parameters": [ + { + "name": "state", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "draft", + "sealed", + "cancelled" + ] + } + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Offset" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "tags": [ + "Release" + ], + "operationId": "createReleaseBatch", + "summary": "创建草稿发布批次", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseBatchCreateRequest" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ] + } + }, + "/api/v1/release-batches/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "get": { + "tags": [ + "Release" + ], + "operationId": "getReleaseBatch", + "summary": "读取发布批次详情", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/release-batches/{id}/seal": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "post": { + "tags": [ + "Release" + ], + "operationId": "sealReleaseBatch", + "summary": "封存草稿发布批次,不触发生产发布", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseBatchSealRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/ID" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ] + } + }, + "/api/v1/release-batches/{id}/authorize-production": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "post": { + "tags": [ + "Release" + ], + "operationId": "authorizeProductionRelease", + "summary": "受门禁保护的生产发布授权(页面按钮或运维脚本)", + "description": "默认关闭。调用方不得提交审核人身份。两条通道:运维脚本用受限配置的 Bearer token(审计记固定授权人);页面按钮用管理员会话 + 固定确认语句(审计记该管理员)。请求门禁、备份恢复门禁与 Release Worker 执行门禁不变。 确认语句:publish-to-production。", + "responses": { + "202": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/ID" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ] + } + }, + "/api/v1/release-executions": { + "get": { + "tags": [ + "Release" + ], + "operationId": "listReleaseExecutions", + "summary": "分页读取生产发布和回滚审计", + "parameters": [ + { + "name": "operation", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "publish", + "rollback" + ] + } + }, + { + "name": "state", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "pending", + "running", + "rolling_back", + "completed", + "failed", + "rolled_back", + "rollback_failed" + ] + } + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Offset" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/release-executions/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "get": { + "tags": [ + "Release" + ], + "operationId": "getReleaseExecution", + "summary": "读取单次生产发布或回滚详情", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/release-executions/{id}/authorize-rollback": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "post": { + "tags": [ + "Release" + ], + "operationId": "authorizeProductionRollback", + "summary": "受门禁保护的生产回滚授权(页面按钮或运维脚本)", + "description": "两条通道:运维脚本用受限配置的 Bearer token(审计记固定授权人);页面按钮用管理员会话 + 固定确认语句(审计记该管理员)。请求门禁、备份恢复门禁与 Release Worker 执行门禁不变。 确认语句:rollback-production-release。", + "responses": { + "202": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/ID" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ] + } + }, + "/api/v1/release-executions/{id}/retry": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "post": { + "tags": [ + "Release" + ], + "operationId": "retryProductionRelease", + "summary": "受门禁保护地重排失败发布(页面按钮或运维脚本)", + "description": "两条通道:运维脚本用受限配置的 Bearer token(审计记固定授权人);页面按钮用管理员会话 + 固定确认语句(审计记该管理员)。请求门禁、备份恢复门禁与 Release Worker 执行门禁不变。 确认语句:retry-production-release。重排沿用原执行的授权人记录。", + "responses": { + "202": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/ID" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ] + } + }, + "/api/v1/release-capabilities": { + "get": { + "tags": [ + "Release" + ], + "operationId": "getReleaseCapabilities", + "summary": "读取不含敏感信息的生产门禁能力", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/routing-bindings": { + "get": { + "tags": [ + "Settings" + ], + "operationId": "getRoutingBindings", + "summary": "读取对内/对外知识库绑定", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "Settings" + ], + "operationId": "saveRoutingBindings", + "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": { + "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": [ + "Settings" + ], + "operationId": "listRoutingBindingOptions", + "summary": "列出可绑定的 WeKnora 知识库", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/ui-settings": { + "get": { + "tags": [ + "Settings" + ], + "operationId": "getActiveUISettings", + "summary": "读取当前活动的安全刷新和告警设置", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/ui-settings/candidates": { + "post": { + "tags": [ + "Settings" + ], + "operationId": "createUISettingsCandidate", + "summary": "创建待验证的安全设置版本", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UISettingsCandidateRequest" + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ] + } + }, + "/api/v1/ui-settings/{id}/activate": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "post": { + "tags": [ + "Settings" + ], + "operationId": "activateUISettings", + "summary": "验证并原子启用设置版本", + "responses": { + "200": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/ID" + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ] + } + }, + "/api/v1/test-publications": { + "get": { + "tags": [ + "Test processing" + ], + "operationId": "listTestPublications", + "summary": "按处理状态分页读取测试发布记录", + "parameters": [ + { + "name": "processing_state", + "in": "query", + "description": "解析状态过滤。可传单个状态,或用逗号拼接多个状态(例如 failed,stalled)一次取出全部失败项。", + "schema": { + "type": "string", + "pattern": "^(planned|pending|processing|finalizing|completed|failed|stalled|cancelled)(,(planned|pending|processing|finalizing|completed|failed|stalled|cancelled))*$" + } + }, + { + "in": "query", + "name": "target_scope", + "description": "Filter the test queue by routing target: internal or external (P4).", + "required": false, + "schema": { + "type": "string", + "enum": [ + "internal", + "external" + ] + } + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "in": "query", + "name": "removal", + "description": "removal=required 只返回「被禁止导入但仍存活在测试库」的条目(解析监控 → 需下架 车道使用)。", + "required": false, + "schema": { + "type": "string", + "enum": [ + "required" + ] + } + }, + { + "$ref": "#/components/parameters/Offset" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/test-publications/start": { + "post": { + "tags": [ + "Test processing" + ], + "operationId": "startTestPublications", + "summary": "人工确认后启动测试解析任务", + "description": "当前按TestPublication确认;P3会把该操作改为按Catalog ChangeSet确认。网络重试复用原 Idempotency-Key。", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartTestPublicationsRequest" + } + } + } + }, + "responses": { + "202": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/test-publications/{id}/acceptance": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "get": { + "tags": [ + "Test processing" + ], + "operationId": "listTestPublicationAcceptance", + "summary": "读取测试发布的安全验收记录", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/test-publications/{id}/retry": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "post": { + "tags": [ + "Test processing" + ], + "operationId": "retryTestPublication", + "summary": "重新排队一份可重试测试发布", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-catalog/snapshots": { + "get": { + "tags": [ + "Source catalog" + ], + "operationId": "listCatalogSnapshots", + "summary": "按来源分页读取历史快照清单", + "description": "Head当前生效快照排在最前;只返回安全投影,不返回snapshot hash或locator hash。", + "parameters": [ + { + "$ref": "#/components/parameters/ConnectionID" + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Offset" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-catalog/snapshots/{id}/nodes": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "get": { + "tags": [ + "Source catalog" + ], + "operationId": "listCatalogSnapshotNodes", + "summary": "只读分页查看历史快照中的目录节点", + "description": "该接口只读,绝不切换当前Head。content_sha256仅在来源实际提供时返回;未知即null,不伪造。", + "parameters": [ + { + "$ref": "#/components/parameters/ConnectionID" + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Offset" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-catalog/change-sets/{id}/confirm": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "post": { + "tags": [ + "Source catalog" + ], + "operationId": "confirmCatalogChangeSet", + "summary": "确认 ChangeSet 中选中的变更项并创建解析任务", + "description": "仅执行选中的 item_seqs;NO_CHANGE 永不入队;禁止/删除项形成测试库移除任务。重复确认返回第一次结果 operation=replayed;同键不同请求 409。绝不返回内部 locator hash。", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmChangeSetRequest" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/job-failures/{id}/requeue": { + "post": { + "operationId": "requeueJobFailure", + "summary": "重排一条终端失败的“源头取件”死信", + "description": "取件任务的幂等键绑在来源版本上,失败那次已占住它,所以重新标注同一文件不会再入队。本接口按新轮次重新入队并复位意图取件状态,同时把死信标记为 requeued。仅限 artifact.fetch 类型且仍为 open 的记录。", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "reason" + ], + "properties": { + "reason": { "type": "string", - "maxLength": 64 + "minLength": 1, + "maxLength": 500 + } } + } + } + } + }, + "responses": { + "200": { + "description": "已重新入队;replayed=true 表示幂等重放" + }, + "400": { + "description": "请求无效或缺少 Idempotency-Key" + }, + "409": { + "description": "该失败记录不可重排,或幂等键与不同请求冲突" + }, + "500": { + "description": "服务端错误" + } + } + } + }, + "/api/v1/job-failures": { + "get": { + "summary": "分页列出解析死信(DLQ)投影", + "description": "终端失败尝试的只读投影;原 jobs 与发布审计行永不移动或删除。", + "parameters": [ + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "open", + "requeued", + "abandoned" + ] }, - "IdempotencyKey": { - "name": "Idempotency-Key", - "in": "header", - "required": true, - "description": "每次用户动作使用一个新键;仅网络重试复用同一键。不得包含凭据或业务内容。", + "description": "默认 open" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "死信投影分页", + "content": { + "application/json": { "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "job_id": { + "type": "string" + }, + "job_type": { + "type": "string" + }, + "publication_id": { + "type": "string" + }, + "source_item_id": { + "type": "string" + }, + "error_code": { + "type": "string" + }, + "attempt": { + "type": "integer" + }, + "max_attempts_at_failure": { + "type": "integer" + }, + "change_set_id": { + "type": "string" + }, + "action": { + "type": "string" + }, + "target_kb_id": { + "type": "string" + }, + "state": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + } + } + }, + "required": [ + "data" + ] + } + } + } + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + } + }, + "operationId": "listJobFailures" + } + }, + "/api/v1/test-publications/retry-batch": { + "post": { + "summary": "服务端批量重试失败发布", + "description": "一次事务内逐项返回 started/already_running/not_retryable;批量操作人、原因与幂等信封原子记录。最多100项,前端不得循环调用单项接口。", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "publication_ids": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + }, + "operator_ref": { "type": "string", "minLength": 1, "maxLength": 128 - } - }, - "ConnectionID": { - "name": "connection_id", - "in": "query", - "required": true, - "schema": { + }, + "reason": { "type": "string", - "maxLength": 64 - } - }, - "Limit": { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - } - }, - "Offset": { - "name": "offset", - "in": "query", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 1000000, - "default": 0 - } - }, - "State": { - "name": "state", - "in": "query", - "schema": { - "type": "string", - "maxLength": 32 - } + "minLength": 1, + "maxLength": 500 + } + }, + "required": [ + "publication_ids", + "operator_ref", + "reason" + ] + } } + } }, "responses": { - "Envelope": { - "description": "安全数据响应", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Envelope" - } - } - } - }, - "OperationEnvelope": { - "description": "可安全重试的目录操作响应;operation=created 表示首次执行,replayed 表示返回原操作结果。", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OperationEnvelope" - } - } - } - }, - "BadRequest": { - "description": "请求参数或状态非法", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "Forbidden": { - "description": "安全门禁或权限拒绝", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "NotFound": { - "description": "资源不存在", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "Conflict": { - "description": "资源已变更、状态冲突或重复动作", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "InternalError": { - "description": "安全的服务端错误码", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "IdempotencyConflict": { - "description": "资源已变更、状态冲突或重复动作", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - }, - "schemas": { - "Health": { - "type": "object", - "additionalProperties": false, - "required": [ - "status" - ], - "properties": { - "status": { - "type": "string", - "const": "ok" - } - } - }, - "Envelope": { - "type": "object", - "additionalProperties": false, - "required": [ - "data" - ], - "properties": { + "200": { + "description": "批量重试结果(含逐项outcome)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "data": { - "description": "端点定义的安全投影" - } - } - }, - "OperationEnvelope": { - "type": "object", - "additionalProperties": false, - "required": [ - "data", - "operation" - ], - "properties": { - "data": { - "description": "端点定义的安全投影" + "type": "object", + "properties": { + "batch_key": { + "type": "string" + }, + "total": { + "type": "integer" + }, + "started": { + "type": "integer" + }, + "already_running": { + "type": "integer" + }, + "not_retryable": { + "type": "integer" + }, + "outcomes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "publication_id": { + "type": "string" + }, + "result": { + "type": "string", + "enum": [ + "started", + "already_running", + "not_retryable" + ] + }, + "detail": { + "type": "string" + } + } + } + } + }, + "required": [ + "batch_key", + "total", + "started", + "already_running", + "not_retryable", + "outcomes" + ] }, "operation": { - "type": "string", - "enum": [ - "created", - "replayed" - ] - } - } - }, - "Error": { - "type": "object", - "additionalProperties": false, - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string", - "maxLength": 512 - } - } - }, - "JobCreateRequest": { - "type": "object", - "additionalProperties": false, - "required": [ - "type", - "idempotency_key" - ], - "properties": { - "type": { - "type": "string", - "maxLength": 100 - }, - "payload": { - "type": "object" - }, - "priority": { - "type": "integer" - }, - "max_attempts": { - "type": "integer", - "minimum": 1, - "maximum": 100 - }, - "idempotency_key": { - "type": "string", - "minLength": 1, - "maxLength": 255 - } - } - }, - "CatalogRouteRequest": { - "type": "object", - "additionalProperties": false, - "required": [ - "connection_id", - "node_ids", - "operator_ref", - "reason" - ], - "properties": { - "connection_id": { - "type": "string", - "maxLength": 64 - }, - "node_ids": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "items": { - "type": "string", - "maxLength": 64 - } - }, - "policy": { - "type": "string", - "enum": [ - "internal", - "external", - "both", - "blocked" - ] - }, - "visibility": { - "type": "string", - "enum": [ - "internal", - "external", - "both", - "blocked" - ] - }, - "operator_ref": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "reason": { - "type": "string", - "minLength": 1, - "maxLength": 500 - } - } - }, - "ScopeRuleMutation": { - "type": "object", - "additionalProperties": false, - "required": [ - "target_type", - "operator_ref", - "reason" - ], - "properties": { - "target_type": { - "type": "string", - "enum": [ - "directory", - "item" - ] - }, - "connection_id": { - "type": "string", - "maxLength": 64 - }, - "directory_path": { - "type": "string", - "maxLength": 4096 - }, - "source_item_id": { - "type": "string", - "maxLength": 64 - }, - "visibility": { - "type": "string", - "enum": [ - "internal", - "external", - "both", - "blocked" - ] - }, - "operator_ref": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "reason": { - "type": "string", - "minLength": 1, - "maxLength": 500 - } - } - }, - "ReviewDecisionRequest": { - "type": "object", - "additionalProperties": false, - "required": [ - "decision", - "decided_visibility", - "reviewer_ref", - "decision_code" - ], - "properties": { - "decision": { - "type": "string", - "enum": [ - "approved", - "rejected" - ] - }, - "decided_visibility": { - "type": "string", - "enum": [ - "internal", - "external" - ] - }, - "reviewer_ref": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "decision_code": { - "type": "string", - "minLength": 1, - "maxLength": 64 - } - } - }, - "ReleaseBatchCreateRequest": { - "type": "object", - "additionalProperties": false, - "required": [ - "name", - "created_by" - ], - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "created_by": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "publication_ids": { - "type": "array", - "maxItems": 100, - "items": { - "type": "string", - "maxLength": 64 - } - }, - "delete_publication_ids": { - "type": "array", - "maxItems": 100, - "items": { - "type": "string", - "maxLength": 64 - } - }, - "release_change_set_id": { - "type": "string", - "description": "R1正式批次绑定的confirmed发布计划集ID;绑定时所有请求项必须在该集冻结清单内且可发布,否则422" - } - } - }, - "ReleaseBatchSealRequest": { - "type": "object", - "additionalProperties": false, - "required": [ - "sealed_by" - ], - "properties": { - "sealed_by": { - "type": "string", - "minLength": 1, - "maxLength": 128 - } - } - }, - "UISettingsCandidateRequest": { - "type": "object", - "additionalProperties": false, - "required": [ - "document_refresh_seconds", - "resource_refresh_seconds", - "stalled_after_seconds", - "finalizing_after_seconds", - "updated_by" - ], - "properties": { - "document_refresh_seconds": { - "type": "integer", - "enum": [ - 15, - 30, - 60 - ] - }, - "resource_refresh_seconds": { - "type": "integer", - "enum": [ - 30, - 60 - ] - }, - "stalled_after_seconds": { - "type": "integer", - "enum": [ - 1800, - 3600 - ] - }, - "finalizing_after_seconds": { - "type": "integer", - "enum": [ - 7200, - 14400 - ] - }, - "updated_by": { - "type": "string", - "minLength": 2, - "maxLength": 128 - } - } - }, - "StartTestPublicationsRequest": { - "type": "object", - "additionalProperties": false, - "required": [ - "publication_ids", - "operator_ref", - "reason" - ], - "properties": { - "publication_ids": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "items": { - "type": "string", - "maxLength": 64 - } - }, - "operator_ref": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "reason": { - "type": "string", - "minLength": 1, - "maxLength": 500 - } - } - }, - "ConfirmChangeSetRequest": { - "type": "object", - "additionalProperties": false, - "required": [ - "item_seqs", - "operator_ref", - "reason" - ], - "properties": { - "item_seqs": { - "type": "array", - "minItems": 1, - "maxItems": 200, - "items": { - "type": "integer", - "minimum": 1 - } - }, - "operator_ref": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "reason": { - "type": "string", - "minLength": 1, - "maxLength": 500 - } - } - }, - "JobWindowMetrics": { - "type": "object", - "properties": { - "window_seconds": { - "type": "integer" - }, - "jobs_started": { - "type": "integer" - }, - "jobs_completed": { - "type": "integer" - }, - "jobs_failed": { - "type": "integer" - }, - "success_rate": { - "type": [ - "number", - "null" - ] - }, - "failure_rate": { - "type": [ - "number", - "null" - ] - }, - "duration_avg_seconds": { - "type": [ - "number", - "null" - ] - }, - "duration_p50_seconds": { - "type": [ - "number", - "null" - ] - }, - "duration_p95_seconds": { - "type": [ - "number", - "null" - ] + "type": "string", + "enum": [ + "created", + "replayed" + ] } + }, + "required": [ + "data", + "operation" + ] } + } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "operationId": "retryPublicationsBatch" + } + }, + "/api/v1/test-publications/cancel-batch": { + "post": { + "summary": "服务端批量取消失败发布(不再自动重试)", + "description": "用于源头即空文档等永久性失败:一次事务内把 failed/stalled 发布置为 cancelled,撤销排队中的上传任务并放弃对应死信;逐项返回 cancelled/already_running/not_retryable。原始记录与暂存文件保留,重新标记入库可再次处理。最多100项,操作人与原因由服务端会话注入并原子审计。", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "publication_ids": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string" + } + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + }, + "required": [ + "publication_ids", + "reason" + ] + } + } + } + }, + "responses": { + "200": { + "description": "批量取消结果(含逐项outcome)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "batch_key": { + "type": "string" + }, + "total": { + "type": "integer" + }, + "started": { + "type": "integer", + "description": "成功取消的项数" + }, + "already_running": { + "type": "integer" + }, + "not_retryable": { + "type": "integer", + "description": "不可取消的项数(如已完成或不在失败态)" + }, + "outcomes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "publication_id": { + "type": "string" + }, + "result": { + "type": "string", + "enum": [ + "cancelled", + "already_running", + "not_retryable" + ] + }, + "detail": { + "type": "string" + } + } + } + } + }, + "required": [ + "batch_key", + "total", + "started", + "already_running", + "not_retryable", + "outcomes" + ] + }, + "operation": { + "type": "string", + "enum": [ + "created", + "replayed" + ] + } + }, + "required": [ + "data", + "operation" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "operationId": "cancelPublicationsBatch" + } + }, + "/api/v1/monitor/metrics": { + "get": { + "operationId": "getMonitorMetrics", + "summary": "真实运行指标(Worker/队列/吞吐/成功率/耗时分布)", + "description": "冻结1小时与24小时统计窗口;样本不足的比率与分位数返回null而非0;活跃Worker按PostgreSQL新鲜租约去重计数,不按容器数猜测。模拟模式声明worker_source=simulation。", + "parameters": [], + "responses": { + "200": { + "description": "运行指标快照", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "properties": { + "observed_at": { + "type": "string", + "format": "date-time" + }, + "active_workers": { + "type": "integer" + }, + "worker_source": { + "type": "string", + "enum": [ + "pg_lease", + "simulation" + ] + }, + "queue": { + "type": "object", + "properties": { + "pending": { + "type": "integer" + }, + "running": { + "type": "integer" + }, + "retry": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "oldest_pending_age_seconds": { + "type": [ + "number", + "null" + ] + } + } + }, + "short_window": { + "$ref": "#/components/schemas/JobWindowMetrics" + }, + "long_window": { + "$ref": "#/components/schemas/JobWindowMetrics" + } + } + } + } + } + } + } + }, + "503": { + "description": "指标暂不可用", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } } + } + }, + "/livez": { + "get": { + "operationId": "getLivez", + "summary": "存活探针(Liveness)", + "description": "仅回答“本进程能否服务HTTP”。绝不依赖PostgreSQL、WeKnora、MinerU或Worker:第三方故障时本端点必须保持200。", + "responses": { + "200": { + "description": "进程存活", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "observed_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "status", + "observed_at" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "tags": [ + "Health" + ] + } + }, + "/readyz": { + "get": { + "operationId": "getReadyz", + "summary": "就绪探针(Readiness)", + "description": "逐服务独立评估Liveness与Readiness(P10)。只有“已部署且已配置但当前不可工作”的服务阻塞就绪并返回503;未部署=not_applicable、缺配置=not_configured 仅警告不阻塞;结果绝不泄漏URL、密钥或主机。", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "503": { + "description": "存在已配置依赖当前不可接单", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "blocked": { + "type": "array", + "items": { + "type": "string" + } + }, + "not_configured": { + "type": "array", + "items": { + "type": "string" + } + }, + "observed_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "status", + "blocked", + "not_configured", + "observed_at" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "tags": [ + "Health" + ] + } + }, + "/api/v1/release-change-sets": { + "get": { + "tags": [ + "Release" + ], + "operationId": "listReleaseChangeSets", + "summary": "分页列出版本化发布计划集", + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + }, + "description": "每页数量,默认20上限100" + }, + { + "in": "query", + "name": "offset", + "schema": { + "type": "integer" + }, + "description": "偏移" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "tags": [ + "Release" + ], + "operationId": "generateReleaseChangeSet", + "summary": "把当前实时发布计划固化为不可变版本化ChangeSet", + "description": "生成即冻结:绑定当前head快照集与确认过的目录ChangeSet集,按精确字段顺序与分隔符计算清单摘要。相同计划并发去重(唯一索引冲突则复用最新planned或取下一版本)。来源刷新绝不可能改变已固化清单。", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "created_by": { + "type": "string" + } + }, + "required": [ + "name", + "created_by" + ] + } + } + } + }, + "responses": { + "201": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/release-change-sets/latest": { + "get": { + "tags": [ + "Release" + ], + "operationId": "getLatestReleaseChangeSet", + "summary": "最新发布计划集(含冻结逐项清单)", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/release-change-sets/{id}": { + "get": { + "tags": [ + "Release" + ], + "operationId": "getReleaseChangeSet", + "summary": "按ID读取发布计划集与清单", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/release-change-sets/{id}/confirm": { + "post": { + "tags": [ + "Release" + ], + "operationId": "confirmReleaseChangeSet", + "summary": "冻结planned发布计划集供正式批次引用", + "description": "planned转confirmed幂等重放;非法状态迁移返回409。确认后清单与摘要永不改变。", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "confirmed_by": { + "type": "string" + } + }, + "required": [ + "confirmed_by" + ] + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/release-executions/{id}/audit-chain": { + "get": { + "tags": [ + "Release" + ], + "operationId": "getReleaseAuditChain", + "summary": "发布审计链(R6 只读投影)", + "description": "一次执行可追溯到批次、已确认发布计划集、绑定的来源快照与目录变更集、逐项审核/验收/来源身份,以及补偿回滚行。所有链路通过真实外键读取(迁移30),只返回安全标识、名称、计数与时间戳,绝不返回Bundle内部路径、凭据、Base URL或对象签名URL。", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "完整证据链", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-connections/{id}/settings/revisions": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "来源连接 ID" + } + ], + "get": { + "operationId": "listSourceSettingsRevisions", + "summary": "来源设置修订历史(V1)", + "description": "validated→active→retired 完整历史;仅安全投影:credential_ref 是凭据引用名,真实凭据不可回读。", + "tags": [ + "governance" + ], + "responses": { + "200": { + "description": "修订列表", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "revision_number": { + "type": "integer" + }, + "state": { + "enum": [ + "validated", + "active", + "retired", + "failed" + ] + }, + "name": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "credential_ref": { + "type": "string", + "description": "引用名,非密钥本身" + }, + "enabled": { + "type": "boolean" + }, + "refresh_interval_seconds": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "validated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "activated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "retired_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + } + }, + "operation": { + "enum": [ + "created", + "replayed" + ] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createSourceSettingsRevision", + "summary": "创建来源设置草稿修订(V1)", + "description": "校验通过落为 validated 修订,不影响运行态;需要 X-Governance-Action: source-settings 与 Idempotency-Key。", + "tags": [ + "governance" + ], + "parameters": [ + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string", + "maxLength": 128 + } + }, + { + "in": "header", + "name": "X-Governance-Action", + "required": true, + "schema": { + "type": "string", + "enum": [ + "source-settings" + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "source_type", + "name", + "operator", + "schedule" + ], + "additionalProperties": false, + "properties": { + "source_type": { + "enum": [ + "pp", + "alist", + "dingtalk", + "manual" + ] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "endpoint": { + "type": "string", + "maxLength": 2048 + }, + "credential_ref": { + "type": "string", + "maxLength": 255, + "description": "密钥引用名;真实凭据永不入库、永不回显" + }, + "enabled": { + "type": "boolean" + }, + "schedule": { + "type": "object", + "additionalProperties": false, + "properties": { + "refresh_interval_seconds": { + "type": "integer", + "nullable": true, + "description": "缺省/0=仅手动;非零最低300秒" + } + } + }, + "operator": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "修订已创建", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + "operation": { + "enum": [ + "created", + "replayed" + ] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/source-connections/{id}/settings/revisions/{revision_id}/activate": { + "post": { + "operationId": "activateSourceSettingsRevision", + "summary": "激活来源设置修订(V1)", + "description": "原子切换:退休旧 active、同步运行态快照、由服务端按周期推导下次刷新时间;停用立即清空到期时间(停止新扫描与新任务),历史全部保留。", + "tags": [ + "governance" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "revision_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string", + "maxLength": 128 + } + }, + { + "in": "header", + "name": "X-Governance-Action", + "required": true, + "schema": { + "type": "string", + "enum": [ + "source-settings" + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 0 + } + } + } + }, + "responses": { + "200": { + "description": "已激活", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "properties": { + "connection_id": { + "type": "string" + }, + "revision_id": { + "type": "string" + } + } + }, + "operation": { + "enum": [ + "created", + "replayed" + ] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/test-publications/{id}/removal": { + "parameters": [ + { + "$ref": "#/components/parameters/ID" + } + ], + "post": { + "tags": [ + "Test processing" + ], + "operationId": "startTestPublicationRemoval", + "summary": "把被禁止导入但仍存活在测试库的知识置为下架(入队既有 test.remove 工作流)", + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "responses": { + "202": { + "$ref": "#/components/responses/OperationEnvelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } } + }, + "components": { + "parameters": { + "ID": { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "maxLength": 64 + } + }, + "IdempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "description": "每次用户动作使用一个新键;仅网络重试复用同一键。不得包含凭据或业务内容。", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "ConnectionID": { + "name": "connection_id", + "in": "query", + "required": true, + "schema": { + "type": "string", + "maxLength": 64 + } + }, + "Limit": { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + "Offset": { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 1000000, + "default": 0 + } + }, + "State": { + "name": "state", + "in": "query", + "schema": { + "type": "string", + "maxLength": 32 + } + } + }, + "responses": { + "Envelope": { + "description": "安全数据响应", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Envelope" + } + } + } + }, + "OperationEnvelope": { + "description": "可安全重试的目录操作响应;operation=created 表示首次执行,replayed 表示返回原操作结果。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperationEnvelope" + } + } + } + }, + "BadRequest": { + "description": "请求参数或状态非法", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Forbidden": { + "description": "安全门禁或权限拒绝", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "NotFound": { + "description": "资源不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "Conflict": { + "description": "资源已变更、状态冲突或重复动作", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "InternalError": { + "description": "安全的服务端错误码", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "IdempotencyConflict": { + "description": "资源已变更、状态冲突或重复动作", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "schemas": { + "Health": { + "type": "object", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "const": "ok" + } + } + }, + "Envelope": { + "type": "object", + "additionalProperties": false, + "required": [ + "data" + ], + "properties": { + "data": { + "description": "端点定义的安全投影" + } + } + }, + "OperationEnvelope": { + "type": "object", + "additionalProperties": false, + "required": [ + "data", + "operation" + ], + "properties": { + "data": { + "description": "端点定义的安全投影" + }, + "operation": { + "type": "string", + "enum": [ + "created", + "replayed" + ] + } + } + }, + "Error": { + "type": "object", + "additionalProperties": false, + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string", + "maxLength": 512 + } + } + }, + "JobCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "idempotency_key" + ], + "properties": { + "type": { + "type": "string", + "maxLength": 100 + }, + "payload": { + "type": "object" + }, + "priority": { + "type": "integer" + }, + "max_attempts": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "idempotency_key": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + }, + "CatalogRouteRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "connection_id", + "node_ids", + "operator_ref", + "reason" + ], + "properties": { + "connection_id": { + "type": "string", + "maxLength": 64 + }, + "node_ids": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "maxLength": 64 + } + }, + "policy": { + "type": "string", + "enum": [ + "internal", + "external", + "both", + "blocked" + ] + }, + "visibility": { + "type": "string", + "enum": [ + "internal", + "external", + "both", + "blocked" + ] + }, + "operator_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + }, + "ScopeRuleMutation": { + "type": "object", + "additionalProperties": false, + "required": [ + "target_type", + "operator_ref", + "reason" + ], + "properties": { + "target_type": { + "type": "string", + "enum": [ + "directory", + "item" + ] + }, + "connection_id": { + "type": "string", + "maxLength": 64 + }, + "directory_path": { + "type": "string", + "maxLength": 4096 + }, + "source_item_id": { + "type": "string", + "maxLength": 64 + }, + "visibility": { + "type": "string", + "enum": [ + "internal", + "external", + "both", + "blocked" + ] + }, + "operator_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + }, + "ReviewDecisionRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "decision", + "decided_visibility", + "reviewer_ref", + "decision_code" + ], + "properties": { + "decision": { + "type": "string", + "enum": [ + "approved", + "rejected" + ] + }, + "decided_visibility": { + "type": "string", + "enum": [ + "internal", + "external" + ] + }, + "reviewer_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "decision_code": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + } + }, + "ReleaseBatchCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "created_by" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "created_by": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "publication_ids": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "maxLength": 64 + } + }, + "delete_publication_ids": { + "type": "array", + "maxItems": 100, + "items": { + "type": "string", + "maxLength": 64 + } + }, + "release_change_set_id": { + "type": "string", + "description": "R1正式批次绑定的confirmed发布计划集ID;绑定时所有请求项必须在该集冻结清单内且可发布,否则422" + } + } + }, + "ReleaseBatchSealRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "sealed_by" + ], + "properties": { + "sealed_by": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "UISettingsCandidateRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "document_refresh_seconds", + "resource_refresh_seconds", + "stalled_after_seconds", + "finalizing_after_seconds", + "updated_by" + ], + "properties": { + "document_refresh_seconds": { + "type": "integer", + "enum": [ + 15, + 30, + 60 + ] + }, + "resource_refresh_seconds": { + "type": "integer", + "enum": [ + 30, + 60 + ] + }, + "stalled_after_seconds": { + "type": "integer", + "enum": [ + 1800, + 3600 + ] + }, + "finalizing_after_seconds": { + "type": "integer", + "enum": [ + 7200, + 14400 + ] + }, + "updated_by": { + "type": "string", + "minLength": 2, + "maxLength": 128 + } + } + }, + "StartTestPublicationsRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "publication_ids", + "operator_ref", + "reason" + ], + "properties": { + "publication_ids": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "string", + "maxLength": 64 + } + }, + "operator_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + }, + "ConfirmChangeSetRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "item_seqs", + "operator_ref", + "reason" + ], + "properties": { + "item_seqs": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "items": { + "type": "integer", + "minimum": 1 + } + }, + "operator_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + } + }, + "JobWindowMetrics": { + "type": "object", + "properties": { + "window_seconds": { + "type": "integer" + }, + "jobs_started": { + "type": "integer" + }, + "jobs_completed": { + "type": "integer" + }, + "jobs_failed": { + "type": "integer" + }, + "success_rate": { + "type": [ + "number", + "null" + ] + }, + "failure_rate": { + "type": [ + "number", + "null" + ] + }, + "duration_avg_seconds": { + "type": [ + "number", + "null" + ] + }, + "duration_p50_seconds": { + "type": [ + "number", + "null" + ] + }, + "duration_p95_seconds": { + "type": [ + "number", + "null" + ] + } + } + } + } + } } diff --git a/docs/USER_FLOWS_ACCEPTANCE.md b/docs/USER_FLOWS_ACCEPTANCE.md index 8e7c250..d89587c 100644 --- a/docs/USER_FLOWS_ACCEPTANCE.md +++ b/docs/USER_FLOWS_ACCEPTANCE.md @@ -164,6 +164,23 @@ - ⚠️ 过程中踩到并修掉 **Bug #6**(见 F.3h):禁止导入只写 `source_items.policy_state`,而**两处**"期望策略"解析 (计划 + 建批 delete 就绪判定)都不看它 → 计划不给 delete、建批直接 422 +### C5 测试库侧移除(解析监控 → 需下架 → 逐条下架)★2026-10-10 首次实测 + +- 入口:**解析监控 → 需下架** 车道逐条列出「来源侧被判禁止导入、但知识仍存活在测试库里」的条目 + (`GET /api/v1/test-publications?removal=required`,口径与车道计数同源)→ 每条 **下架(从测试库移除)** + → 页内二次确认(**取消在左、确认在右**)→ 走既有 `test.remove` 工作流(删除测试库知识并读回校验) +- 期望:条目置 `disabling` → 工作流完成后 `disabled`;车道计数递减;生产知识库不受影响 +- 证据(2026-10-10,两条都经界面走完): + - 点击前车道 `需下架(2)`:`相机线束固定孔位尺寸图.pdf`、`DH-AG95夹爪适配-指令协议.md`(与直接查库的候选一致) + - 第 1 条(PDF):点下架 → 车道回到 1 → `test_publications.retrieval_state=disabled`(`03:57:03Z`); + `jobs` `test.remove` / `completed` / attempt 2(第 1 次 `test_remove_pending`=远端仍在处理,重试通过); + 工作流以 **`GetKnowledge` 读回 404** 判成功 + - 第 2 条(md):点下架 → **确认态出现但未执行**(核库:仍 `unpublished`、移除任务数仍 1)→ 点确认 + → `disabling` → `disabled`(`04:08:55Z`);job `job-67e7e62c…` `completed`(attempt 2) + - 界面终态:`需下架(0)` + 空态文案;页面提示「已受理下架…」 +- ⚠️ 过程中踩到并修掉 **Bug #7**(二次确认按钮出现在原按钮位置,连点即误执行;已把「取消」放左) + 与 **Bug #8**(`jobs` 部分唯一索引 + 空串 business_* → 第二次移除被静默吞掉、条目永久卡死;已改 NULL + 落行校验) + ### C3 查看结果 → 回滚 - 入口:**发布管理 → 查看结果** → 需要时 **回滚** → 确认语句 `rollback-production-release` - 期望:回滚后目标库该知识停用(**不删知识行**),wiki 页保持 @@ -284,8 +301,9 @@ 唯一约束 `uq_ui_setting_active` 只允许一条 active)恢复,两条 update 各影响 1 行,并复核线上值 ✓; 根因修复=给该用例加 per-test schema 隔离(照 `internal/monitoring`),复跑后线上设置不再被改动 ✓。 **规矩**:任何"创建并激活"的集成测试必须建独立 schema;只判 `TEST_DATABASE_URL` 存在就写共享库的用例一律视为危险。 -3i. **测试库侧的移除仍无页面入口**:生产侧下架已可通过发布通道完成(见 C2b);但"禁止导入后从**测试库**移除知识" - 仍只能靠 `test.remove` 工作流(变更集删除路径触发),需下架车道也只有按库计数、没有条目级列表与按钮。 +3i. ~~**测试库侧的移除仍无页面入口**~~ **已闭环(2026-10-10)**:解析监控 → 需下架 现在逐条列出条目, + 每条可「下架(从测试库移除)」并二次确认(`POST /api/v1/test-publications/{id}/removal`,engineer 权限、 + 写路由需 `Idempotency-Key`、按条目幂等);实测两条走通(见 C5)。 3e. **发布/回滚结果页不自动刷新**:Release Worker 完成后,`发布管理` 的执行行仍显示"待执行"(首次与第二遍都遇到), 需手动 `刷新当前页面`/reload 才看到 `已完成`/`已回滚`。后端状态是对的(DB 已是终态),属展示层轮询缺口。 3d. **`release_executions.snapshot_verification_id` 为空**:发布前快照门禁确实生效(缺少/过期会 `release_target_snapshot_missing`),但执行行没有记下"用的是哪份快照",追踪性待补。 diff --git a/internal/governance/test_publication.go b/internal/governance/test_publication.go index b07c045..4b21a73 100644 --- a/internal/governance/test_publication.go +++ b/internal/governance/test_publication.go @@ -28,8 +28,11 @@ type TestPublicationFilter struct { ProcessingState string // TargetScope filters the test queue by KB routing target (P4). TargetScope string - Limit int - Offset int + // RemovalRequired 只取「被禁止导入、但仍在测试库里存活」的条目: + // 解析监控「需下架」车道用它列出待移除的具体条目(此前只有按库计数)。 + RemovalRequired bool + Limit int + Offset int } type TestPublicationPage struct { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 55b7c69..77b8f0a 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -447,6 +447,11 @@ func NewHandler(config Config) (http.Handler, error) { mux.HandleFunc("GET /api/v1/test-publications", func(w http.ResponseWriter, r *http.Request) { listTestPublications(w, r, config.Store) }) + // 需下架条目的移除入口:把「被禁止导入但仍存活在测试库」的知识从测试库移除 + // (置 disabling + 入队既有 test.remove 工作流)。与开始处理同级:engineer 即可。 + mux.HandleFunc("POST /api/v1/test-publications/{id}/removal", func(w http.ResponseWriter, r *http.Request) { + startTestPublicationRemoval(w, r, config) + }) mux.HandleFunc("POST /api/v1/test-publications/start", func(w http.ResponseWriter, r *http.Request) { startTestPublications(w, r, config) }) @@ -1966,6 +1971,13 @@ func getReleaseBatch(w http.ResponseWriter, r *http.Request, store Store) { func listTestPublications(w http.ResponseWriter, r *http.Request, store Store) { filter := governance.TestPublicationFilter{ProcessingState: r.URL.Query().Get("processing_state"), TargetScope: r.URL.Query().Get("target_scope"), Limit: 50} + if raw := r.URL.Query().Get("removal"); raw != "" { + if raw != "required" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "removal must be required"}) + return + } + filter.RemovalRequired = true + } if filter.ProcessingState != "" && !validProcessingStateFilter(filter.ProcessingState) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "processing_state must be a comma-separated list of planned, pending, processing, finalizing, completed, failed, stalled, or cancelled"}) return diff --git a/internal/httpapi/test_publication_removal.go b/internal/httpapi/test_publication_removal.go new file mode 100644 index 0000000..790eb87 --- /dev/null +++ b/internal/httpapi/test_publication_removal.go @@ -0,0 +1,51 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + postgresstore "knowledge-governance/internal/platform/postgres" +) + +// testPublicationRemovalStarter 是本路由需要的最小能力面。 +// +// 真实 Store 实现它;httpapi 的既有 fake 不必实现(断言失败时路由返回 501, +// 而不是把"没接线"当成成功)。这样避免为一条新路由改动庞大的 Store 接口、连带改掉所有 fake。 +type testPublicationRemovalStarter interface { + StartTestPublicationRemoval(context.Context, string, time.Time) (string, error) +} + +// startTestPublicationRemoval 处理「解析监控 → 需下架 → 移除」: +// 把被禁止导入、但仍在测试库里存活的知识置为 disabling,并交给既有的 test.remove 工作流 +// 去真正删除远端知识并读回校验。语义与变更集删除路径一致,只是由运维在页面上逐个触发。 +func startTestPublicationRemoval(w http.ResponseWriter, r *http.Request, config Config) { + if _, ok := requireOperationID(w, r); !ok { + return + } + publicationID := strings.TrimSpace(r.PathValue("id")) + if !validCatalogID(publicationID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid publication id"}) + return + } + starter, ok := config.Store.(testPublicationRemovalStarter) + if !ok { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "test publication removal is not wired"}) + return + } + jobID, err := starter.StartTestPublicationRemoval(r.Context(), publicationID, config.Now().UTC()) + switch { + case err == nil: + writeJSON(w, http.StatusAccepted, map[string]string{"publication_id": publicationID, "job_id": jobID, "state": "disabling"}) + case errors.Is(err, postgresstore.ErrPublicationNotFound): + writeJSON(w, http.StatusNotFound, map[string]string{"error": "publication not found"}) + case errors.Is(err, postgresstore.ErrPublicationRemovalDone): + writeJSON(w, http.StatusConflict, map[string]string{"error": "该知识的检索已经移除,无需重复下架"}) + case errors.Is(err, postgresstore.ErrPublicationStateConflict): + writeJSON(w, http.StatusConflict, map[string]string{"error": "只有仍在测试库存活的知识才能移除"}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not start test publication removal"}) + } +} diff --git a/internal/httpapi/test_publication_removal_test.go b/internal/httpapi/test_publication_removal_test.go new file mode 100644 index 0000000..628a100 --- /dev/null +++ b/internal/httpapi/test_publication_removal_test.go @@ -0,0 +1,133 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "knowledge-governance/internal/platform/postgres" +) + +// removalFakeStore 在既有 fakeJobStore 之上补出「移除入口」这一条能力, +// 用来验证路由的权限、幂等键要求与错误映射。 +type removalFakeStore struct { + fakeJobStore + capturedPublicationID string + jobID string + err error +} + +func (s *removalFakeStore) StartTestPublicationRemoval(_ context.Context, publicationID string, _ time.Time) (string, error) { + s.capturedPublicationID = publicationID + return s.jobID, s.err +} + +// storeWithoutRemoval 只满足大接口、没有移除能力:路由必须诚实报 501, +// 而不是把"没接线"当成成功。 +type storeWithoutRemoval struct{ Store } + +func TestStartTestPublicationRemovalHandler(t *testing.T) { + store := &removalFakeStore{jobID: "job-remove-1"} + handler, err := NewHandler(Config{Store: store}) + if err != nil { + t.Fatal(err) + } + + // 缺 Idempotency-Key:写路由一律要求幂等键。 + missing := httptest.NewRecorder() + handler.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/api/v1/test-publications/tp-1/removal", nil)) + if missing.Code != http.StatusBadRequest { + t.Fatalf("missing idempotency key status=%d", missing.Code) + } + + // 非法 id。 + badID := httptest.NewRecorder() + badRequest := httptest.NewRequest(http.MethodPost, "/api/v1/test-publications/bad%20id/removal", nil) + badRequest.Header.Set("Idempotency-Key", "op-remove-1") + handler.ServeHTTP(badID, badRequest) + if badID.Code != http.StatusBadRequest { + t.Fatalf("invalid id status=%d", badID.Code) + } + + // 正常受理。 + accepted := httptest.NewRecorder() + acceptedRequest := httptest.NewRequest(http.MethodPost, "/api/v1/test-publications/tp-1/removal", nil) + acceptedRequest.Header.Set("Idempotency-Key", "op-remove-2") + handler.ServeHTTP(accepted, acceptedRequest) + if accepted.Code != http.StatusAccepted { + t.Fatalf("accepted status=%d body=%s", accepted.Code, accepted.Body.String()) + } + var body map[string]string + if err := json.Unmarshal(accepted.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["publication_id"] != "tp-1" || body["job_id"] != "job-remove-1" || body["state"] != "disabling" { + t.Fatalf("accepted body=%v", body) + } + if store.capturedPublicationID != "tp-1" { + t.Fatalf("store saw %q", store.capturedPublicationID) + } + + // 错误映射。 + for _, testCase := range []struct { + name string + err error + status int + }{ + {"not found", postgres.ErrPublicationNotFound, http.StatusNotFound}, + {"already removed", postgres.ErrPublicationRemovalDone, http.StatusConflict}, + {"state conflict", postgres.ErrPublicationStateConflict, http.StatusConflict}, + {"unexpected", errors.New("boom"), http.StatusInternalServerError}, + } { + failing := &removalFakeStore{err: testCase.err} + failingHandler, err := NewHandler(Config{Store: failing}) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/test-publications/tp-1/removal", nil) + request.Header.Set("Idempotency-Key", "op-remove-3") + failingHandler.ServeHTTP(recorder, request) + if recorder.Code != testCase.status { + t.Fatalf("%s status=%d want=%d", testCase.name, recorder.Code, testCase.status) + } + } + + // 未接线:诚实 501。 + unwired, err := NewHandler(Config{Store: storeWithoutRemoval{Store: store}}) + if err != nil { + t.Fatal(err) + } + unwiredRecorder := httptest.NewRecorder() + unwiredRequest := httptest.NewRequest(http.MethodPost, "/api/v1/test-publications/tp-1/removal", nil) + unwiredRequest.Header.Set("Idempotency-Key", "op-remove-4") + unwired.ServeHTTP(unwiredRecorder, unwiredRequest) + if unwiredRecorder.Code != http.StatusNotImplemented { + t.Fatalf("unwired status=%d", unwiredRecorder.Code) + } +} + +func TestListTestPublicationsRemovalFilter(t *testing.T) { + store := &fakeJobStore{} + handler, err := NewHandler(Config{Store: store}) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/test-publications?removal=required", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d", recorder.Code) + } + if !store.publicationFilter.RemovalRequired { + t.Fatalf("filter=%#v, want RemovalRequired", store.publicationFilter) + } + bogus := httptest.NewRecorder() + handler.ServeHTTP(bogus, httptest.NewRequest(http.MethodGet, "/api/v1/test-publications?removal=bogus", nil)) + if bogus.Code != http.StatusBadRequest { + t.Fatalf("bogus removal status=%d", bogus.Code) + } +} diff --git a/internal/platform/postgres/test_publication_removal.go b/internal/platform/postgres/test_publication_removal.go new file mode 100644 index 0000000..a9d7eb1 --- /dev/null +++ b/internal/platform/postgres/test_publication_removal.go @@ -0,0 +1,136 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// ErrPublicationRemovalDone:该发布单元的检索已经移除过(路由映射 409; +// 条目不存在复用既有的 ErrPublicationNotFound,状态不允许复用 ErrPublicationStateConflict)。 +var ErrPublicationRemovalDone = errors.New("test publication retrieval already removed") + +// StartTestPublicationRemoval 让运维把一个「被禁止导入、但仍存活在测试库」的知识从测试库里移除: +// 把该发布单元置为 disabling 并入队既有的 test.remove 工作流(该工作流会调 WeKnora 删除知识并读回校验, +// 只有它能把状态推进到 disabled/failed)。 +// +// 为什么要有这条入口:禁止导入本身只撤销待入库条目并置 policy_state='blocked',测试库里已经建好的知识 +// 不会自动消失;此前只有"变更集删除路径"会入队 test.remove,界面既列不出条目也没有按钮,运维无法闭环。 +// +// 幂等:按 publication 固定 idempotency_key(ON CONFLICT DO NOTHING)。重复调用(含并发与用户重试) +// 只会有一个任务;已在 disabling 时直接返回既有任务 id;已 disabled 时报 ErrPublicationRemovalDone。 +func (s *Store) StartTestPublicationRemoval(ctx context.Context, publicationID string, now time.Time) (string, error) { + publicationID = strings.TrimSpace(publicationID) + if publicationID == "" || len(publicationID) > 128 || strings.ContainsAny(publicationID, "\x00\r\n") { + return "", ErrPublicationNotFound + } + beginner, ok := s.db.(interface { + Begin(context.Context) (pgx.Tx, error) + }) + if !ok { + return "", errors.New("test publication removal transactions are unavailable") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return "", fmt.Errorf("begin test publication removal: %w", err) + } + defer tx.Rollback(ctx) + + var retrievalState, knowledgeID, processingState string + var superseded bool + err = tx.QueryRow(ctx, `SELECT retrieval_state, knowledge_id, processing_state, superseded_at IS NOT NULL +FROM test_publications WHERE id=$1 FOR UPDATE`, publicationID).Scan(&retrievalState, &knowledgeID, &processingState, &superseded) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrPublicationNotFound + } + if err != nil { + return "", fmt.Errorf("load test publication for removal: %w", err) + } + key := "operator-remove:" + publicationID + if retrievalState == "disabled" { + return "", ErrPublicationRemovalDone + } + if retrievalState == "disabling" { + // 已经在移除中:返回既有任务,不重复入队。 + var existingJobID string + if err := tx.QueryRow(ctx, `SELECT id FROM jobs WHERE idempotency_key=$1`, key).Scan(&existingJobID); err == nil { + if err := tx.Commit(ctx); err != nil { + return "", fmt.Errorf("commit test publication removal: %w", err) + } + return existingJobID, nil + } + } + if superseded || knowledgeID == "" || processingState != "completed" { + // 只处理"测试库里确实活着"的条目:没上传过的走撤销,不进这里。 + return "", ErrPublicationStateConflict + } + tag, err := tx.Exec(ctx, `UPDATE test_publications SET retrieval_state='disabling', last_error='', updated_at=$2 WHERE id=$1`, publicationID, now) + if err != nil { + return "", fmt.Errorf("mark test publication removal: %w", err) + } + if tag.RowsAffected() != 1 { + return "", ErrPublicationStateConflict + } + jobID, err := newOpaqueID("job-") + if err != nil { + return "", err + } + payload, err := json.Marshal(map[string]string{"publication_id": publicationID}) + if err != nil { + return "", errors.New("encode test publication removal job") + } + // business_* 三列必须写 NULL,不能写空串:jobs 上有部分唯一索引 + // UNIQUE (business_change_set_id, business_version_id, business_target_kb_id, business_action) + // WHERE business_change_set_id IS NOT NULL + // 变更集路径写的是真实 id,空串则是"非 NULL",于是 ('','','','remove') 对**所有**运维移除都是同一个键, + // 第一条之后每次插入都撞唯一索引;而 ON CONFLICT DO NOTHING 会把它静默吞掉,留下"状态已 disabling、 + // 却没有任务"的永久卡死条目(车道还看不见它)。NULL 表达"没有变更集身份",索引因此不适用,幂等只由 + // 下面的 idempotency_key 决定。 + tag, err = tx.Exec(ctx, `INSERT INTO jobs(id,type,payload,state,priority,available_at,max_attempts,idempotency_key,created_at,updated_at,business_change_set_id,business_version_id,business_target_kb_id,business_action) +VALUES($1,'test.remove',$2,'pending',20,$3,10,$4,$3,$3,NULL,NULL,NULL,'remove') +ON CONFLICT (idempotency_key) DO NOTHING`, jobID, payload, now, key) + if err != nil { + return "", fmt.Errorf("enqueue test publication removal: %w", err) + } + if tag.RowsAffected() != 1 { + // 键已存在(并发重复调用):必须是同一个任务,不能留下没有任务的 disabling。 + var existingJobID string + if err := tx.QueryRow(ctx, `SELECT id FROM jobs WHERE idempotency_key=$1`, key).Scan(&existingJobID); err != nil { + return "", fmt.Errorf("test publication removal job was not enqueued: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return "", fmt.Errorf("commit test publication removal: %w", err) + } + return existingJobID, nil + } + if err := tx.Commit(ctx); err != nil { + return "", fmt.Errorf("commit test publication removal: %w", err) + } + return jobID, nil +} + +func mustJSONString(value string) string { + var builder strings.Builder + builder.WriteByte('"') + for _, r := range value { + switch r { + case '"': + builder.WriteString(`\"`) + case '\\': + builder.WriteString(`\\`) + default: + if r < 0x20 { + builder.WriteString(fmt.Sprintf(`\u%04x`, r)) + continue + } + builder.WriteRune(r) + } + } + builder.WriteByte('"') + return builder.String() +} diff --git a/internal/platform/postgres/test_publication_removal_integration_test.go b/internal/platform/postgres/test_publication_removal_integration_test.go new file mode 100644 index 0000000..0eaf47a --- /dev/null +++ b/internal/platform/postgres/test_publication_removal_integration_test.go @@ -0,0 +1,225 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "knowledge-governance/internal/governance" +) + +// TestStartTestPublicationRemovalIntegration 固化「需下架 → 移除」这条运维闭环的服务端语义: +// 置 disabling + 入队既有 test.remove 工作流(该工作流负责真正删远端知识并读回校验), +// 且必须幂等——重复点击/并发只有一个任务;已移除、未上传、已 supersede 的条目要明确拒绝。 +func TestStartTestPublicationRemovalIntegration(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, 10, 10, 12, 0, 0, 0, time.UTC) + if _, err := pool.Exec(ctx, `INSERT INTO source_connections (id, source_type, name) VALUES ('conn-manual', 'manual', 'Manual')`); err != nil { + t.Fatal(err) + } + insertReviewFixture(t, ctx, pool, 2, "tp-removal", now) + insertReviewFixture(t, ctx, pool, 3, "tp-planned", now) + // 「未上传」的条目:没有 knowledge id,只能走撤销,不能走移除。 + if _, err := pool.Exec(ctx, `UPDATE test_publications SET knowledge_id='', ingest_state='pending', processing_state='planned' WHERE id='tp-planned'`); err != nil { + t.Fatal(err) + } + + jobID, err := store.StartTestPublicationRemoval(ctx, "tp-removal", now) + if err != nil || jobID == "" { + t.Fatalf("first removal job=%q error=%v", jobID, err) + } + var retrievalState string + if err := pool.QueryRow(ctx, `SELECT retrieval_state FROM test_publications WHERE id='tp-removal'`).Scan(&retrievalState); err != nil { + t.Fatal(err) + } + if retrievalState != "disabling" { + t.Fatalf("retrieval_state=%q, want disabling", retrievalState) + } + var jobType, payload, key string + var jobCount int + if err := pool.QueryRow(ctx, `SELECT type, payload::text, idempotency_key FROM jobs WHERE id=$1`, jobID).Scan(&jobType, &payload, &key); err != nil { + t.Fatal(err) + } + var decoded struct { + PublicationID string `json:"publication_id"` + } + if err := json.Unmarshal([]byte(payload), &decoded); err != nil || decoded.PublicationID != "tp-removal" { + t.Fatalf("job payload=%q error=%v", payload, err) + } + if jobType != "test.remove" || key != "operator-remove:tp-removal" { + t.Fatalf("job type=%q key=%q", jobType, key) + } + + // 重复调用(此时已是 disabling)必须返回同一个任务,且不会多入队。 + again, err := store.StartTestPublicationRemoval(ctx, "tp-removal", now.Add(time.Minute)) + if err != nil || again != jobID { + t.Fatalf("repeat removal job=%q error=%v, want %q", again, err, jobID) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*)::integer FROM jobs WHERE idempotency_key='operator-remove:tp-removal'`).Scan(&jobCount); err != nil { + t.Fatal(err) + } + if jobCount != 1 { + t.Fatalf("removal jobs=%d, want 1", jobCount) + } + + // 已移除:明确 409 语义,而不是静默成功。 + if _, err := pool.Exec(ctx, `UPDATE test_publications SET retrieval_state='disabled' WHERE id='tp-removal'`); err != nil { + t.Fatal(err) + } + if _, err := store.StartTestPublicationRemoval(ctx, "tp-removal", now); !errors.Is(err, ErrPublicationRemovalDone) { + t.Fatalf("already removed error=%v, want ErrPublicationRemovalDone", err) + } + // 未上传:不可移除。 + if _, err := store.StartTestPublicationRemoval(ctx, "tp-planned", now); !errors.Is(err, ErrPublicationStateConflict) { + t.Fatalf("planned removal error=%v, want ErrPublicationStateConflict", err) + } + // 不存在的条目。 + if _, err := store.StartTestPublicationRemoval(ctx, "tp-missing", now); !errors.Is(err, ErrPublicationNotFound) { + t.Fatalf("missing removal error=%v, want ErrPublicationNotFound", err) + } +} + +// TestStartTestPublicationRemovalEnqueuesOneJobPerPublication 固化踩到过的陷阱: +// jobs 上有部分唯一索引 UNIQUE (business_change_set_id, business_version_id, business_target_kb_id, business_action) +// WHERE business_change_set_id IS NOT NULL。运维移除若把 business_* 写成空串(非 NULL), +// (”,”,”,'remove') 对所有条目都是同一个键——第一条之后每次插入都撞唯一索引,被 ON CONFLICT DO NOTHING +// 静默吞掉,留下"状态 disabling、没有任务"的永久卡死条目。这里断言:两条不同的移除各自都能入队, +// 且 business_* 必须是 NULL;外加"只有 disabling 没有任务"的半成品状态能被重试自愈。 +func TestStartTestPublicationRemovalEnqueuesOneJobPerPublication(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, 10, 10, 12, 0, 0, 0, time.UTC) + if _, err := pool.Exec(ctx, `INSERT INTO source_connections (id, source_type, name) VALUES ('conn-manual', 'manual', 'Manual')`); err != nil { + t.Fatal(err) + } + insertReviewFixture(t, ctx, pool, 8, "tp-remove-a", now) + insertReviewFixture(t, ctx, pool, 9, "tp-remove-b", now) + + firstJob, err := store.StartTestPublicationRemoval(ctx, "tp-remove-a", now) + if err != nil { + t.Fatal(err) + } + secondJob, err := store.StartTestPublicationRemoval(ctx, "tp-remove-b", now) + if err != nil { + t.Fatalf("second removal must still be enqueued: %v", err) + } + if firstJob == secondJob { + t.Fatalf("both removals collapsed onto one job %q", firstJob) + } + var jobCount int + if err := pool.QueryRow(ctx, `SELECT COUNT(*)::integer FROM jobs WHERE type='test.remove'`).Scan(&jobCount); err != nil { + t.Fatal(err) + } + if jobCount != 2 { + t.Fatalf("test.remove jobs=%d, want 2", jobCount) + } + var nullBusinessColumns int + if err := pool.QueryRow(ctx, `SELECT COUNT(*)::integer FROM jobs WHERE id=$1 AND business_change_set_id IS NULL AND business_version_id IS NULL AND business_target_kb_id IS NULL`, secondJob).Scan(&nullBusinessColumns); err != nil { + t.Fatal(err) + } + if nullBusinessColumns != 1 { + t.Fatalf("business_* columns must be NULL so the partial unique index does not apply") + } + + // 半成品状态(只有 disabling、没有任务)必须能被重试自愈,而不是永久卡死。 + if _, err := pool.Exec(ctx, `DELETE FROM jobs WHERE idempotency_key='operator-remove:tp-remove-a'`); err != nil { + t.Fatal(err) + } + healed, err := store.StartTestPublicationRemoval(ctx, "tp-remove-a", now.Add(time.Minute)) + if err != nil || healed == "" { + t.Fatalf("stuck removal must heal on retry: job=%q error=%v", healed, err) + } + if err := pool.QueryRow(ctx, `SELECT COUNT(*)::integer FROM jobs WHERE idempotency_key='operator-remove:tp-remove-a'`).Scan(&jobCount); err != nil { + t.Fatal(err) + } + if jobCount != 1 { + t.Fatalf("healed removal jobs=%d, want 1", jobCount) + } +} + +// TestListTestPublicationsRemovalRequiredIntegration 固化「需下架」车道的条目列表口径: +// 只取「被禁止导入 + 仍在测试库存活」的条目;未禁止的、已在移除中的、未上传的都不应出现。 +func TestListTestPublicationsRemovalRequiredIntegration(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, 10, 10, 12, 0, 0, 0, time.UTC) + if _, err := pool.Exec(ctx, `INSERT INTO source_connections (id, source_type, name) VALUES ('conn-manual', 'manual', 'Manual')`); err != nil { + t.Fatal(err) + } + insertReviewFixture(t, ctx, pool, 4, "tp-live-blocked", now) + insertReviewFixture(t, ctx, pool, 5, "tp-live-internal", now) + insertReviewFixture(t, ctx, pool, 6, "tp-live-disabling", now) + insertReviewFixture(t, ctx, pool, 7, "tp-planned-blocked", now) + for _, update := range []string{ + `UPDATE source_items SET policy_state='blocked' WHERE id='si-review-4'`, + `UPDATE source_items SET policy_state='blocked' WHERE id='si-review-6'`, + `UPDATE source_items SET policy_state='blocked' WHERE id='si-review-7'`, + `UPDATE test_publications SET retrieval_state='disabling' WHERE id='tp-live-disabling'`, + `UPDATE test_publications SET knowledge_id='', processing_state='planned' WHERE id='tp-planned-blocked'`, + } { + if _, err := pool.Exec(ctx, update); err != nil { + t.Fatal(err) + } + } + + page, err := store.ListTestPublications(ctx, governance.TestPublicationFilter{RemovalRequired: true, Limit: 50}) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || page.Items[0].ID != "tp-live-blocked" { + t.Fatalf("removal list=%#v", page.Items) + } + if page.Total != 1 { + t.Fatalf("removal total=%d, want 1", page.Total) + } + // 不带过滤时四条都在(过滤是叠加的,不是替换)。 + all, err := store.ListTestPublications(ctx, governance.TestPublicationFilter{Limit: 50}) + if err != nil { + t.Fatal(err) + } + if len(all.Items) != 4 { + t.Fatalf("unfiltered list=%d, want 4", len(all.Items)) + } +} diff --git a/internal/platform/postgres/test_publications.go b/internal/platform/postgres/test_publications.go index 46da3a3..92834b3 100644 --- a/internal/platform/postgres/test_publications.go +++ b/internal/platform/postgres/test_publications.go @@ -117,6 +117,15 @@ const countTestPublicationsSQL = ` SELECT COUNT(*) FROM test_publications WHERE ($1 = '' OR processing_state = ANY(string_to_array($1, ','))) AND ($2 = '' OR target_scope = $2) + AND ($3::boolean IS NOT TRUE OR EXISTS ( + SELECT 1 FROM source_item_versions siv + JOIN source_items si ON si.id = siv.source_item_id + WHERE siv.id = test_publications.source_item_version_id + AND test_publications.superseded_at IS NULL + AND test_publications.knowledge_id <> '' + AND test_publications.processing_state = 'completed' + AND test_publications.retrieval_state NOT IN ('disabling','disabled') + AND si.policy_state = 'blocked')) ` const listTestPublicationsSQL = ` @@ -148,6 +157,9 @@ JOIN source_item_versions siv ON siv.id = p.source_item_version_id JOIN source_items si ON si.id = siv.source_item_id LEFT JOIN content_objects co ON co.id = siv.content_object_id WHERE ($1 = '' OR p.processing_state = ANY(string_to_array($1, ','))) AND ($2 = '' OR p.target_scope = $2) AND p.superseded_at IS NULL + -- removal=required:被禁止导入但仍在测试库存活(口径与监控「需下架」车道一致) + AND ($5::boolean IS NOT TRUE OR (p.knowledge_id <> '' AND p.processing_state = 'completed' + AND p.retrieval_state NOT IN ('disabling','disabled') AND si.policy_state = 'blocked')) ORDER BY p.updated_at DESC, p.id LIMIT $3 OFFSET $4 ` @@ -466,10 +478,10 @@ func (s *Store) ListTestPublications(ctx context.Context, filter governance.Test if filter.TargetScope != "" && filter.TargetScope != string(governance.VisibilityInternal) && filter.TargetScope != string(governance.VisibilityExternal) { return governance.TestPublicationPage{}, errors.New("invalid test publication target scope") } - if err := s.db.QueryRow(ctx, countTestPublicationsSQL, filter.ProcessingState, filter.TargetScope).Scan(&page.Total); err != nil { + if err := s.db.QueryRow(ctx, countTestPublicationsSQL, filter.ProcessingState, filter.TargetScope, filter.RemovalRequired).Scan(&page.Total); err != nil { return governance.TestPublicationPage{}, fmt.Errorf("count test publications: %w", err) } - rows, err := s.db.Query(ctx, listTestPublicationsSQL, filter.ProcessingState, filter.TargetScope, filter.Limit, filter.Offset) + rows, err := s.db.Query(ctx, listTestPublicationsSQL, filter.ProcessingState, filter.TargetScope, filter.Limit, filter.Offset, filter.RemovalRequired) if err != nil { return governance.TestPublicationPage{}, fmt.Errorf("list test publications: %w", err) } diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 0000000..79f98c2 --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"5.0.3","results":[[":web/src/WeKnoraMonitor.test.tsx",{"duration":113.22230200000001,"failed":false}]]} \ No newline at end of file diff --git a/web/src/WeKnoraMonitor.test.tsx b/web/src/WeKnoraMonitor.test.tsx index ad527ca..eba2060 100644 --- a/web/src/WeKnoraMonitor.test.tsx +++ b/web/src/WeKnoraMonitor.test.tsx @@ -34,6 +34,13 @@ describe('WeKnoraMonitorView',()=>{ const removal=renderToStaticMarkup() expect(removal).toContain('需下架(3)') expect(removal).toContain('3 份仍在检索中') + const withItems = renderToStaticMarkup( as never) + expect(withItems).toContain('相机线束固定孔位尺寸图.pdf') + expect(withItems).toContain('下架(从测试库移除)') + // 确认态在视图内部(不依赖容器传参):初始渲染必须是普通按钮,且不得出现确认/取消, + // 避免"点一下就直接执行"的降级。 + expect(withItems).toContain('下架(从测试库移除)') + expect(withItems).not.toContain('确认下架(从测试库移除)') }) it('shows inbox lanes for the knowledge-base diff view',()=>{ const attention=renderToStaticMarkup() diff --git a/web/src/WeKnoraMonitor.tsx b/web/src/WeKnoraMonitor.tsx index 38befb6..6cf5aa6 100644 --- a/web/src/WeKnoraMonitor.tsx +++ b/web/src/WeKnoraMonitor.tsx @@ -3,7 +3,7 @@ import { useContext, useEffect, useMemo, useState } from 'react' import { fetchMonitorFiles, fetchMonitorOverview, fetchRunMetrics } from './monitor' import type { RunMetrics } from './monitor' import type { KnowledgeProgress, MonitorFilePage, MonitorOverview, MonitorStatus } from './monitor' -import { fetchJobFailures, fetchTestPublications, retryPublication, retryPublicationsBatch, cancelPublicationsBatch, requeueJobFailure, startTestPublications } from './testPublications' +import { fetchJobFailures, fetchTestPublications, retryPublication, retryPublicationsBatch, cancelPublicationsBatch, requeueJobFailure, startTestPublications, fetchRemovalRequiredPublications, startPublicationRemoval } from './testPublications' import type { JobFailure, ProcessingState, TestPublication, TestPublicationPage } from './testPublications' import { fetchActiveUISettings, settingsRefreshDelays } from './uiSettings' import { WorkspaceContextTabsSetter } from './workspaceContext' @@ -107,6 +107,11 @@ interface ViewProps { onStart?: (ids: string[]) => void onLoadMore?: () => void onRefresh: () => void + /** 需下架车道的条目列表(被禁止导入但仍存活在测试库),以及移除动作。 */ + removalItems?: TestPublication[] + removalBusyIDs?: string[] + removalMessage?: string + onStartRemoval?: (id: string) => void } export function WeKnoraMonitorView({ @@ -143,9 +148,15 @@ export function WeKnoraMonitorView({ onStart = () => undefined, onLoadMore = () => undefined, onRefresh, + removalItems = [], + removalBusyIDs = [], + removalMessage = '', + onStartRemoval = () => undefined, }: ViewProps) { const [selectedStartIDs, setSelectedStartIDs] = useState([]) const [selectedRetryIDs, setSelectedRetryIDs] = useState([]) + // 破坏性动作的二次确认状态放在视图内部:容器漏传参也不会把"确认"这一步静默降级掉。 + const [confirmRemovalID, setConfirmRemovalID] = useState('') const toggleStartSelection = (id: string) => setSelectedStartIDs((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) const toggleRetrySelection = (id: string) => setSelectedRetryIDs((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]) const stages: Array<[MonitorStatus, number]> = selectedKB ? [ @@ -217,8 +228,10 @@ export function WeKnoraMonitorView({ : null} {lane === 'removal' ?

需下架({laneCounts.removal})

来源目录里被改为“禁止导入”或撤销标注、但仍在知识库中可检索的文件
- {(overview?.knowledge_bases || []).some((kb) => kb.needs_removal > 0) ?
    {(overview?.knowledge_bases || []).filter((kb) => kb.needs_removal > 0).map((kb) =>
  • 需下架{kb.name}{kb.needs_removal} 份仍在检索中
  • )}
: emptyLane('没有标注为禁止但仍留在知识库里的文件。')} -

这里只负责把差异暴露出来:来源项被判“禁止导入”后,仍存活在测试库里的知识必须移除。移除动作走 test.remove 工作流(会删除测试库里的知识条目,重新标注后可再次入库);该入口当前由变更集删除路径触发,页面尚未提供独立按钮。

+ {removalMessage ?

{removalMessage}

: null} + {removalItems.length ?
    {removalItems.map((item) =>
  • 需下架「{item.title}」{scope(item.target_scope)} · 知识仍在测试库中可检索{confirmRemovalID === item.id ? <> : }
  • )}
: null} + {(overview?.knowledge_bases || []).some((kb) => kb.needs_removal > 0) ?
    {(overview?.knowledge_bases || []).filter((kb) => kb.needs_removal > 0).map((kb) =>
  • 需下架{kb.name}{kb.needs_removal} 份仍在检索中
  • )}
: removalItems.length ? null : emptyLane('没有标注为禁止但仍留在知识库里的文件。')} +

这里只负责把差异暴露出来:来源项被判“禁止导入”后,仍存活在测试库里的知识必须移除。逐条点“下架(从测试库移除)”并二次确认后,走 test.remove 工作流删除测试库里的知识条目(会读回校验;重新标注后可再次入库,不影响生产知识库)。

: null} {lane === 'inflight' ?

处理中({inflightItems.length})

无需操作,仅供查看
@@ -276,6 +289,9 @@ export function WeKnoraMonitor() { const [batchMessage, setBatchMessage] = useState('') const [failures, setFailures] = useState([]) const [attention, setAttention] = useState(null) + const [removalItems, setRemovalItems] = useState([]) + const [removalBusyIDs, setRemovalBusyIDs] = useState([]) + const [removalMessage, setRemovalMessage] = useState('') const [loadingMore, setLoadingMore] = useState(false) const [metrics, setMetrics] = useState(null) const setContextTabs = useContext(WorkspaceContextTabsSetter) @@ -306,8 +322,9 @@ export function WeKnoraMonitor() { fetchTestPublications({ processing_state: queueStatus, target_scope: queueScope, limit: 50, offset: 0 }, controller.signal), fetchTestPublications({ processing_state: 'planned', limit: 100, offset: 0 }, controller.signal), fetchTestPublications({ processing_state: ['failed', 'stalled'], limit: 100, offset: 0 }, controller.signal), - ]).then(([queuePage, planPage, attentionPage]) => { - if (!controller.signal.aborted) { setQueue(queuePage); setPlans(planPage.items); setAttention(attentionPage.items); setError(null) } + fetchRemovalRequiredPublications(controller.signal), + ]).then(([queuePage, planPage, attentionPage, removalPage]) => { + if (!controller.signal.aborted) { setQueue(queuePage); setPlans(planPage.items); setAttention(attentionPage.items); setRemovalItems(removalPage.items); setError(null) } }).catch((cause) => { if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : '加载解析队列失败') }).finally(() => { if (!controller.signal.aborted) setLoading(false) }) return () => controller.abort() }, [queueStatus, queueScope, documentRevision]) @@ -379,6 +396,22 @@ export function WeKnoraMonitor() { } } + // 需下架 → 移除:把被禁止导入、但仍存活在测试库的知识交给既有的 test.remove 工作流。 + const startRemoval = async (id: string) => { + if (removalBusyIDs.includes(id)) return + setRemovalBusyIDs((current: string[]) => [...current, id]) + setRemovalMessage('') + try { + await startPublicationRemoval(id) + setRemovalMessage('已受理下架:正在移除测试库中的知识条目,完成后本条会从「需下架」消失。') + setDocumentRevision((value) => value + 1) + } catch (cause) { + setRemovalMessage(cause instanceof Error ? cause.message : '触发下架失败') + } finally { + setRemovalBusyIDs((current: string[]) => current.filter((value) => value !== id)) + } + } + const loadMoreQueue = async () => { if (loadingMore) return setLoadingMore(true) @@ -441,5 +474,5 @@ export function WeKnoraMonitor() { setStarting(false) } } - return { setSelected(kb); setStatus(''); setFiles(null) }} onStatus={(value) => { setStatus(value); setFiles(null) }} onQueueStatus={setQueueStatus} onQueueScope={setQueueScope} lane={lane} onLaneChange={(value) => setLane(value as typeof lane)} onRetry={(id) => void retryOne(id)} onBatchRetry={(ids) => void retryBatch(ids)} onBatchCancel={(ids) => void cancelBatch(ids)} onRequeueFailure={(id) => void requeueFailure(id)} onStart={(ids) => void startPlans(ids)} attention={attention ?? undefined} loadingMore={loadingMore} onLoadMore={() => void loadMoreQueue()} onRefresh={() => { setDocumentRevision((value) => value + 1); setResourceRevision((value) => value + 1) }} /> + return { setSelected(kb); setStatus(''); setFiles(null) }} onStatus={(value) => { setStatus(value); setFiles(null) }} onQueueStatus={setQueueStatus} onQueueScope={setQueueScope} lane={lane} onLaneChange={(value) => setLane(value as typeof lane)} onRetry={(id) => void retryOne(id)} onBatchRetry={(ids) => void retryBatch(ids)} onBatchCancel={(ids) => void cancelBatch(ids)} onRequeueFailure={(id) => void requeueFailure(id)} onStart={(ids) => void startPlans(ids)} attention={attention ?? undefined} loadingMore={loadingMore} onLoadMore={() => void loadMoreQueue()} onRefresh={() => { setDocumentRevision((value) => value + 1); setResourceRevision((value) => value + 1) }} removalItems={removalItems} removalBusyIDs={removalBusyIDs} removalMessage={removalMessage} onStartRemoval={(id) => void startRemoval(id)} /> } diff --git a/web/src/testPublications.test.ts b/web/src/testPublications.test.ts index 222e5fe..b04688a 100644 --- a/web/src/testPublications.test.ts +++ b/web/src/testPublications.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { fetchAcceptanceResults, fetchTestPublications, parseTestPublicationsResponse, retryPublication, retryPublicationsBatch, startTestPublications, fetchJobFailures } from './testPublications' +import { fetchAcceptanceResults, fetchTestPublications, parseTestPublicationsResponse, retryPublication, retryPublicationsBatch, startTestPublications, fetchJobFailures, fetchRemovalRequiredPublications, startPublicationRemoval } from './testPublications' afterEach(() => vi.unstubAllGlobals()) @@ -89,6 +89,37 @@ describe('retryPublication', () => { }) }) +describe('需下架条目的移除入口', () => { + it('只取被禁止但仍存活在测试库的条目', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [], total: 0, limit: 50, offset: 0 }) }) + vi.stubGlobal('fetch', fetchMock) + + await fetchRemovalRequiredPublications() + + const url = String(fetchMock.mock.calls[0][0]) + expect(url).toContain('/api/v1/test-publications?') + expect(url).toContain('removal=required') + }) + + it('带幂等键触发下架并回传任务号', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ publication_id: 'tp-1', job_id: 'job-remove-1', state: 'disabling' }), { status: 202 })) + vi.stubGlobal('fetch', fetchMock) + + await expect(startPublicationRemoval('tp-1')).resolves.toEqual({ job_id: 'job-remove-1' }) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('/api/v1/test-publications/tp-1/removal') + expect(init.method).toBe('POST') + expect(String(init.headers['Idempotency-Key'])).not.toHaveLength(0) + }) + + it('把服务端拒绝原因原样抛给界面', async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 409, json: async () => ({ error: '该知识的检索已经移除,无需重复下架' }) }) + vi.stubGlobal('fetch', fetchMock) + + await expect(startPublicationRemoval('tp-1')).rejects.toThrow('该知识的检索已经移除,无需重复下架') + }) +}) + describe('startTestPublications', () => { it('starts only the explicitly confirmed processing plans', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { selected: 2, started: 1, already_started: 1, unavailable: 0 } }), { status: 202 })) diff --git a/web/src/testPublications.ts b/web/src/testPublications.ts index 542c150..8ae3f8e 100644 --- a/web/src/testPublications.ts +++ b/web/src/testPublications.ts @@ -34,6 +34,8 @@ export interface AcceptanceRecord { } export interface TestPublicationQuery { + /** removal: 'required' 只取「被禁止导入但仍存活在测试库」的条目(解析监控 → 需下架)。 */ + removal?: 'required' // A single state, or several at once (joined into a comma list server-side). processing_state: ProcessingState | '' | ProcessingState[] target_scope?: 'internal' | 'external' | '' @@ -96,6 +98,7 @@ export async function fetchTestPublications(query: TestPublicationQuery, signal? const parameters = new URLSearchParams() if (query.processing_state) parameters.set('processing_state', Array.isArray(query.processing_state) ? query.processing_state.join(',') : query.processing_state) if (query.target_scope) parameters.set('target_scope', query.target_scope) + if (query.removal) parameters.set('removal', query.removal) parameters.set('limit', String(query.limit)) parameters.set('offset', String(query.offset)) const response = await fetch(`/api/v1/test-publications?${parameters.toString()}`, { headers: { Accept: 'application/json' }, signal }) @@ -103,6 +106,32 @@ export async function fetchTestPublications(query: TestPublicationQuery, signal? return parseTestPublicationsResponse(await response.json()) } +/** + * 需下架车道用:列出「来源侧被判禁止导入、但知识仍存活在测试库里」的具体条目。 + * 此前车道只有按库计数,运维看不到条目、也没有处置入口。 + */ +export async function fetchRemovalRequiredPublications(signal?: AbortSignal): Promise { + return fetchTestPublications({ processing_state: '', removal: 'required', limit: 50, offset: 0 }, signal) +} + +/** + * 触发移除:把该条目置为下架中,交服务端既有的 test.remove 工作流去删远端知识并读回校验。 + * 服务端按条目幂等(重复点击不会重复入队),因此这里每次生成新的幂等键是安全的。 + */ +export async function startPublicationRemoval(publicationId: string): Promise<{ job_id: string }> { + const response = await fetch(`/api/v1/test-publications/${encodeURIComponent(publicationId)}/removal`, { + method: 'POST', + headers: { Accept: 'application/json', 'Idempotency-Key': newOperationID() }, + }) + if (response.status !== 202) { + const body = (await response.json().catch(() => null)) as { error?: string } | null + throw new Error(body?.error ?? `触发下架失败(HTTP ${response.status})`) + } + const value = await response.json() as unknown + if (!isRecord(value) || typeof value.job_id !== 'string') throw new Error('下架响应无效') + return { job_id: value.job_id } +} + export async function fetchAcceptanceResults(publicationId: string, signal?: AbortSignal): Promise { const response = await fetch(`/api/v1/test-publications/${encodeURIComponent(publicationId)}/acceptance`, { headers: { Accept: 'application/json' }, signal }) if (!response.ok) throw new Error(`加载验收结果失败(HTTP ${response.status})`)