feat(tasks): 任务列表支持批量修改分类

勾选多个任务后可一次性移动到目标分类,或选「未分类」把它们移出当前分类。

- db: 新增 SetTasksCategory,在单个事务里校验分类存在性并 UPDATE ... WHERE
  id=ANY(...),返回真正被更新的 id 和刷新后的分类(含 task_count)。分类检查
  放在事务内,避免并发删除把 UPDATE 变成外键冲突。单批上限 100。
- server: 新增 POST /api/tasks/category/batch。写入原子,响应里的逐任务失败
  只可能是勾选后又被删掉的任务;顺带把 category_id 的解析(必填但可为 null)
  抽成 parseCategoryIDField 与单任务接口共用。
- server: 把批量 id 的 trim/归一/去重逻辑从 controlTasksBatch 抽成
  normalizeBatchTaskIDs,两个批量端点共用同一套重复判定。
- manager: SetTasksCategory 在 taskStateMu 内完成 DB 写入与运行中任务 DTO
  刷新,防止与单任务更新交错后留下过期的 category_name。
- web: 选中工具栏新增「修改分类」对话框(需确认目标,避免误点批量改动);
  「未分类」哨兵值统一提取为 UNCATEGORIZED_VALUE。

验证: go build / vet / gofmt、db 与 server 新增测试连本地 PG 实跑通过、
tsc --noEmit、biome check、next build 均通过。
This commit is contained in:
Autumn-27 committed 2026-08-24 09:16:44 -04:00
1 parent 3eca390089
commit fea78b2d6f
12 files changed
+607 -47

No files matched your search

