Files
iboard/docs/V0.2/任务模块/任务模块数据库设计文档.md
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

21 KiB
Raw Permalink Blame History

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(草案)

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:

  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),首次访问 /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,未在需求中约束,设计预留