Files
knowledge-governance/internal/httpapi/test_publication_removal.go
T
姚章浩 a1b46d2b7c feat(monitor): 需下架车道可逐条移除测试库知识 —— 补齐最后一个没有页面入口的流程
背景:来源侧被判「禁止导入」后,已建好的测试库知识不会自动消失;此前只有变更集
删除路径会入队 test.remove,页面既列不出条目也没有按钮,运维无法闭环(车道只有按
库计数)。

新增:
- GET /api/v1/test-publications?removal=required:条目级列表,判据与车道计数同源
  (superseded_at IS NULL / 有 knowledge_id / completed / retrieval_state NOT IN
  (disabling,disabled) / policy_state='blocked'),过滤是叠加而不是替换。
- POST /api/v1/test-publications/{id}/removal:engineer 级写路由,要求 Idempotency-Key,
  按条目幂等(operator-remove:<id>);置 disabling 并交既有 test.remove 工作流去删除
  远端知识并读回校验。错误映射 404/409(已移除)/409(不可移除)/501(未接线)。
- 界面:需下架车道逐条显示条目 + 「下架(从测试库移除)」,页内二次确认,成功提示,
  完成后计数归零。

修掉过程中踩到的两个真问题:
1. 二次确认的确认态原先经容器 props 传给视图,一次容器改动把三个 props 丢了 ——
   视图永远渲染原按钮、确认步骤静默消失,而所有静态单元测试仍然通过。改为把确认态
   放在视图内部;并把「取消」放在原按钮位置(真实指针连点两下只会落到取消)。
2. jobs 上有部分唯一索引 UNIQUE (business_change_set_id, business_version_id,
   business_target_kb_id, business_action) WHERE business_change_set_id IS NOT NULL。
   本路径给这三列写空串(非 NULL),('','','','remove') 对所有条目是同一个键 ——
   第一条之后每次 INSERT 都撞唯一索引、被 ON CONFLICT DO NOTHING 静默吞掉,留下
   「状态已 disabling、没有任务」的永久卡死条目,而车道恰好隐藏 disabling,运维也
   看不到。改为写 NULL(语义:无变更集身份)+ 冲突目标收敛到 idempotency_key +
   校验 RowsAffected()(0 行回查既有任务;查不到就报错,不提交无任务的 disabling)。
   卡死状态在重试时可自愈。

测试:internal/platform/postgres/test_publication_removal_integration_test.go(两条发布
各自入队、business_* 为 NULL、卡死自愈;把修复退回旧写法该测试即 FAIL,已实测)、
internal/httpapi/test_publication_removal_test.go(权限/幂等键/错误映射/未接线 501/列表
过滤)、前端 testPublications.test.ts + WeKnoraMonitor.test.tsx。

验收(纯界面,两条都走完):解析监控 → 需下架(2)→ 逐条「下架」+ 二次确认 →
retrieval_state=disabled(PDF 03:57:03Z、md 04:08:55Z),jobs completed(attempt 2,
首次 test_remove_pending=远端仍在处理,重试通过;工作流以 GetKnowledge 读回 404 判
成功)→ 车道需下架(0)。核验「确认前不执行」:首次点击后仍 unpublished、移除任务数仍 1。
2026-10-10 12:10:46 +08:00

52 lines
2.3 KiB
Go

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"})
}
}