Files
knowledge-governance/internal/platform/postgres/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

137 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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()
}