fix(task): 删任务级联清掉 run 与产物,不再留孤儿逐字稿
DELETE /api/my/tasks/:id 原先只删 task_record。实测:删前 1/7/6, 删后 0/7/6 —— 运行记录与产物原样留着。任务列表里再也看不到, 也没有任何接口能按 task_id 找回,等于永久留在库里。而产物正文常常是 完整逐字稿(一次 26 分钟会议的录音内容),于是「删掉任务」并不等于 「删掉录音内容」;交付前按 DELIVERY.md 手工清单 C 项清测试数据时, 会留下一批谁也删不掉的逐字稿。 补上 TaskRunDAO.DeleteByTask + TaskArtifactDAO.DeleteByTask —— 两个 DAO 早就有了,weixin_public_account 的工作流重置一直在配对使用,缺的只是 这里没调。先子后父:中途失败任务还在,重试一次就干净;反过来先删父 再失败,子记录就再没人能找到了。task_id 外键只有这两张表,即完整级联。 验证(internal/api/my_task_delete_test.go,真实路由 + 真实鉴权中间件): 先 stash 掉 my_task.go 跑,孤儿断言如期红;改回全绿。另有越权用例钉住 「404 且一条都不许少」,以及旁观任务证明不误伤。条数用 3/2 而非 1, 计数走模型而非表名字符串(拼错表名 Count 为 0,而 0 正是期望值)。 详见 bugs_and_errors.md E15。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+17
-3
@@ -693,9 +693,23 @@ torch / matplotlib 之类的 dotfile 也落在同一个可写位置。
|
||||
`DELIVERY.md` 第 3 节手工清单 C 项「清空测试数据」若按这个删法走,
|
||||
库里会留下一批**没有任何入口能看到、也没人能删**的孤儿逐字稿。
|
||||
|
||||
**状态**:**未修**。删除语义(硬删 / 软删留痕)是产品决定,不擅自改。
|
||||
本次是我自己造的夹具,已手工清掉那 7 条 run 与 6 条产物(先备份
|
||||
`/tmp/eai_backup_before_retry_test.db`,删后核对为 0 / 0)。
|
||||
**修法**:`DeleteMyTask` 补上 `TaskRunDAO.DeleteByTask` + `TaskArtifactDAO.DeleteByTask`
|
||||
(两个 DAO 早就有了,`weixin_public_account` 的工作流重置一直在配对使用 —— 缺的只是
|
||||
这里没调)。**顺序先子后父**:中途失败时任务还在,重试一次就干净;反过来先删父再失败,
|
||||
那些子记录就再也没人能按 task_id 找到了。响应里回带清掉的条数。
|
||||
|
||||
`task_id` 外键只有 `task_run` 与 `task_artifact` 两张表(grep `internal/model` 确认),
|
||||
所以这两步就是完整的级联。
|
||||
|
||||
**验证**:新增 `internal/api/my_task_delete_test.go`,走**真实路由 + 真实鉴权中间件**。
|
||||
- **先证明会红**:把 `my_task.go` stash 掉再跑,孤儿断言如期失败
|
||||
(「task_run 还剩 3 条孤儿」「task_artifact 还剩 2 条孤儿」)。
|
||||
- 改回后全绿;另有一条越权用例钉住「返回 404 且**一条都不许少**」——
|
||||
若哪天有人在权限判断之前就把级联删了,状态码照样 404,孤儿却已经产生。
|
||||
- 条数故意用 3 / 2 而不是 1,免得「删了一条就以为删干净了」蒙混过关;另铺一条
|
||||
「旁观任务」证明级联不会误伤别的任务。
|
||||
- 计数走模型而不是表名字符串:表名是各模型 `TableName()` 写死的单数,拼错字符串
|
||||
`Count` 出来是 0 —— 而 0 正是这里的期望值,断言会**对着一个不存在的表**全绿。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -43,6 +43,17 @@ func ListMyTasks(c *gin.Context) {
|
||||
|
||||
// DeleteMyTask 彻底删掉一条任务。task_record 没有软删字段,删了就是删了 ——
|
||||
// 前端那边因此必须在确认框里说清楚,不能拿它当「归档」用。
|
||||
//
|
||||
// 删主记录**必须连运行记录与产物一起删**。原先只删 task_record,另两样就成了
|
||||
// 孤儿:任务列表里再也看不到,也没有任何接口能按 task_id 找到它们,等于永久
|
||||
// 留在库里。而产物正文常常是**完整逐字稿**(一次 26 分钟会议的录音内容),
|
||||
// 于是「删掉任务」并不等于「删掉录音内容」—— 交付前按 DELIVERY.md 手工清单
|
||||
// C 项清测试数据时,会留下一批谁也删不掉的逐字稿(2026-09-26 实测:
|
||||
// DELETE /api/my/tasks/100 之后 task_record 0 条,task_run 7 条、task_artifact
|
||||
// 6 条原样都在)。
|
||||
//
|
||||
// 顺序是先子后父:中途失败时任务还在,重试一次就干净了;反过来先删父再失败,
|
||||
// 那些子记录就再也没人能找到它们了。
|
||||
func DeleteMyTask(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
@@ -59,11 +70,21 @@ func DeleteMyTask(c *gin.Context) {
|
||||
web.Fail(c, web.NewNotFoundError("任务不存在"))
|
||||
return
|
||||
}
|
||||
runs, ok := taskRunDAO.DeleteByTask(task.ID)
|
||||
if !ok {
|
||||
web.Fail(c, web.NewBadRequest("删除任务的运行记录失败,任务未删除"))
|
||||
return
|
||||
}
|
||||
artifacts, ok := taskArtifactDAO.DeleteByTask(task.ID)
|
||||
if !ok {
|
||||
web.Fail(c, web.NewBadRequest("删除任务的产物失败,任务未删除"))
|
||||
return
|
||||
}
|
||||
if !taskRecordDAO.Delete(&task) {
|
||||
web.Fail(c, web.NewBadRequest("删除任务失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"id": id})
|
||||
web.OK(c, gin.H{"id": id, "runs": runs, "artifacts": artifacts})
|
||||
}
|
||||
|
||||
// CreateMyTask 建一条属于当前用户的任务。没给专员就落在通用助手名下。
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"eai_agentplatform/backend/internal/model"
|
||||
specialistruntime "eai_agentplatform/backend/internal/specialists/runtime"
|
||||
"eai_agentplatform/backend/internal/store"
|
||||
)
|
||||
|
||||
// 这一组用例钉住 DELETE /api/my/tasks/:id 的**级联**。
|
||||
//
|
||||
// 为什么函数级测不到:级联本来就没有函数 —— 它体现在 handler 里多调了两个 DAO。
|
||||
// 少调一个,`taskRecordDAO.Delete` 照样返回成功,接口照样 200,只有库里的
|
||||
// 孤儿能证明它漏了。所以必须走真实路由 + 真库,把三类记录**都数一遍**。
|
||||
|
||||
// seedTaskWithChildren 铺一条任务,带 n 条运行记录与 m 条产物,返回任务 ID。
|
||||
//
|
||||
// 产物正文特意写成一段「逐字稿」:这是真实形态,也是这个 bug 的要害 ——
|
||||
// 孤儿产物里留着的就是会议录音的内容。
|
||||
func seedTaskWithChildren(t *testing.T, owner model.User, title string, n, m int) uint {
|
||||
t.Helper()
|
||||
|
||||
task := model.TaskRecord{
|
||||
SpecialistKey: "general-assistant",
|
||||
Title: title,
|
||||
Owner: owner.FullName,
|
||||
Status: specialistruntime.TaskStatusDraft,
|
||||
CreatedBy: &owner.ID,
|
||||
}
|
||||
if err := store.DB.Create(&task).Error; err != nil {
|
||||
t.Fatalf("创建任务失败:%v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
run := model.TaskRun{
|
||||
TaskID: task.ID,
|
||||
ActionKey: "audio-transcribe:structure",
|
||||
ActionTitle: "整理段落与重点",
|
||||
Status: "done",
|
||||
}
|
||||
if err := store.DB.Create(&run).Error; err != nil {
|
||||
t.Fatalf("创建运行记录失败:%v", err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < m; i++ {
|
||||
artifact := model.TaskArtifact{
|
||||
TaskID: task.ID,
|
||||
SpecialistKey: "general-assistant",
|
||||
Title: "逐字转写稿",
|
||||
ArtifactType: "transcript",
|
||||
Status: specialistruntime.ArtifactStatusDraft,
|
||||
// 这一段就是「删了任务却还留在库里」的东西。
|
||||
ContentText: "[0:01 - 0:03] 说话人 SPEAKER_01:这次会议讨论的是……",
|
||||
}
|
||||
if err := store.DB.Create(&artifact).Error; err != nil {
|
||||
t.Fatalf("创建产物失败:%v", err)
|
||||
}
|
||||
}
|
||||
return task.ID
|
||||
}
|
||||
|
||||
// runCount / artifactCount 按模型数,不按表名字符串数。
|
||||
//
|
||||
// 表名是各模型自己 TableName() 写死的单数(task_run / task_artifact),
|
||||
// 拼字符串一旦拼错,Count 出来的是 0 —— 而 0 恰好是这些用例想要的期望值,
|
||||
// 于是断言会**对着一个不存在的表**全绿。用模型就把表名交给 gorm 自己解析。
|
||||
func runCount(t *testing.T, taskID uint) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := store.DB.Model(&model.TaskRun{}).Where("task_id = ?", taskID).Count(&n).Error; err != nil {
|
||||
t.Fatalf("数 task_run 失败:%v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func artifactCount(t *testing.T, taskID uint) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := store.DB.Model(&model.TaskArtifact{}).Where("task_id = ?", taskID).Count(&n).Error; err != nil {
|
||||
t.Fatalf("数 task_artifact 失败:%v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// deleteTask 发一次真实 HTTP DELETE(fixture 里只有 post,这里补一个)。
|
||||
func (fx *audioRouteFixture) deleteTask(t *testing.T, token string, taskID uint) (int, map[string]any) {
|
||||
t.Helper()
|
||||
path := "/api/my/tasks/" + itoa(taskID)
|
||||
req := httptest.NewRequest(http.MethodDelete, path, nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
fx.engine.ServeHTTP(rec, req)
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
||||
t.Fatalf("%s 返回的不是 JSON:%s", path, rec.Body.String())
|
||||
}
|
||||
return rec.Code, decoded
|
||||
}
|
||||
|
||||
func itoa(v uint) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// TestDeleteMyTaskCascadesRunsAndArtifacts 是本次的主用例:
|
||||
// 删掉任务之后,它名下的运行记录与产物必须**一条不剩**。
|
||||
//
|
||||
// 反面(改之前的行为)已经实测过:task_record 0 条,task_run 与 task_artifact
|
||||
// 原样留着 —— 任务列表里再也看不到它们,也没有任何接口能按 task_id 找回,
|
||||
// 而产物正文是完整逐字稿。
|
||||
func TestDeleteMyTaskCascadesRunsAndArtifacts(t *testing.T) {
|
||||
fx := newAudioRouteFixture(t)
|
||||
|
||||
owner, err := userByUsername(t, "asr_owner")
|
||||
if err != nil {
|
||||
t.Fatalf("取测试用户失败:%v", err)
|
||||
}
|
||||
|
||||
// 3 条 run、2 条产物:数目故意不等于 1,免得「删了一条就以为删干净了」蒙混过关。
|
||||
targetID := seedTaskWithChildren(t, owner, "待删除的任务", 3, 2)
|
||||
// 另一条任务:证明级联**只清自己那一条**,不是把整张表清了。
|
||||
bystanderID := seedTaskWithChildren(t, owner, "旁观的任务", 2, 1)
|
||||
|
||||
code, body := fx.deleteTask(t, fx.token, targetID)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("删除任务返回 %d:%s", code, errorMessageOf(t, body))
|
||||
}
|
||||
data := dataOf(t, body)
|
||||
if got, _ := data["runs"].(float64); int(got) != 3 {
|
||||
t.Errorf("响应里的 runs = %v,期望 3(调用方要能看出清掉了多少)", data["runs"])
|
||||
}
|
||||
if got, _ := data["artifacts"].(float64); int(got) != 2 {
|
||||
t.Errorf("响应里的 artifacts = %v,期望 2", data["artifacts"])
|
||||
}
|
||||
|
||||
var taskCount int64
|
||||
if err := store.DB.Model(&model.TaskRecord{}).Where("id = ?", targetID).Count(&taskCount).Error; err != nil {
|
||||
t.Fatalf("数任务失败:%v", err)
|
||||
}
|
||||
if taskCount != 0 {
|
||||
t.Errorf("task_record 还剩 %d 条,期望 0", taskCount)
|
||||
}
|
||||
if n := runCount(t, targetID); n != 0 {
|
||||
t.Errorf("task_run 还剩 %d 条孤儿,期望 0 —— handler 漏调了 TaskRunDAO.DeleteByTask", n)
|
||||
}
|
||||
if n := artifactCount(t, targetID); n != 0 {
|
||||
t.Errorf("task_artifact 还剩 %d 条孤儿,期望 0 —— handler 漏调了 TaskArtifactDAO.DeleteByTask", n)
|
||||
}
|
||||
|
||||
// 旁观者必须分毫未动。
|
||||
if n := runCount(t, bystanderID); n != 2 {
|
||||
t.Errorf("旁观任务的 task_run 变成 %d 条,期望 2(级联误伤了别的任务)", n)
|
||||
}
|
||||
if n := artifactCount(t, bystanderID); n != 1 {
|
||||
t.Errorf("旁观任务的 task_artifact 变成 %d 条,期望 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteMyTaskRefusesOtherOwnerAndDeletesNothing 是越权用例。
|
||||
//
|
||||
// 关键不只是「返回 404」,还有**一条都不能少**:若哪天有人在权限判断之前
|
||||
// 就把级联删了,状态码照样是 404,孤儿却已经产生。
|
||||
func TestDeleteMyTaskRefusesOtherOwnerAndDeletesNothing(t *testing.T) {
|
||||
fx := newAudioRouteFixture(t)
|
||||
|
||||
owner, err := userByUsername(t, "asr_owner")
|
||||
if err != nil {
|
||||
t.Fatalf("取测试用户失败:%v", err)
|
||||
}
|
||||
targetID := seedTaskWithChildren(t, owner, "别人的任务", 2, 2)
|
||||
|
||||
code, _ := fx.deleteTask(t, fx.other, targetID)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("越权删除返回 %d,期望 404", code)
|
||||
}
|
||||
if n := runCount(t, targetID); n != 2 {
|
||||
t.Errorf("越权被拒之后 task_run 少了:剩 %d 条,期望 2", n)
|
||||
}
|
||||
if n := artifactCount(t, targetID); n != 2 {
|
||||
t.Errorf("越权被拒之后 task_artifact 少了:剩 %d 条,期望 2", n)
|
||||
}
|
||||
|
||||
// 未登录同理。
|
||||
code, _ = fx.deleteTask(t, "", targetID)
|
||||
if code == http.StatusOK {
|
||||
t.Fatalf("未登录竟然删成功了:返回 %d", code)
|
||||
}
|
||||
if n := artifactCount(t, targetID); n != 2 {
|
||||
t.Errorf("未登录被拒之后 task_artifact 少了:剩 %d 条,期望 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func userByUsername(t *testing.T, username string) (model.User, error) {
|
||||
t.Helper()
|
||||
var user model.User
|
||||
err := store.DB.Where("username = ?", username).First(&user).Error
|
||||
return user, err
|
||||
}
|
||||
Reference in New Issue
Block a user