a1b46d2b7c
背景:来源侧被判「禁止导入」后,已建好的测试库知识不会自动消失;此前只有变更集
删除路径会入队 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。
137 lines
5.7 KiB
Go
137 lines
5.7 KiB
Go
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()
|
||
}
|