From e9c0c4007cf3e96117ba8eb6946f83ebc5ab673e Mon Sep 17 00:00:00 2001 From: eaiadmin Date: Sun, 27 Sep 2026 00:16:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(task):=20=E5=88=A0=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=BA=A7=E8=81=94=E6=B8=85=E6=8E=89=20run=20=E4=B8=8E=E4=BA=A7?= =?UTF-8?q?=E7=89=A9=EF=BC=8C=E4=B8=8D=E5=86=8D=E7=95=99=E5=AD=A4=E5=84=BF?= =?UTF-8?q?=E9=80=90=E5=AD=97=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bugs_and_errors.md | 20 +- .../backend-go/internal/api/my_task.go | 23 +- .../internal/api/my_task_delete_test.go | 214 ++++++++++++++++++ 3 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 eai_agentplatform/backend-go/internal/api/my_task_delete_test.go diff --git a/bugs_and_errors.md b/bugs_and_errors.md index dca19eb..2ea49b1 100644 --- a/bugs_and_errors.md +++ b/bugs_and_errors.md @@ -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 正是这里的期望值,断言会**对着一个不存在的表**全绿。 --- diff --git a/eai_agentplatform/backend-go/internal/api/my_task.go b/eai_agentplatform/backend-go/internal/api/my_task.go index 7b90faf..e1b776b 100644 --- a/eai_agentplatform/backend-go/internal/api/my_task.go +++ b/eai_agentplatform/backend-go/internal/api/my_task.go @@ -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 建一条属于当前用户的任务。没给专员就落在通用助手名下。 diff --git a/eai_agentplatform/backend-go/internal/api/my_task_delete_test.go b/eai_agentplatform/backend-go/internal/api/my_task_delete_test.go new file mode 100644 index 0000000..ac3f41e --- /dev/null +++ b/eai_agentplatform/backend-go/internal/api/my_task_delete_test.go @@ -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 +}