invitation-lifecycle

邀请生命周期:令牌链接与预签名附件上传

youteacher 里的一次邀请,是一位雇主或招聘方就某一个具体职位、向某一位教师发出的联系。整件事都挂在一个秘密令牌上:邀请在服务端创建,令牌被送到教师手里,此后教师的每一个动作——查看、接受、拒绝、附上文件——都仅凭这个令牌来认证,无需登录账号。

创建:快照、令牌、过期时间,加一道去重闸

Invitation 聚合通过一个工厂方法创建,一次性打上三样东西:唯一 id、随机令牌、过期日期。令牌由 16 个随机字节转成十六进制得到;过期时间默认为发送时刻起七天,按发送时间戳做一个简单的偏移算出。聚合不只是一个指针——它携带了发送当时职位的快照:标题、地点、薪资区间与币种,以及雇主名称和已验证标记。即便之后线上职位的薪资或某项细节变了,这条邀请仍然展示当初真正给出的条件。

在邀请被写入之前,发送处理器先校验职位 id 和人才 id 都在,再向仓储询问是否已存在重复。重复的定义很窄:同一雇主、同一人才、同一职位。针对这三元组的第二次尝试会以冲突被拒,而不是悄悄再造一份。当调用方没有提供教师姓名时,处理器还会从搜索索引里补上,使存下来的邀请自带描述。

状态是一台小状态机

一条邀请在五个状态间流转:pendingviewedaccepteddeclinedexpired。转移都有守卫:

  • pending → viewed 发生在教师第一次打开链接时,且只能从 pending 出发;重新打开一条已查看或已回应的邀请不会把它重置。
  • → accepted / declined 只在邀请还可以被回应时允许;已到终态的邀请会拒绝第二次回应。
  • expired 是推导出来的,不由某个时钟去写——当前时间一旦越过过期日期,邀请即为过期,各回应路径都会先于其它检查判定这一点。

接受会记下接受时间、回应时间、一段可选备注,以及随附的附件;拒绝则记下自己的时间和一个可选理由。持久化的行把 viewedAtacceptedAtdeclinedAtrespondedAt 作为各自独立、可空的时间戳保留,因此一次回应的来龙去脉事后仍然清楚。

公开的令牌端点

面向教师的一侧是一小组以路径中令牌为键的路由:一个 GET 取回邀请,以及用于接受、拒绝、申请上传 URL 的若干 POST。接受路径既执行回应规则,也有自己的输入上限:备注最长 400 字,附件不超过五个;过期或已回应的邀请会以相应状态被挡回。错误以带稳定错误码的类型化 HTTP 错误返回(例如 invitation_not_foundexpiredalready_accepted),而不是抛出原始失败。

附件:预签名上传,白名单加限时

教师不通过 API 服务器上传文件。他们改为申请一个预签名上传 URL,把字节直接发往对象存储。规则都住在预签名处理器里:

  • 内容类型必须落在一份固定白名单上——JPEG、PNG、WebP 或 PDF——否则请求在任何存储调用之前就被拒。
  • 令牌必须解析到一条未过期、且仍可行动(pendingviewed)的邀请;已回应的邀请不能再接收文件。
  • 存储键按邀请分命名空间(<前缀>/<invitationId>/<fileId>.<扩展名>),因此一条邀请的上传永远不会和另一条相撞。

预签名 URL 绑定内容类型,并带有较短的有效期(一小时);处理器同时返回它期望客户端遵守的最大文件大小。下载走的是相反的同一套路——一个有效期同样有界的预签名 GET URL——因此除非某个前缀被显式开放为公开可读,存储的文件绝不会通过一个永久公开的链接被提供。

落在哪里

邀请存在一张 Postgres 表里。令牌列唯一,状态是一个默认 pending 的数据库枚举,并在“人才+状态”组合、发送者、雇主、过期日期上各建了索引——最后一个使得清扫已过期邀请的代价很低。

本节内容

  • 聚合与规则:domain/invitation/Invitation.ts
  • 创建 / 去重闸:application/invitation/commands/SendInvitation.ts
  • 接受路径:application/invitation/commands/AcceptInvitation.ts
  • 预签名上传:application/invitation/commands/RequestInvitationPresignUpload.ts
  • 令牌路由:interfaces/rest/talents/InvitationTokenController.ts
  • 对象存储适配器:infrastructure/storage/S3FileStorage.ts
  • 数据表结构:prisma/schema.prisma
about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

invitation-lifecycle