- 需求文档 §3.15: 手动归档(⋯菜单+连带子任务)/自动归档(项目级档位 3天/一周/一月/永不)/归档排除/游标分页大量查询/恢复/彻底删除/配置入口/权限 - 功能设计 §15: Project.autoArchive + Task.archived/archivedAt/archiveSource、 归档/恢复/归档查询 API、自动归档双保险执行(读时惰性+小时cron)、 边界处理; 覆盖自检表更新为 157 条 - 数据库设计: Project 补 notes/taskKinds/icon/autoArchive 字段, Task 补归档三字段与两索引, 迁移清单标注 20261026000000_task_archive 待实施
21 KiB
iBoard V0.2 任务模块 数据库设计文档
| 项 | 内容 |
|---|---|
| 版本 | V0.2(对应分支 dev-0.2,应用版本 0.2.0) |
| 文档状态 | 设计基线(待评审,未落库) |
| 数据库 | MariaDB / MySQL 8(Prisma provider = "mysql") |
| 字符集 | utf8mb4 / utf8mb4_unicode_ci |
| 时区约定 | 应用层统一 Asia/Shanghai,库内 DATETIME 存 UTC |
| 相关文件 | prisma/schema.prisma、prisma/migrations/20261024xxxxxx_add_task_module/migration.sql |
| 关联文档 | 任务模块需求文档.md、任务模块设计文档-框架设计.md、任务模块功能设计.md、任务模块界面设计.html |
1. 概述
任务模块新增 6 张表,并把既有 User 表补上反向关系(不改 User 结构):
| 表 | 作用 | 归属 |
|---|---|---|
Project |
项目(隔离与权限单位) | 项目域 |
ProjectMember |
项目成员(权限关系) | 项目域 |
BoardColumn |
看板列(名称 / 顺序 / 状态绑定 / 保留选项) | 看板域 |
Task |
任务与子任务(自关联,一层) | 任务域 |
TaskMember |
任务成员(多人标记) | 任务域 |
TaskTag |
任务标签 | 任务域 |
1.1 设计原则
- 列是实体,不是枚举。 看板列允许增删改与排序(T-COL),若仍用
plan/doing/action/done枚举,则"改名"无从表达、"排序"只能堆在 JSON 里、"删除列"会留下悬空字符串。因此独立建BoardColumn表,Task用columnId外键引用。 - 状态与列双落库,互不推导。
Task.status(三值)与Task.columnId是两个正交维度;未绑定的列(默认Action)必须允许二者不一致(T-BD-08、T-BD-16),任何"由状态反推列"或"由列反推状态"的写法都会丢失信息。联动只在写接口中按项目配置即时裁决。 - 配置不下沉到任务。 绑定与保留选项只存在
BoardColumn上,任务不冗余任何配置副本——改配置立即对所有任务生效,且不需要回填。 - 按项目隔离。 所有业务表都可通过
projectId归集,权限校验统一查ProjectMember。 - 软删除优先。 项目用
archived;任务删除为级联物理删除(T-ST-11),但删除前有二次确认。 - 迁移只增不改。 新增需求一律新迁移文件,不修改已执行的迁移。
2. 概念模型(ER)
┌────────────────┐
│ User │ (既有表, 仅补反向关系)
└───┬────────┬───┘
│ │
┌──────────▼──┐ ┌──▼──────────────┐
│ProjectMember│ │ TaskMember │
│(权限关系) │ │ (任务成员) │
└──────┬──────┘ └──▲──────────────┘
│ * │ *
│ │
┌───▼──────┐ ┌─┴──────┐
│ Project │1─*│ Task │*─1┌────────────┐
└───┬──────┘ └───┬────┘ │ BoardColumn│
│ * │ * └────────────┘
┌────▼───────┐ │ ▲
│BoardColumn │1────*┘ │
└────────────┘ (columnId) │
│
┌────────┐ │
│TaskTag │*──────────────────┘
└────────┘
Task 自关联: parentId ── 子任务(一层, 仅顶层任务可作父)
基数说明:
| 关系 | 基数 | 级联 |
|---|---|---|
| User → ProjectMember | 1 : N | Cascade |
| Project → ProjectMember | 1 : N | Cascade |
| Project → BoardColumn | 1 : N | Cascade |
| Project → Task | 1 : N | Cascade |
| BoardColumn → Task | 1 : N | RESTRICT(有任务的列不可直接删) |
| Task → Task(parent) | 1 : N | Cascade |
| Task → TaskMember | 1 : N | Cascade |
| Task → TaskTag | 1 : N | Cascade |
| User → Task(assignee / creator) | 1 : N | SetNull / Cascade(见下) |
BoardColumn → Task使用RESTRICT而非Cascade:防止误删列时静默丢任务。删除列必须先在其接口内把任务迁移走(事务内改columnId),迁移完成后再删列。
3. 表结构
3.1 Project — 项目
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
id |
INT PK AI |
否 | — | 主键 |
name |
VARCHAR(50) |
否 | — | 项目名 |
ownerId |
INT |
否 | — | 创建人(可退出项目,故与成员表分离) |
isPersonal |
BOOLEAN |
否 | false |
个人默认项目:不可删、不可加成员(T-PJ-02/03) |
archived |
BOOLEAN |
否 | false |
归档(T-PJ-09) |
notes |
JSON |
否 | [] |
项目信息块 [{title, content}](T-PJ-13) |
taskKinds |
JSON |
是 | NULL |
任务类型标签列表;NULL 时服务端回落默认四类(T-TK-14) |
icon |
TEXT |
是 | NULL |
项目图标 data URL(客户端裁剪 96x96 JPEG) |
autoArchive |
VARCHAR(8) |
否 | 'never' |
自动归档档位:3d/1w/1m/never(T-ARC-02) |
createdAt |
DATETIME(3) |
否 | now() |
— |
updatedAt |
DATETIME(3) |
否 | — | @updatedAt |
索引:
| 名称 | 字段 | 用途 |
|---|---|---|
idx_project_owner |
(ownerId) |
查"我的项目" |
idx_project_archived |
(archived) |
归档分区 |
约束说明
isPersonal唯一性由应用层保证(每个用户至多一个):UNIQUE(ownerId, isPersonal)不可用(普通项目也是false),改为在创建时查询判重。
3.2 ProjectMember — 项目成员
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
id |
INT PK AI |
否 | — | — |
projectId |
INT |
否 | — | FK → Project.id ON DELETE CASCADE |
userId |
INT |
否 | — | FK → User.id ON DELETE CASCADE |
permission |
VARCHAR(16) |
否 | 'member' |
预留权限分级(T-PM-03),本期恒为 member |
createdAt |
DATETIME(3) |
否 | now() |
加入时间 |
索引:
| 名称 | 字段 | 唯一 | 用途 |
|---|---|---|---|
uk_member_project_user |
(projectId, userId) |
✔ | 防重复加入;权限查询 |
idx_member_user |
(userId) |
— | "我参与的项目" |
注意:视图记忆与上次项目不在此表。视图记忆存设备端(T-VW-06、T-DT-06),早期草案把它放在
ProjectMember上,已在评审中否决。
3.3 BoardColumn — 看板列
本模块新增的核心实体,承载三件事:显示顺序、状态绑定、已完成保留选项。
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
id |
INT PK AI |
否 | — | — |
projectId |
INT |
否 | — | FK → Project.id ON DELETE CASCADE |
name |
VARCHAR(20) |
否 | — | 列名,项目内唯一(T-COL-07) |
sortOrder |
INT |
否 | 0 |
显示顺序;决定自动归位取"第一个匹配列"(T-BD-15) |
boundStatus |
VARCHAR(8) |
是 | NULL |
NULL=不绑定;或 todo/doing/done(T-BD-02) |
retention |
VARCHAR(8) |
否 | 'forever' |
never/today/3d/week/month/forever(T-FL-02) |
createdAt |
DATETIME(3) |
否 | now() |
— |
updatedAt |
DATETIME(3) |
否 | — | — |
索引:
| 名称 | 字段 | 唯一 | 用途 |
|---|---|---|---|
uk_column_project_name |
(projectId, name) |
✔ | 列名不重名(T-COL-07) |
idx_column_project_sort |
(projectId, sortOrder) |
— | 看板渲染与归位查找 |
默认数据(新建项目时由应用层在事务内写入,T-COL-01、T-BD-09):
| sortOrder | name | boundStatus | retention |
|---|---|---|---|
| 0 | Plan |
todo |
forever |
| 1 | Doing |
doing |
forever |
| 2 | Action |
NULL |
forever |
| 3 | Done |
done |
forever |
为什么保留选项默认为
forever而不是never:默认应"看得见",避免用户误以为数据丢失;隐藏是主动行为。
约束与校验
| 规则 | 实现层 |
|---|---|
| 列数 1~10(上限待确认,T-COL-06) | 应用层:新增/删除前计数校验 |
| 至少保留 1 列(T-EX-17) | 应用层:删除前 count > 1 |
| 删列必须指定迁移目标(T-EX-16) | 应用层:接口必须带 moveToColumnId |
boundStatus 取值合法 |
应用层白名单校验 |
retention 取值合法 |
应用层白名单校验 |
3.4 Task — 任务 / 子任务
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
id |
INT PK AI |
否 | — | — |
projectId |
INT |
否 | — | FK → Project.id ON DELETE CASCADE |
columnId |
INT |
否 | — | FK → BoardColumn.id ON DELETE RESTRICT(T-BD-01) |
parentId |
INT |
是 | NULL |
非空 = 一级子任务;FK → Task.id ON DELETE CASCADE(T-ST-01/11) |
title |
VARCHAR(200) |
否 | — | 唯一必填(T-TK-02);应用层限 30 字(T-TK-14) |
description |
TEXT |
否 | '' |
快速添加超长文本的余文并入此处(T-TK-14) |
kind |
VARCHAR(20) |
是 | NULL |
任务类型标签,取值见 Project.taskKinds |
markDate |
DATETIME(3) |
是 | NULL |
时间标记,完整日期存储、展示 MM-dd(T-TK-15;迁移 20261025120000) |
status |
VARCHAR(8) |
否 | 'todo' |
todo/doing/done(T-TK-03) |
doneAt |
DATETIME(3) |
是 | NULL |
完成时刻;保留选项判定依据(T-FL-10) |
assigneeId |
INT |
是 | NULL |
负责人(单人);新建默认创建者(T-TK-09) |
creatorId |
INT |
否 | — | 创建人;拷贝时为操作者(T-CP-04) |
extras |
JSON |
否 | [] |
扩展信息卡片 [{label,value,url?}](T-TK-04/05) |
sortOrder |
INT |
否 | 0 |
列内/列表内排序 |
archived |
BOOLEAN |
否 | false |
归档标记;主查询一律 archived = false(T-ARC-01/03) |
archivedAt |
DATETIME(3) |
是 | NULL |
归档时刻;归档查询的过滤/排序键(T-ARC-04) |
archiveSource |
VARCHAR(8) |
是 | NULL |
'manual' / 'auto' 溯源(T-ARC-01/02) |
createdAt |
DATETIME(3) |
否 | now() |
— |
updatedAt |
DATETIME(3) |
否 | — | — |
外键与索引:
| 名称 | 字段 | 类型 | 说明 |
|---|---|---|---|
fk_task_project |
(projectId) |
Cascade | 删项目连带删任务 |
fk_task_column |
(columnId) |
Restrict | 有任务的列不可直接删(见 §2 说明) |
fk_task_parent |
(parentId) |
Cascade | 删父任务连带删子任务(T-EX-07) |
fk_task_assignee |
(assigneeId) |
SetNull | 用户删除时清空负责人 |
fk_task_creator |
(creatorId) |
Cascade | 用户删除时连带删其创建的任务* |
idx_task_project_column |
(projectId, columnId) |
— | 看板按列查询 |
idx_task_project_status |
(projectId, status) |
— | 列表/统计 |
idx_task_parent |
(parentId) |
— | 取子任务 |
idx_task_done_at |
(projectId, doneAt) |
— | 保留窗口计算 |
idx_task_project_archived |
(projectId, archived, archivedAt) |
— | 已归档查询(T-ARC-04) |
idx_task_project_status_arch |
(projectId, status, archived) |
— | 主列表/看板排除归档(T-ARC-03) |
*
creatorId用 Cascade 会丢失"他人创建的任务"的历史;更稳妥的做法是Restrict并禁止删除有任务的用户。建议改为Restrict,与既有User删除行为保持一致(本期系统无删除用户入口)。
校验规则
| 规则 | 位置 |
|---|---|
title trim 后非空且 ≤ 200 |
应用层 |
parentId 非空时,其父任务 parentId 必须为空(禁止二级,T-ST-01) |
应用层 |
子任务 projectId == 父任务 projectId(T-EX-12) |
应用层 + 事务内校验 |
parentId 不得指向自身或其后代(T-EX-15) |
应用层 |
status='done' 时写 doneAt=now();退出 done 时置 NULL |
应用层(领域统一裁决) |
归档/恢复父任务连带全部后代,同事务内更新 archived/archivedAt/archiveSource(T-ARC-01/05) |
应用层 |
autoArchive 枚举 3d/1w/1m/never,非法 400(T-ARC-02) |
应用层 |
assigneeId 必须是 ProjectMember 中的用户(T-AS-03) |
应用层 |
3.5 TaskMember — 任务成员
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
id |
INT PK AI |
否 | — | — |
taskId |
INT |
否 | — | FK → Task.id ON DELETE CASCADE |
userId |
INT |
否 | — | FK → User.id ON DELETE CASCADE |
索引:uk_task_member (taskId, userId) 唯一。
3.6 TaskTag — 任务标签
| 字段 | 类型 | 空 | 默认 | 说明 |
|---|---|---|---|---|
id |
INT PK AI |
否 | — | — |
taskId |
INT |
否 | — | FK → Task.id ON DELETE CASCADE |
name |
VARCHAR(20) |
否 | — | 标签名 |
索引:idx_tag_task (taskId)。
4. Prisma Schema(草案)
model Project {
id Int @id @default(autoincrement())
name String @db.VarChar(50)
ownerId Int
isPersonal Boolean @default(false)
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("ProjectOwner", fields: [ownerId], references: [id])
members ProjectMember[]
columns BoardColumn[]
tasks Task[]
@@index([ownerId])
@@index([archived])
@@map("Project")
}
model ProjectMember {
id Int @id @default(autoincrement())
projectId Int
userId Int
permission String @default("member") @db.VarChar(16)
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([projectId, userId])
@@index([userId])
@@map("ProjectMember")
}
model BoardColumn {
id Int @id @default(autoincrement())
projectId Int
name String @db.VarChar(20)
sortOrder Int @default(0)
boundStatus String? @db.VarChar(8) // null | todo | doing | done
retention String @default("forever") @db.VarChar(8)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
tasks Task[]
@@unique([projectId, name])
@@index([projectId, sortOrder])
@@map("BoardColumn")
}
model Task {
id Int @id @default(autoincrement())
projectId Int
columnId Int
parentId Int?
title String @db.VarChar(200)
description String @default("") @db.Text
status String @default("todo") @db.VarChar(8)
doneAt DateTime?
assigneeId Int?
creatorId Int
extras Json @default("[]")
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
column BoardColumn @relation(fields: [columnId], references: [id], onDelete: Restrict)
parent Task? @relation("Subtask", fields: [parentId], references: [id], onDelete: Cascade)
children Task[] @relation("Subtask")
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
creator User @relation("TaskCreator", fields: [creatorId], references: [id], onDelete: Restrict)
members TaskMember[]
tags TaskTag[]
@@index([projectId, columnId])
@@index([projectId, status])
@@index([parentId])
@@index([projectId, doneAt])
@@map("Task")
}
model TaskMember {
id Int @id @default(autoincrement())
taskId Int
userId Int
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([taskId, userId])
@@map("TaskMember")
}
model TaskTag {
id Int @id @default(autoincrement())
taskId Int
name String @db.VarChar(20)
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@index([taskId])
@@map("TaskTag")
}
User 需补反向关系(不改字段):
model User {
// …既有字段…
ownedProjects Project[] @relation("ProjectOwner")
projectMembers ProjectMember[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
taskMembers TaskMember[]
}
5. 关键查询
| 场景 | 查询 |
|---|---|
| 我的项目 | SELECT p.* FROM Project p JOIN ProjectMember m ON m.projectId=p.id WHERE m.userId=? AND NOT p.archived |
| 项目详情 + 列 | SELECT * FROM BoardColumn WHERE projectId=? ORDER BY sortOrder |
| 看板任务(含子任务) | 顶层:WHERE projectId=? AND parentId IS NULL;子任务:WHERE parentId IN (...) 一次取回后在内存分组(避免 N+1) |
| 权限校验 | SELECT 1 FROM ProjectMember WHERE projectId=? AND userId=? |
| 保留窗口过滤 | 服务端可选:status <> 'done' OR (doneAt >= ?);前端另有一份同口径实现 |
| 自动归位选列 | 内存中对已加载的 columns(按 sortOrder)find(c => c.boundStatus === status) |
| 已归档查询(T-ARC-04) | WHERE projectId=? AND archived=1 [AND kind=?] [AND columnId=?] [AND archivedAt BETWEEN ? AND ?] [AND title LIKE ?] ORDER BY archivedAt DESC, id DESC LIMIT ?+1;多取 1 条判定 nextCursor |
防 N+1:一次请求返回 { project, columns, members, tasks },子任务与父任务同批返回,由前端按 parentId 分组。
6. 迁移与初始化
6.1 迁移文件
prisma/migrations/20261024xxxxxx_add_task_module/migration.sql:
- 建
Project、ProjectMember、BoardColumn、Task、TaskMember、TaskTag六张表; - 建外键(注意
Task.columnId为RESTRICT); - 建唯一索引与普通索引;
- 不做数据回填(本模块为新功能,无历史数据)。
后续增量迁移:
| 迁移 | 内容 | 状态 |
|---|---|---|
20261022120000_add_checkin_checklist |
打卡/清单模块 | 已上线 |
20261025030000_task_kind |
Project.taskKinds / Task.kind |
已上线 |
20261025120000_task_mark_date |
Task.markDate |
已上线 |
20261025130000_project_icon |
Project.icon |
已上线 |
20261026000000_task_archive |
任务归档三字段 + 两索引(T-ARC,DDL 见 §3.1/§3.4 与功能设计 §15) | 待实施 |
6.2 个人默认项目初始化
-- 应用层逻辑(伪 SQL),首次访问 /api/projects 时执行
INSERT INTO Project (name, ownerId, isPersonal, createdAt, updatedAt)
SELECT CONCAT(u.name, '的任务'), u.id, 1, NOW(3), NOW(3)
FROM User u
WHERE u.id = ?
AND NOT EXISTS (SELECT 1 FROM Project p WHERE p.ownerId = u.id AND p.isPersonal = 1);
-- 随后为该项目的 4 个默认列插入 BoardColumn(同事务)
6.3 新建普通项目
同事务内:建 Project(isPersonal=0)→ 写 ProjectMember(owner) → 写 4 个默认 BoardColumn。任一步失败整体回滚,避免出现"无列项目"。
7. 容量与性能估算
| 项 | 估算 | 依据 |
|---|---|---|
| 单项目任务 | 千级 / 年 | T-NF-05 |
| 单项目列 | ≤ 10 | T-COL-06 |
Task 行宽 |
~400 B + JSON | 含 extras |
| 单项目年增量 | ≈ 400 KB | 1k 任务 × 400B |
| 索引开销 | 约表大小 30% | 4 个二级索引 |
无需分表;按 projectId 的查询均可走索引。
8. 与既有模块的关系
| 项 | 说明 |
|---|---|
User 表 |
仅补反向关系,不改结构,不影响打卡/宏观/监控模块 |
打卡模块(Habit/CheckIn) |
完全独立,同为"按用户隔离";任务模块改为"按项目隔离" |
| 迁移顺序 | 在 20261022120000_add_checkin_checklist 之后追加,互不依赖 |
| 时区 | 沿用 Asia/Shanghai 应用层口径;doneAt 用 DATETIME(3) 存 UTC |
9. 未决事项
| # | 事项 | 当前处理 |
|---|---|---|
| 1 | 列数上限是否 10 | 按 10 设计(T-COL-06 标注"待确认"),若定其他值只改常量 |
| 2 | Task.creatorId 外键行为 |
草案建议 Restrict(见 §3.4 注),需与"用户删除策略"一并确认 |
| 3 | 孤儿项目(无成员)的维护 | 本期不处理(T-PJ-08),由后台流程清理;数据库层保留数据 |
| 4 | extras 条目数量上限 |
建议 ≤ 20,未在需求中约束,设计预留 |