feat(api): DELETE /api/projects/:id + store.deleteProject(连带 tasks/runs/events)

新增项目删除能力: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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 14:15:38 +08:00
parent 0d45de4507
commit 0b3ebbd569
3 changed files with 39 additions and 0 deletions
+7
View File
@@ -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 };
+12
View File
@@ -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);
+20
View File
@@ -367,3 +367,23 @@ test('requeueTaskneeds_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();
});