# 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) | | `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` | 列内/列表内排序 | | `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)` | — | 保留窗口计算 | > \* `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` | 应用层(领域统一裁决) | | `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)` | **防 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. **不做数据回填**(本模块为新功能,无历史数据)。 ### 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,未在需求中约束,设计预留 |