docs(V0.2): 新增设计文档 / 数据库设计文档 / 界面设计静态原型

- 设计文档.md: 架构与分层、目录结构、周期口径与边界规则、前端组件与交互、
  后端接口清单、关键流程时序、非功能设计、测试验证、风险与演进
- 数据库设计文档.md: ER、5 张表逐字段、索引与外键级联、关键设计决策
  (date 用 VARCHAR(10) 的原因 / 不冗余计数 / seq 幂等 / 归档语义 / 模板 JSON)、
  容量估算、典型 SQL、迁移与回滚、V0.3 预留
- 界面设计.html: 420x820 静态高保真原型 8 屏 (打卡/日历/清单/新建弹窗/
  完成特效/明天计划视图/清单完成/空态) + 设计规范、组件状态表、动效与适配约定
This commit is contained in:
cheney
2026-10-22 14:12:24 +08:00
parent 004f8c262d
commit 9b4705870d
3 changed files with 1552 additions and 0 deletions
+325
View File
@@ -0,0 +1,325 @@
# iBoard V0.2 数据库设计文档
| 项 | 内容 |
| --- | --- |
| 版本 | V0.2 |
| 文档状态 | 已实现(与代码一致) |
| 数据库 | MariaDB / MySQL 8(Prisma `provider = "mysql"`) |
| 字符集 | `utf8mb4 / utf8mb4_unicode_ci` |
| 时区约定 | 应用层统一 `Asia/Shanghai`,库内 `DATETIME` 存 UTC |
| 相关文件 | `prisma/schema.prisma`、`prisma/migrations/20261022120000_add_checkin_checklist/migration.sql` |
| 关联文档 | `需求文档.md`、`设计文档.md`、`界面设计.html` |
---
## 1. 概述
V0.2 新增「打卡 / 日历 / 清单」三个功能域,共引入 **5 张表**:
| 表 | 作用 | 归属域 |
| --- | --- | --- |
| `Habit` | 打卡项(每天/每周/每月/每年 n 次的目标定义) | 打卡 |
| `CheckIn` | 打卡记录(一次打卡 = 一条) | 打卡 |
| `CheckList` | 检查清单(出差/回老家等场景的一次性清单) | 清单 |
| `CheckListItem` | 检查项(勾选即完成) | 清单 |
| `CheckListTemplate` | 清单模板(系统预置 + 用户自定义) | 清单 |
日历视图**不新增表**:完全由 `Habit` + `CheckIn` 按月聚合得出(见 §7.3)。
### 1.1 设计原则
1. **记录为真,进度不落库。** 环形进度、完成率、streak 一律由 `CheckIn` 现场聚合,不在 `Habit` 上冗余 `doneCount` 字段——避免并发计数不一致、以及用户中途把「每周 3 次」改成「每周 5 次」后需要回填历史的问题。
2. **日期以本地日历日字符串存储。** `CheckIn.date` 用 `VARCHAR(10)` 存 `"YYYY-MM-DD"`(Asia/Shanghai 下的日历日),而不是 `DATETIME`:范围查询走索引、字典序等于时间序、天然幂等边界,且彻底规避「UTC 存 + 本地读」导致的跨零点归属错误。
3. **软删除优先。** 打卡项删除默认走 `archived` 归档(保留历史统计),只有显式 `?hard=1` 才物理删除。
4. **一切按 `userId` 隔离。** 所有业务表都带 `userId`,`Habit`/`CheckIn`/`CheckList`/`CheckListTemplate` 外键 `ON DELETE CASCADE` 到 `User`。
5. **迁移只增不改。** 新增需求一律新迁移文件,不修改已执行的迁移(部署走 `prisma migrate deploy`)。
---
## 2. 概念模型(ER)
```
┌──────────┐
│ User │ (既有表, 本次仅新增反向关系)
└────┬─────┘
┌───────────────┼────────────────────┬─────────────────┐
│ │ │ │
┌────▼────┐ ┌────▼────┐ ┌─────▼─────┐ ┌──────▼──────┐
│ Habit │1───*│ CheckIn │ │ CheckList │1─* │CheckListItem│
└─────────┘ └─────────┘ └───────────┘ └─────────────┘
打卡项 打卡记录 场景清单 检查项
(周期+目标n) (habitId,date,seq) (done, doneAt)
▲
│ 生成(复制条目, 不建外键)
┌─────┴──────────┐
│CheckListTemplate│
│ (builtin/自定义) │
└────────────────┘
```
基数说明:
| 关系 | 基数 | 级联 |
| --- | --- | --- |
| User → Habit | 1 : N | Cascade |
| User → CheckIn | 1 : N | Cascade |
| Habit → CheckIn | 1 : N | Cascade |
| User → CheckList | 1 : N | Cascade |
| CheckList → CheckListItem | 1 : N | Cascade |
| User → CheckListTemplate | 1 : N(可空,系统预置为空) | Cascade |
| CheckListTemplate → CheckList | 无外键,创建时**复制**条目内容 | — |
---
## 3. 表结构
### 3.1 `Habit` — 打卡项
周期目标的**定义**表,不含任何计数。
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `userId` | INT FK | 否 | — | 所属用户 |
| `name` | VARCHAR(191) | 否 | — | 名称,接口校验 1~20 字 |
| `period` | VARCHAR(191) | 否 | `'day'` | `day` \| `week` \| `month` \| `year` |
| `target` | INT | 否 | `1` | 周期内目标次数 n(校验 ≥1) |
| `color` | VARCHAR(191) | 否 | `'#1677ff'` | 环形与标记主色(7 色预设之一) |
| `icon` | VARCHAR(191) | 否 | `''` | emoji / 图标名,**V0.2 预留不展示** |
| `note` | VARCHAR(191) | 否 | `''` | 备注 |
| `archived` | BOOLEAN | 否 | `false` | 归档后不出现在待办,历史保留 |
| `sortOrder` | INT | 否 | `0` | 排序权重(升序) |
| `createdAt` | DATETIME(3) | 否 | now() | 用于「创建之前的日期不计入月历期望」 |
| `updatedAt` | DATETIME(3) | 否 | 自动 | — |
索引:`INDEX(userId, archived, sortOrder)` — 覆盖「取当前用户未归档打卡项」主查询。
### 3.2 `CheckIn` — 打卡记录
**一次打卡 = 一条记录**,同日多次用 `seq` 递增。
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `habitId` | INT FK | 否 | — | 所属打卡项 |
| `userId` | INT FK | 否 | — | 冗余用户(用于单用户全量区间查询,避免 join) |
| `date` | VARCHAR(10) | 否 | — | 本地日历日 `"YYYY-MM-DD"` |
| `seq` | INT | 否 | `1` | 当日第几次(1..n,超额继续累加) |
| `note` | VARCHAR(191) | 否 | `''` | 单次备注(接口截断 200 字) |
| `source` | VARCHAR(191) | 否 | `'user'` | `user` \| `backfill`(补打) |
| `createdAt` | DATETIME(3) | 否 | now() | 打卡时刻(UTC) |
约束与索引:
| 名称 | 类型 | 字段 | 作用 |
| --- | --- | --- | --- |
| `CheckIn_habitId_date_seq_key` | **UNIQUE** | `(habitId, date, seq)` | 并发重复提交的幂等防线(冲突 → P2002 → 409) |
| `CheckIn_userId_date_idx` | INDEX | `(userId, date)` | 月历按月区间扫全量记录 |
| `CheckIn_habitId_date_idx` | INDEX | `(habitId, date)` | 单打卡项周期区间聚合 |
### 3.3 `CheckList` — 检查清单
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `userId` | INT FK | 否 | — | 所属用户 |
| `title` | VARCHAR(191) | 否 | — | 清单名,如「出差 · 杭州」 |
| `scene` | VARCHAR(191) | 否 | `''` | 场景:`出差` \| `回老家` \| `其他` |
| `note` | VARCHAR(191) | 否 | `''` | 备注 |
| `archived` | BOOLEAN | 否 | `false` | 归档 |
| `createdAt` / `updatedAt` | DATETIME(3) | 否 | — | 列表默认按 `updatedAt` 倒序 |
索引:`INDEX(userId, archived)` — 对应「进行中 / 已归档」两个分段列表。
### 3.4 `CheckListItem` — 检查项
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `listId` | INT FK | 否 | — | 所属清单 |
| `title` | VARCHAR(191) | 否 | — | 条目名(校验 ≤40 字) |
| `note` | VARCHAR(191) | 否 | `''` | 条目备注 |
| `done` | BOOLEAN | 否 | `false` | 是否已完成 |
| `doneAt` | DATETIME(3) | **是** | NULL | 勾选时刻;取消勾选置回 NULL |
| `sortOrder` | INT | 否 | `0` | 排序(升序,同值按 id) |
| `createdAt` / `updatedAt` | DATETIME(3) | 否 | — | — |
索引:`INDEX(listId, sortOrder)`。
### 3.5 `CheckListTemplate` — 清单模板
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `userId` | INT FK | **是** | NULL | NULL = 系统预置(`builtin=true`) |
| `name` | VARCHAR(191) | 否 | — | 模板名 |
| `scene` | VARCHAR(191) | 否 | `''` | 场景标签 |
| `items` | **JSON** | 否 | — | `[{ title, note }]`,创建清单时展开为 `CheckListItem` |
| `builtin` | BOOLEAN | 否 | `false` | 系统预置:只读、不可删(接口层限制) |
| `createdAt` / `updatedAt` | DATETIME(3) | 否 | — | — |
索引:`INDEX(userId, builtin)` — 查询条件为 `builtin=true OR userId=me`。
系统预置数据(首次访问 `GET /api/checklists/templates` 时**幂等写入**):
| 模板 | 条目数 | 条目 |
| --- | --- | --- |
| 出差 | 11 | 身份证/证件、手机充电器+充电宝、笔记本电脑+电源、耳机、换洗衣物、洗漱包、常用药、雨伞、车票/机票/酒店确认、工牌/名片、报销单据 |
| 回老家 | 12 | 身份证/证件、钥匙、手机充电器+充电宝、换洗衣物、洗漱包、常用药、给家人的礼物/伴手礼、长辈的药品/营养品、口罩、现金零钱、车票/加油/行程确认、家里门窗水电检查 |
---
## 4. 外键与级联
| 子表.字段 | 父表.字段 | ON DELETE | ON UPDATE |
| --- | --- | --- | --- |
| `Habit.userId` | `User.id` | CASCADE | CASCADE |
| `CheckIn.habitId` | `Habit.id` | CASCADE | CASCADE |
| `CheckIn.userId` | `User.id` | CASCADE | CASCADE |
| `CheckList.userId` | `User.id` | CASCADE | CASCADE |
| `CheckListItem.listId` | `CheckList.id` | CASCADE | CASCADE |
| `CheckListTemplate.userId` | `User.id` | CASCADE | CASCADE |
删除链路:删用户 → 级联清 Habit/CheckIn/CheckList/Template;删 Habit → 清其全部 CheckIn;删 CheckList → 清其全部 Item。业务层删打卡项默认只置 `archived=true`,物理删需 `?hard=1`(此时先 `deleteMany` 记录再删主表)。
---
## 5. 关键设计决策
### 5.1 为什么 `date` 用 `VARCHAR(10)` 而不是 `DATETIME`
| 维度 | VARCHAR(10) 本地日历日 | DATETIME(UTC) |
| --- | --- | --- |
| 跨零点归属 | 明确:由应用层按 Asia/Shanghai 计算一次,落库即定 | 读时需要再转时区,易错 |
| 范围查询 | `date >= '2026-10-01' AND date <= '2026-10-31'` 直接走索引 | 需函数/边界换算,易失效 |
| 幂等边界 | `UNIQUE(habitId, date, seq)` 天然按「日」收敛 | 需按日截取 |
| 字典序 | 等于时间序,可安全字符串比较 | — |
| 存储 | 10 字节 | 5 字节(略优,但不足以抵消上述成本) |
代价:跨时区展示需自行换算——本项目全链路 Asia/Shanghai,不成为问题。
### 5.2 为什么不冗余 `doneCount` / `streak`
- **并发**:计数需事务 + 行锁,聚合查询天然一致。
- **改配置**:用户把「每周 3 次」改成「每周 5 次」,冗余计数立刻失真,需要全量重算;现方案改 `target` 立即生效。
- **撤销**:撤销即删一条记录,无需维护计数器。
- **成本**:单个用户打卡项数量在 10~30 量级,月历查询一次拉取整月记录(≤ 数百行)在内存聚合,开销可接受。
### 5.3 `seq` 与幂等
同日多次打卡用 `seq = max(seq)+1`。唯一约束 `(habitId, date, seq)` 使并发重复提交必然撞 P2002,服务端转 409「该次打卡已记录」,前端幂等收敛,不会出现「点两下加两次」的体验事故。
### 5.4 归档 vs 物理删除
| 操作 | 语义 | 数据影响 |
| --- | --- | --- |
| `DELETE /api/checkin/habits/:id` | 归档(默认) | `archived=true`,记录全保留 |
| `DELETE /api/checkin/habits/:id?hard=1` | 物理删除 | 先删 CheckIn,再删 Habit |
| `DELETE /api/checklists/:id` | 物理删除(一次性清单,不留历史) | 先删 Item,再删 List |
理由:打卡是长期行为数据,历史构成 streak 与完成率;清单是短期事务,用完即归档/删除。
### 5.5 模板用 JSON 而非明细表
模板条目结构固定(`title` + `note`)、只在「创建清单」时整体读取并展开、不需要按条目检索或统计 —— 用 `JSON` 列避免额外的 `TemplateItem` 表与 join。展开后落到 `CheckListItem`,之后模板改动不影响已生成清单。
---
## 6. 数据量估算
单用户规模假设:打卡项 20 个,平均每天打卡 25 次;清单每月 4 份,每份 12 条。
| 表 | 年增量(单用户) | 行大小(约) | 年占用(约) |
| --- | --- | --- | --- |
| `CheckIn` | 25 × 365 ≈ 9,100 | ~80 B | ~0.7 MB |
| `CheckListItem` | 4 × 12 × 12 = 576 | ~90 B | ~0.05 MB |
| `Habit` / `CheckList` / `CheckListTemplate` | < 100 | ~200 B | 忽略 |
结论:**无需分区、无需归档策略**。10 用户 × 5 年 ≈ 45 万行 CheckIn,仍远低于单表瓶颈。若未来接入多租户或做年视图长周期统计,再考虑按月汇总表。
---
## 7. 典型查询
### 7.1 今日待办 + 周期进度
```sql
-- 步骤 1: 取未归档打卡项 (走 Habit_userId_archived_sortOrder_idx)
SELECT id, name, period, target, color FROM Habit
WHERE userId = ? AND archived = 0 ORDER BY sortOrder, id;
-- 步骤 2: 取覆盖所有周期窗口的最小区间内的记录 (走 CheckIn_userId_date_idx)
SELECT habitId, date FROM CheckIn
WHERE userId = ? AND date >= ? AND date <= ?; -- 如 week: '2026-10-19' ~ '2026-10-25'
-- 步骤 3: 内存按 habitId/date 分组 -> 计算 done/percent/streak
```
### 7.2 打一次卡
```sql
SELECT MAX(seq) FROM CheckIn WHERE habitId = ? AND date = ?; -- -> seq
INSERT INTO CheckIn (habitId, userId, date, seq, note, source) VALUES (?, ?, ?, ?, ?, 'user');
-- 冲突 -> P2002 -> 409
```
撤销(撤销本周期最近一次):
```sql
SELECT id FROM CheckIn WHERE habitId = ? AND date BETWEEN ? AND ?
ORDER BY date DESC, seq DESC LIMIT 1;
DELETE FROM CheckIn WHERE id = ?;
```
### 7.3 月历(日历视图数据来源)
```sql
SELECT habitId, date FROM CheckIn
WHERE userId = ? AND date >= '2026-10-01' AND date <= '2026-10-31';
```
按天聚合后与 `Habit.expectedPerDay` 比较得出日状态:
| 状态 | 条件 | 展示 |
| --- | --- | --- |
| `full` | 全部打卡项当日次数 ≥ 日均期望 | 绿点 |
| `over` | 有任一打卡项当日次数 > 日均期望 | 紫点 |
| `partial` | 有完成但未全完成 | 黄点 |
| `empty` | 无记录或当天无打卡项 | 无点 |
`Habit.createdAt` 早于该日的才计入期望,避免新建打卡项把历史日期标成「未完成」。
### 7.4 清单列表(带进度)
```sql
SELECT l.*, i.* FROM CheckList l
LEFT JOIN CheckListItem i ON i.listId = l.id
WHERE l.userId = ? AND l.archived = ?
ORDER BY l.updatedAt DESC, i.sortOrder, i.id;
```
---
## 8. 迁移
| 项 | 内容 |
| --- | --- |
| 迁移目录 | `prisma/migrations/20261022120000_add_checkin_checklist/` |
| 内容 | 5 张 `CREATE TABLE` + 6 条 `ADD CONSTRAINT`(见 `migration.sql`) |
| 生成方式 | 手写 SQL(与 `prisma migrate diff` 产出格式一致),已通过 `prisma migrate deploy` 验证 |
| 部署命令 | `npx prisma migrate deploy && npx prisma generate` |
| 回滚 | 无自动回滚。应急:`DROP TABLE CheckIn, CheckListItem, CheckList, CheckListTemplate, Habit;`(注意外键顺序)并把 `_prisma_migrations` 中该记录删除 |
>V0.2 全为新增表与新增字段(`User` 侧仅新增 Prisma 反向关系,不产生 DDL),对既有数据零影响。
---
## 9. V0.3 预留
| 方向 | 可能的表变更 |
| --- | --- |
| 打卡提醒 | `Habit` 增加 `remindAt TIME`、`remindDays` |
| 打卡统计快照 | 新增 `HabitDailyStat(userId, date, done, expected)` 汇总表,用于年视图/热力图加速 |
| 清单共享协作 | 新增 `CheckListShare(listId, userId, role)` |
| 模板市场 | `CheckListTemplate` 增加 `visibility`、`useCount` |
以上均可通过「新增表 / 新增可空字段」完成,不影响 V0.2 数据结构。