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:
1 parent
3eca390089
commit
fea78b2d6f
12 files changed
+607
-47
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user