邀请生命周期:令牌链接与预签名附件上传
youteacher 里的一次邀请,是一位雇主或招聘方就某一个具体职位、向某一位教师发出的联系。整件事都挂在一个秘密令牌上:邀请在服务端创建,令牌被送到教师手里,此后教师的每一个动作——查看、接受、拒绝、附上文件——都仅凭这个令牌来认证,无需登录账号。
创建:快照、令牌、过期时间,加一道去重闸
Invitation 聚合通过一个工厂方法创建,一次性打上三样东西:唯一 id、随机令牌、过期日期。令牌由 16 个随机字节转成十六进制得到;过期时间默认为发送时刻起七天,按发送时间戳做一个简单的偏移算出。聚合不只是一个指针——它携带了发送当时职位的快照:标题、地点、薪资区间与币种,以及雇主名称和已验证标记。即便之后线上职位的薪资或某项细节变了,这条邀请仍然展示当初真正给出的条件。
在邀请被写入之前,发送处理器先校验职位 id 和人才 id 都在,再向仓储询问是否已存在重复。重复的定义很窄:同一雇主、同一人才、同一职位。针对这三元组的第二次尝试会以冲突被拒,而不是悄悄再造一份。当调用方没有提供教师姓名时,处理器还会从搜索索引里补上,使存下来的邀请自带描述。
状态是一台小状态机
一条邀请在五个状态间流转:pending、viewed、accepted、declined、expired。转移都有守卫:
- pending → viewed 发生在教师第一次打开链接时,且只能从
pending出发;重新打开一条已查看或已回应的邀请不会把它重置。 - → accepted / declined 只在邀请还可以被回应时允许;已到终态的邀请会拒绝第二次回应。
- expired 是推导出来的,不由某个时钟去写——当前时间一旦越过过期日期,邀请即为过期,各回应路径都会先于其它检查判定这一点。
接受会记下接受时间、回应时间、一段可选备注,以及随附的附件;拒绝则记下自己的时间和一个可选理由。持久化的行把 viewedAt、acceptedAt、declinedAt、respondedAt 作为各自独立、可空的时间戳保留,因此一次回应的来龙去脉事后仍然清楚。
公开的令牌端点
面向教师的一侧是一小组以路径中令牌为键的路由:一个 GET 取回邀请,以及用于接受、拒绝、申请上传 URL 的若干 POST。接受路径既执行回应规则,也有自己的输入上限:备注最长 400 字,附件不超过五个;过期或已回应的邀请会以相应状态被挡回。错误以带稳定错误码的类型化 HTTP 错误返回(例如 invitation_not_found、expired、already_accepted),而不是抛出原始失败。
附件:预签名上传,白名单加限时
教师不通过 API 服务器上传文件。他们改为申请一个预签名上传 URL,把字节直接发往对象存储。规则都住在预签名处理器里:
- 内容类型必须落在一份固定白名单上——JPEG、PNG、WebP 或 PDF——否则请求在任何存储调用之前就被拒。
- 令牌必须解析到一条未过期、且仍可行动(
pending或viewed)的邀请;已回应的邀请不能再接收文件。 - 存储键按邀请分命名空间(
<前缀>/<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