+81
View File
@@ -13,6 +13,10 @@ import (
const MaxTaskCategoryNameRunes = 80
// MaxTaskCategoryBatchSize bounds one batch move so a single request cannot lock
// an unbounded number of task rows.
const MaxTaskCategoryBatchSize = 100
var (
ErrTaskCategoryInvalid = errors.New("invalid task category")
ErrTaskCategoryNameConflict = errors.New("task category name already exists")
@@ -199,3 +203,80 @@ FROM selected, updated`, taskID, *categoryID))
}
return &category, nil
}
// SetTasksCategory moves several tasks into one category (nil = uncategorized)
// inside a single transaction, so a half-applied batch is never observable.
// It returns the ids that were actually updated — ids missing from that slice
// were deleted between selection and submit — plus the refreshed category row
// whose task_count already reflects this move.
func (d *DB) SetTasksCategory(taskIDs []int64, categoryID *int64) ([]int64, *TaskCategory, error) {
if len(taskIDs) == 0 {
return nil, nil, fmt.Errorf("%w: task ids are required", ErrTaskCategoryInvalid)
}
if len(taskIDs) > MaxTaskCategoryBatchSize {
return nil, nil, fmt.Errorf("%w: at most %d tasks per request", ErrTaskCategoryInvalid, MaxTaskCategoryBatchSize)
}
for _, id := range taskIDs {
if id <= 0 {
return nil, nil, fmt.Errorf("%w: task id must be positive", ErrTaskCategoryInvalid)
}
}
if categoryID != nil && *categoryID <= 0 {
return nil, nil, fmt.Errorf("%w: category id must be positive", ErrTaskCategoryInvalid)
}
tx, err := d.Begin()
if err != nil {
return nil, nil, err
}
defer tx.Rollback() //nolint:errcheck
// Checking the category inside the transaction keeps a concurrent delete from
// turning the UPDATE below into a foreign key violation.
if categoryID != nil {
var exists bool
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM task_categories WHERE id=$1)`, *categoryID).Scan(&exists); err != nil {
return nil, nil, err
}
if !exists {
return nil, nil, ErrTaskCategoryNotFound
}
}
rows, err := tx.Query(`
UPDATE tasks SET category_id=$2
WHERE id=ANY($1::bigint[]) AND deleted_at IS NULL
RETURNING id`, taskIDs, categoryID)
if err != nil {
return nil, nil, err
}
updated := make([]int64, 0, len(taskIDs))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, nil, err
}
updated = append(updated, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, nil, err
}
var category *TaskCategory
if categoryID != nil {
fetched, err := scanTaskCategory(tx.QueryRow(`
SELECT `+taskCategoryCols+`
FROM task_categories category
LEFT JOIN tasks task ON task.category_id=category.id
WHERE category.id=$1
GROUP BY category.id`, *categoryID))
if err != nil {
return nil, nil, err
}
category = &fetched
}
if err := tx.Commit(); err != nil {
return nil, nil, err
}
return updated, category, nil
}
+92
View File
@@ -89,3 +89,95 @@ func TestCreateTaskRejectsMissingCategoryAtomically(t *testing.T) {
t.Fatalf("failed create leaked %d exploration rows", count)
}
}
func TestSetTasksCategoryBatch(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
defer d.Close()
stamp := time.Now().UnixNano()
category, err := d.CreateTaskCategory(fmt.Sprintf("Batch %d", stamp))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = d.DeleteTaskCategory(category.ID) })
ids := make([]int64, 0, 3)
for i := range 3 {
task, err := d.CreateTaskWithOptions(fmt.Sprintf("batch-move-%d-%d", stamp, i), "goal", TaskCreateOptions{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
ids = append(ids, task.ID)
}
// A deleted id must not abort the move for the surviving tasks; it is simply
// absent from the returned slice so the caller can report it.
missing := int64(1 << 62)
updated, moved, err := d.SetTasksCategory(append(append([]int64{}, ids...), missing), &category.ID)
if err != nil {
t.Fatal(err)
}
if len(updated) != len(ids) {
t.Fatalf("updated=%v, want the %d live ids only", updated, len(ids))
}
if moved == nil || moved.TaskCount != len(ids) {
t.Fatalf("category=%+v, want task_count=%d", moved, len(ids))
}
for _, id := range ids {
loaded, err := d.GetTask(id)
if err != nil || loaded.CategoryID == nil || *loaded.CategoryID != category.ID {
t.Fatalf("task %d not moved: %+v err=%v", id, loaded, err)
}
}
// A nil category clears the assignment for the whole batch.
updated, moved, err = d.SetTasksCategory(ids, nil)
if err != nil || len(updated) != len(ids) || moved != nil {
t.Fatalf("clear updated=%v category=%+v err=%v", updated, moved, err)
}
for _, id := range ids {
loaded, _ := d.GetTask(id)
if loaded.CategoryID != nil || loaded.CategoryName != "" {
t.Fatalf("task %d still categorized: %+v", id, loaded)
}
}
}
func TestSetTasksCategoryRejectsBadInputAtomically(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
defer d.Close()
stamp := time.Now().UnixNano()
task, err := d.CreateTaskWithOptions(fmt.Sprintf("batch-reject-%d", stamp), "goal", TaskCreateOptions{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
missingCategory := int64(1 << 62)
if _, _, err := d.SetTasksCategory([]int64{task.ID}, &missingCategory); !errors.Is(err, ErrTaskCategoryNotFound) {
t.Fatalf("missing category error=%v, want %v", err, ErrTaskCategoryNotFound)
}
if _, _, err := d.SetTasksCategory(nil, nil); !errors.Is(err, ErrTaskCategoryInvalid) {
t.Fatalf("empty ids error=%v, want %v", err, ErrTaskCategoryInvalid)
}
oversized := make([]int64, MaxTaskCategoryBatchSize+1)
for i := range oversized {
oversized[i] = task.ID
}
if _, _, err := d.SetTasksCategory(oversized, nil); !errors.Is(err, ErrTaskCategoryInvalid) {
t.Fatalf("oversized error=%v, want %v", err, ErrTaskCategoryInvalid)
}
// The rejected calls must leave the task untouched.
loaded, _ := d.GetTask(task.ID)
if loaded.CategoryID != nil {
t.Fatalf("rejected batch mutated task: %+v", loaded)
}
}