Skip to content

TaskFlow 领域模型 ​

TaskFlow 管理项目、项目成员和任务。本页介绍四张表、每个关系如何用 TypeORM 声明、任务状态的生命周期,以及 OVERDUE 状态带来的设计取舍。这些实体分别出现在 J4(Project)、 J5(Task)和 J8(User、成员)。

模型 ​

text
┌──────────┐ 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 属性约束
iduuidid: string主键
emailvarchar(254)email: string非空,唯一
password_hashvarcharpasswordHash: string非空,从不序列化(@Exclude()),不出现在 OpenAPI schema 中(@ApiHideProperty())
role枚举 user_role(ADMIN、MEMBER)role: Role默认 MEMBER
created_attimestamptzcreatedAt: Date默认 now()

projects ​

列PostgreSQL 类型Project 属性约束
iduuidid: string主键
namevarchar(100)name: string非空
descriptiontextdescription: string | null可为空
archivedbooleanarchived: boolean默认 false
created_at、updated_attimestamptzcreatedAt、updatedAt: Date由 TypeORM 填充
deleted_attimestamptzdeletedAt: Date | null由 softDelete() 填充;之后 find() 会忽略这一行

project_members ​

列PostgreSQL 类型ProjectMember 属性约束
project_iduuidprojectId: string复合主键;外键指向 projects,ON DELETE CASCADE
user_iduuiduserId: string复合主键;外键指向 users,ON DELETE CASCADE
joined_attimestamptzjoinedAt: Date默认 now()

tasks ​

列PostgreSQL 类型Task 属性约束
iduuidid: string主键
titlevarchar(200)title: string非空
status枚举 task_statusstatus: TaskStatus默认 TODO
due_datedatedueDate: string | null可为空;返回的是字符串 'YYYY-MM-DD'
archivedbooleanarchived: boolean默认 false
project_iduuidprojectId: string非空;外键指向 projects,ON DELETE CASCADE
assignee_iduuidassigneeId: string | null可为空;外键指向 users,ON DELETE SET NULL
created_at、updated_attimestamptzcreatedAt、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_idProject.tasks(@OneToMany)CASCADE
一个用户,n 个被指派的任务Task.assignee,列 assignee_id,可为空无:单向关系SET NULL
n 个用户,n 个项目ProjectMember.project 和 ProjectMember.userProject.members;User 一侧没有两侧都是 CASCADE

和 JPA 一样,外键位于 @ManyToOne 一侧,@JoinColumn({ name: 'project_id' }) 指定列名。传给 @OneToMany 的函数 (task) => task.project 起到 mappedBy 的作用。目标类型通过函数给出(() => Project),因为实体文件之间互相导入: 类只在两个文件都加载完之后才会被读取。

ts
@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 和复合索引都用到了这一点。

ts
@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 的实体。

ts
// 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;
}
ts
@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() 委托给它。

任务状态 ​

ts
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 事件的形式发布;提交与当前相同的状态,既不会写入,也不会产生事件。

ts
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 遵循计划:

ts
/** 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 计算是否逾期:

ts
// 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。

ts
// 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 保留了存储状态的做法,以忠实于计划。在实际项目中,计算逾期通常是更稳妥的选择:它不会丢失任何信息,也不 依赖任何定时任务。