TaskFlow 领域模型
TaskFlow 管理项目、项目成员和任务。本页介绍四张表、每个关系如何用 TypeORM 声明、任务状态的生命周期,以及 OVERDUE 状态带来的设计取舍。这些实体分别出现在 J4(Project)、 J5(Task)和 J8(User、成员)。
模型
┌──────────┐ 1 n ┌─────────────────┐ n 1 ┌──────────┐
│ users ├──────────┤ project_members ├──────────┤ projects │
└────┬─────┘ └─────────────────┘ └────┬─────┘
│ 1 │ 1
│ assignee_id (nullable) project_id │
│ n │ n
│ ┌─────────┐ │
└───────────────────┤ tasks ├────────────────────┘
└─────────┘- 一个用户参与多个项目,一个项目有多个成员:多对多关系,由
project_members表承载。 - 一个项目包含多个任务;一个任务只属于一个项目。
- 任务可以指派给一个用户(也可以不指派给任何人)。
下面的表格依据生成的迁移(src/database/migrations/1793093891613-InitialSchema.ts)整理。所有主键都是由 PostgreSQL 生成的 UUID(uuid_generate_v4())。
users
| 列 | PostgreSQL 类型 | User 属性 | 约束 |
|---|---|---|---|
id | uuid | id: string | 主键 |
email | varchar(254) | email: string | 非空,唯一 |
password_hash | varchar | passwordHash: string | 非空,从不序列化(@Exclude()),不出现在 OpenAPI schema 中(@ApiHideProperty()) |
role | 枚举 user_role(ADMIN、MEMBER) | role: Role | 默认 MEMBER |
created_at | timestamptz | createdAt: Date | 默认 now() |
projects
| 列 | PostgreSQL 类型 | Project 属性 | 约束 |
|---|---|---|---|
id | uuid | id: string | 主键 |
name | varchar(100) | name: string | 非空 |
description | text | description: string | null | 可为空 |
archived | boolean | archived: boolean | 默认 false |
created_at、updated_at | timestamptz | createdAt、updatedAt: Date | 由 TypeORM 填充 |
deleted_at | timestamptz | deletedAt: Date | null | 由 softDelete() 填充;之后 find() 会忽略这一行 |
project_members
| 列 | PostgreSQL 类型 | ProjectMember 属性 | 约束 |
|---|---|---|---|
project_id | uuid | projectId: string | 复合主键;外键指向 projects,ON DELETE CASCADE |
user_id | uuid | userId: string | 复合主键;外键指向 users,ON DELETE CASCADE |
joined_at | timestamptz | joinedAt: Date | 默认 now() |
tasks
| 列 | PostgreSQL 类型 | Task 属性 | 约束 |
|---|---|---|---|
id | uuid | id: string | 主键 |
title | varchar(200) | title: string | 非空 |
status | 枚举 task_status | status: TaskStatus | 默认 TODO |
due_date | date | dueDate: string | null | 可为空;返回的是字符串 'YYYY-MM-DD' |
archived | boolean | archived: boolean | 默认 false |
project_id | uuid | projectId: string | 非空;外键指向 projects,ON DELETE CASCADE |
assignee_id | uuid | assigneeId: string | null | 可为空;外键指向 users,ON DELETE SET NULL |
created_at、updated_at | timestamptz | createdAt、updatedAt: Date | 由 TypeORM 填充 |
(project_id, status) 上的索引 idx_tasks_project_status:服务于最常见的查询,即按状态过滤某个项目的任务列表。
TaskFlow 在计划的模型之上增加的内容
计划描述了 USER (id, email, passwordHash, role)、PROJECT (id, name, description, archived)、 TASK (id, title, status, dueDate) 以及成员关联表。TaskFlow 增加了:
| 增加项 | 原因 |
|---|---|
created_at、updated_at | 按创建时间排序,便于追溯(J4) |
projects.deleted_at | 用 softDelete() 做软删除(J4) |
tasks.archived | 归档项目时,在同一个事务中归档它的任务(J5) |
project_members.joined_at | 关联表带有数据:它成为一个独立的实体 |
ON DELETE CASCADE 只在物理删除时起作用:通过 API 删除项目是逻辑删除(deleted_at),关联的行(包括成员)都会 保留。所以访问控制在检查成员身份之前,会先检查项目是否存在(J8,成员)。
TypeORM 关系与实体
| 关系 | 持有外键的一方(@ManyToOne + @JoinColumn) | 反向一方 | ON DELETE |
|---|---|---|---|
| 一个项目,n 个任务 | Task.project,列 project_id | Project.tasks(@OneToMany) | CASCADE |
| 一个用户,n 个被指派的任务 | Task.assignee,列 assignee_id,可为空 | 无:单向关系 | SET NULL |
| n 个用户,n 个项目 | ProjectMember.project 和 ProjectMember.user | Project.members;User 一侧没有 | 两侧都是 CASCADE |
和 JPA 一样,外键位于 @ManyToOne 一侧,@JoinColumn({ name: 'project_id' }) 指定列名。传给 @OneToMany 的函数 (task) => task.project 起到 mappedBy 的作用。目标类型通过函数给出(() => Project),因为实体文件之间互相导入: 类只在两个文件都加载完之后才会被读取。
@Entity('tasks')
@Index('idx_tasks_project_status', ['projectId', 'status'])
export class Task {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 200 })
title: string;
@Column({ type: 'enum', enum: TaskStatus, enumName: 'task_status', default: TaskStatus.Todo })
status: TaskStatus;
// A PostgreSQL `date` comes back as a 'YYYY-MM-DD' string, not a Date.
@Column({ name: 'due_date', type: 'date', nullable: true })
dueDate: string | null;
@Column({ type: 'boolean', default: false })
archived: boolean;
// Keeping the foreign key as a plain column avoids loading the relation
// just to read or set an id.
@Column({ name: 'project_id', type: 'uuid' })
projectId: string;
@ManyToOne(() => Project, (project) => project.tasks, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project: Project;
@Column({ name: 'assignee_id', type: 'uuid', nullable: true })
assigneeId: string | null;
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'assignee_id' })
assignee: User | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}Task 把外键声明了两次:一次作为关系(project),一次作为普通列(projectId)。这个列让你不用加载项目就能读取或 设置它的 id,TasksService 和复合索引都用到了这一点。
@Entity('projects')
export class Project {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'boolean', default: false })
archived: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// softDelete() fills this column; normal find() calls then skip the row.
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@OneToMany(() => Task, (task) => task.project)
tasks: Task[];
@OneToMany(() => ProjectMember, (member) => member.project)
members: ProjectMember[];
}多对多关系没有使用 @ManyToMany:关联表带有数据(joinedAt),所以它成为一个带复合主键的实体,就像 JPA 中使用 @IdClass 或 @EmbeddedId 的实体。
// The join table of a many-to-many relation, modelled as its own entity so it
// can carry data (joinedAt). Composite primary key: (project_id, user_id).
@Entity('project_members')
export class ProjectMember {
@PrimaryColumn({ name: 'project_id', type: 'uuid' })
projectId: string;
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
userId: string;
@ManyToOne(() => Project, (project) => project.members, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@CreateDateColumn({ name: 'joined_at', type: 'timestamptz' })
joinedAt: Date;
}@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 254, unique: true })
email: string;
// Never serialized: ClassSerializerInterceptor applies @Exclude on responses.
// @ApiHideProperty keeps it out of the OpenAPI schema as well.
@Exclude()
@ApiHideProperty()
@Column({ name: 'password_hash', type: 'varchar' })
passwordHash: string;
@Column({ type: 'enum', enum: Role, enumName: 'user_role', default: Role.Member })
role: Role;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}由模型得出的访问规则:
ADMIN能看到所有项目;MEMBER只能看到自己是成员的项目。- 项目的创建者在创建项目的同一个事务中成为成员(J5,事务)。
- 非成员收到 404,而不是 403,以免泄露项目的存在(J8,成员)。
- 被逻辑删除的项目对所有人都返回 404,包括管理员,它的任务也一样:
ProjectsService.findOne()要求项目存在并且 调用者是它的成员(或管理员),而TasksService使用的assertAccess()委托给它。
任务状态
export enum TaskStatus {
Todo = 'TODO',
InProgress = 'IN_PROGRESS',
Done = 'DONE',
Overdue = 'OVERDUE',
}| 状态 | 含义 | 由谁设置 |
|---|---|---|
TODO | 待办 | 创建时的默认值(列默认值) |
IN_PROGRESS | 进行中 | 客户端,通过 PATCH /v1/tasks/:id/status |
DONE | 已完成 | 客户端,同上 |
OVERDUE | 已逾期 | 每日定时任务,针对截止日期已过、未归档的 TODO 或 IN_PROGRESS 任务;客户端也可以发送这个值 |
TaskFlow 不强制任何状态流转规则:UpdateTaskStatusDto 接受枚举中的任意值(@IsEnum(TaskStatus))。状态变化会先被 保存,再以 task.status.changed 事件的形式发布;提交与当前相同的状态,既不会写入,也不会产生事件。
async updateStatus(id: string, status: TaskStatus, user: AuthUser): Promise<Task> {
const task = await this.tasks.findOneBy({ id });
if (!task) throw new NotFoundException(`Task ${id} not found`);
await this.projects.assertAccess(task.projectId, user);
if (task.status === status) return task;
const from = task.status;
task.status = status;
const saved = await this.tasks.save(task);
// Emit only once the change is saved. Listeners run separately: if one
// fails, this request still succeeds (see NotificationsListener).
this.events.emit(
TASK_STATUS_CHANGED,
new TaskStatusChangedEvent(task.id, task.projectId, from, status, user.id, this.clock()),
);
return saved;
}按状态过滤通过查询字符串完成:GET /v1/projects/:projectId/tasks?status=DONE。枚举之外的值(?status=LATE)会被 ParseEnumPipe 以 400 拒绝(端到端测试已验证)。
OVERDUE:一个设计选择
计划把 OVERDUE 作为一个状态,由定时任务每晚设置(J9)。TaskFlow 遵循计划:
/** One UPDATE for every late task; returns how many changed. */
async markOverdue(): Promise<number> {
// The Paris calendar day, like the cron schedule: toISOString() would give
// the UTC day, one day behind at 01:00 in summer.
const today = localDate(this.clock());
const result = await this.tasks.update(
{
dueDate: LessThan(today),
status: In([TaskStatus.Todo, TaskStatus.InProgress]),
archived: false,
},
{ status: TaskStatus.Overdue },
);
return result.affected ?? 0;
}这个选择把两种性质不同的信息混在了一起:工作进度(待办、进行中、已完成),由人来决定;以及一个随时间推移产生的 事实(截止日期已过)。后果如下:
- 之前的状态丢失了。 一个
IN_PROGRESS的任务变成OVERDUE后,就看不出是否有人在做它。 - 状态有滞后。 从截止日期到定时任务运行之间,任务已经逾期却没有显示出来;而且如果截止日期可以修改(TaskFlow 没有这样的路由),一个延期的任务会一直保持
OVERDUE,直到有人手动修改它的状态。 - 变化没有通知。 定时任务只做一次批量
UPDATE,不经过updateStatus():这些任务不会发出任何task.status.changed事件。
另一种做法是让 status 只表示进度(TODO、IN_PROGRESS、DONE),再根据 dueDate 和 status 计算是否逾期:
// TaskFlow 之外的示例:逾期由计算得出,状态只描述进度
const isOverdue = (task: Task, today: string): boolean =>
task.status !== TaskStatus.Done && task.dueDate !== null && task.dueDate < today;在 SQL 中,同样的过滤条件写成 due_date < CURRENT_DATE AND status <> 'DONE'。比较 'YYYY-MM-DD' 字符串是可行的, 因为这种格式的字母顺序就是时间顺序。
| 标准 | 存储状态(TaskFlow、计划) | 计算逾期 |
|---|---|---|
| 列出逾期任务 | status = 'OVERDUE',可利用 (project_id, status) 索引 | 对 due_date 和 status 加条件 |
| 时效性 | 最多滞后一天(每日定时任务) | 始终最新 |
| 实际进度 | 变为逾期时丢失 | 保留 |
| 截止日期变化的任务 | 仍然是 OVERDUE | 自动重新计算 |
| 定时任务 | 必需 | 对状态而言不需要(只在发通知时有用) |
两种做法都要确定“今天”是哪一天,以及按哪个时区算。定时任务在巴黎时间凌晨 1 点运行。如果用 toISOString() 计算, 得到的是 UTC 的日期:冬令时,巴黎凌晨 1 点对应 UTC 午夜,两个日期一致;夏令时,UTC 还是前一天 23 点,前一天到期的 任务要等到下一次运行才会被标记。TaskFlow 避开了这个陷阱:markOverdue() 用 localDate() 取巴黎的日期,使用的 时区是定时任务同样使用的 APP_TIME_ZONE。
// Business dates (due dates, "today") are Paris calendar days, not UTC ones:
// at 01:00 in Paris during summer time it is still the previous day in UTC.
export const APP_TIME_ZONE = 'Europe/Paris';
/** 'YYYY-MM-DD' of `date` in APP_TIME_ZONE. */
export function localDate(date: Date, timeZone = APP_TIME_ZONE): string {
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
}一个单元测试把时钟固定在 6 月 30 日 UTC 23 点(巴黎时间 7 月 1 日凌晨 1 点),并验证用于比较的日期是 2026-07-01 (J10)。计算逾期的做法也有同样的需求:在 SQL 中,CURRENT_DATE 取决于 PostgreSQL 会话的时区;更好的做法是把用同样方式算出的当天日期作为参数传进去。
TaskFlow 保留了存储状态的做法,以忠实于计划。在实际项目中,计算逾期通常是更稳妥的选择:它不会丢失任何信息,也不 依赖任何定时任务。