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:
eaiadmin
2026-09-27 00:16:39 +08:00
co-authored by Claude Code
parent 81366ca273
commit e9c0c4007c
3 changed files with 253 additions and 4 deletions
+17 -3
View File
@@ -693,9 +693,23 @@ torch / matplotlib 之类的 dotfile 也落在同一个可写位置。
`DELIVERY.md` 第 3 节手工清单 C 项「清空测试数据」若按这个删法走, `DELIVERY.md` 第 3 节手工清单 C 项「清空测试数据」若按这个删法走,
库里会留下一批**没有任何入口能看到、也没人能删**的孤儿逐字稿。 库里会留下一批**没有任何入口能看到、也没人能删**的孤儿逐字稿。
**状态**:**未修**。删除语义(硬删 / 软删留痕)是产品决定,不擅自改。 **修法**:`DeleteMyTask` 补上 `TaskRunDAO.DeleteByTask` + `TaskArtifactDAO.DeleteByTask`
本次是我自己造的夹具,已手工清掉那 7 条 run 与 6 条产物(先备份 (两个 DAO 早就有了,`weixin_public_account` 的工作流重置一直在配对使用 —— 缺的只是
`/tmp/eai_backup_before_retry_test.db`,删后核对为 0 / 0)。 这里没调)。**顺序先子后父**:中途失败时任务还在,重试一次就干净;反过来先删父再失败,
那些子记录就再也没人能按 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 没有软删字段,删了就是删了 —— // 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) { func DeleteMyTask(c *gin.Context) {
user := middleware.CurrentUser(c) user := middleware.CurrentUser(c)
if user == nil { if user == nil {
@@ -59,11 +70,21 @@ func DeleteMyTask(c *gin.Context) {
web.Fail(c, web.NewNotFoundError("任务不存在")) web.Fail(c, web.NewNotFoundError("任务不存在"))
return 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) { if !taskRecordDAO.Delete(&task) {
web.Fail(c, web.NewBadRequest("删除任务失败")) web.Fail(c, web.NewBadRequest("删除任务失败"))
return return
} }
web.OK(c, gin.H{"id": id}) web.OK(c, gin.H{"id": id, "runs": runs, "artifacts": artifacts})
} }
// CreateMyTask 建一条属于当前用户的任务。没给专员就落在通用助手名下。 // 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
}