From 0b3ebbd569528f80fa7417b2e74e1f9cc6917063 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 14:15:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20DELETE=20/api/projects/:id=20+=20s?= =?UTF-8?q?tore.deleteProject(=E8=BF=9E=E5=B8=A6=20tasks/runs/events)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增项目删除能力:store.deleteProject 事务内删 events(无 FK,手动)+ projects(tasks/runs/ approvals 经 schema ON DELETE CASCADE 连带清);DELETE /api/projects/:id 暴露之。 store.test 覆盖连带清除 + 重复删报错。用于清理 cutover 测试遗留的一次性 smoke-test 项目 (本次经直连 DB 删除,未重启打断在跑的 worker;端点下次重启生效)。 Co-Authored-By: Claude Opus 4.8 --- src/api/server.ts | 7 +++++++ src/store/store.ts | 12 ++++++++++++ test/store.test.ts | 20 ++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/src/api/server.ts b/src/api/server.ts index a0d8f43..a09829f 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -92,6 +92,13 @@ export function buildServer(opts: ApiOptions): FastifyInstance { return store.reorderProjects(b.order.map(String)).map(projectOut); }); + // 彻底删除项目(连带 tasks/runs/approvals/events,不可恢复) + app.delete('/api/projects/:id', (req) => { + const { id } = req.params as { id: string }; + store.deleteProject(id); + return { ok: true }; + }); + // 项目 logo:仓库内文件 → 流式返回;外链头像 → 302;无 → 404(前端用首字母徽章兜底) app.get('/api/projects/:id/logo', async (req, reply) => { const { id } = req.params as { id: string }; diff --git a/src/store/store.ts b/src/store/store.ts index eca79b7..c5e899e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -145,6 +145,18 @@ export class Store { return row ? rowToProject(row) : null; } + /** + * 彻底删除项目(不可恢复):tasks→runs/approvals 经 schema 的 ON DELETE CASCADE 连带清(foreign_keys=ON), + * events 表无外键、手动按 project_id 删。事务内一次性完成。 + */ + deleteProject(projectId: string): void { + if (!this.getProject(projectId)) throw new StoreError(`项目不存在: ${projectId}`); + this.db.transaction(() => { + this.db.prepare(`DELETE FROM events WHERE project_id = ?`).run(projectId); + this.db.prepare(`DELETE FROM projects WHERE id = ?`).run(projectId); + })(); + } + /** 部分更新项目配置(autonomy/concurrency/verifyCmd/model/status),带合法性校验。 */ patchProject(projectId: string, patch: PatchProjectInput): Project { const cur = this.getProject(projectId); diff --git a/test/store.test.ts b/test/store.test.ts index a3df7b6..67c096c 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -367,3 +367,23 @@ test('requeueTask:needs_attention → queued,重置 retryBaseline', () => { assert.throws(() => s.requeueTask(t.id), /needs_attention/); s.close(); }); + +test('deleteProject:连带删 tasks/runs/approvals/events,不可恢复', () => { + const s = freshStore(); + const p = s.createProject({ name: 'del', repoPath: '/tmp/del-' + Math.random() }); + const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' }); + s.setOperations(t.id, 'op'); + const r = s.startRun(t.id, 'executor'); + s.finishRun(r.id, 'succeeded'); + assert.ok(s.listEvents(p.id).length > 0, '删前有事件'); + assert.ok(s.listTasks(p.id).length > 0, '删前有任务'); + assert.ok(s.listRuns(t.id).length > 0, '删前有 run'); + + s.deleteProject(p.id); + assert.equal(s.getProject(p.id), null, '项目已删'); + assert.equal(s.listTasks(p.id).length, 0, 'tasks 连带清'); + assert.equal(s.listRuns(t.id).length, 0, 'runs 连带清(经 tasks cascade)'); + assert.equal(s.listEvents(p.id).length, 0, 'events 连带清'); + assert.throws(() => s.deleteProject(p.id), /不存在/, '重复删 → 报错'); + s.close(); +});