Files
iboard/docs/V0.2/任务模块/任务模块数据库设计文档.md
T
jif2.zhang 39282b9633 docs(任务模块): 任务归档 T-ARC 需求与设计 (T-ARC-01~08)
- 需求文档 §3.15: 手动归档(⋯菜单+连带子任务)/自动归档(项目级档位
  3天/一周/一月/永不)/归档排除/游标分页大量查询/恢复/彻底删除/配置入口/权限
- 功能设计 §15: Project.autoArchive + Task.archived/archivedAt/archiveSource、
  归档/恢复/归档查询 API、自动归档双保险执行(读时惰性+小时cron)、
  边界处理; 覆盖自检表更新为 157 条
- 数据库设计: Project 补 notes/taskKinds/icon/autoArchive 字段,
  Task 补归档三字段与两索引, 迁移清单标注 20261026000000_task_archive 待实施
2026-10-25 22:59:08 +08:00

468 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 设计原则
1. **列是实体,不是枚举。** 看板列允许增删改与排序(T-COL),若仍用 `plan/doing/action/done` 枚举,则"改名"无从表达、"排序"只能堆在 JSON 里、"删除列"会留下悬空字符串。因此独立建 `BoardColumn` 表,`Task` 用 `columnId` 外键引用。
2. **状态与列双落库,互不推导。** `Task.status`(三值)与 `Task.columnId` 是两个正交维度;未绑定的列(默认 `Action`)必须允许二者不一致(T-BD-08、T-BD-16),任何"由状态反推列"或"由列反推状态"的写法都会丢失信息。联动只在写接口中按项目配置即时裁决。
3. **配置不下沉到任务。** 绑定与保留选项只存在 `BoardColumn` 上,任务不冗余任何配置副本——改配置立即对所有任务生效,且不需要回填。
4. **按项目隔离。** 所有业务表都可通过 `projectId` 归集,权限校验统一查 `ProjectMember`。
5. **软删除优先。** 项目用 `archived`;任务删除为级联物理删除(T-ST-11),但删除前有二次确认。
6. **迁移只增不改。** 新增需求一律新迁移文件,不修改已执行的迁移。
---
## 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(草案)
```prisma
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` 需补反向关系(不改字段):
```prisma
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`:
1. 建 `Project`、`ProjectMember`、`BoardColumn`、`Task`、`TaskMember`、`TaskTag` 六张表;
2. 建外键(注意 `Task.columnId` 为 `RESTRICT`);
3. 建唯一索引与普通索引;
4. **不做数据回填**(本模块为新功能,无历史数据)。
后续增量迁移:
| 迁移 | 内容 | 状态 |
| --- | --- | --- |
| `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
-- 应用层逻辑(伪 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,未在需求中约束,设计预留 |