21 KiB
V42 企业人才邀约与授权沟通——详细设计(已实现并通过真实黑盒验收)
状态:设计已确认并实现,真实 MySQL/Docker 黑盒验收通过(PASS=76/FAIL=0),待 Git 检查点。负责人已于 2026-09-04 确认本文第 17 节全部决策。
目标读者:后端开发、测试、前端联调与产品负责人。
设计目标:在 V41 企业人才发现的隐私边界之上,允许企业向候选人发出一次可审计、可拒绝、会过期的站内邀约;只有学生明确接受后,双方才建立站内会话。任何阶段均不向企业直接提供联系方式、简历文件或测评明细。
1. 背景与核心决策
V41 只返回不可逆的 candidateKey,不返回 studentUserId,也没有通过该 key 查询个人详情的接口。V42 必须延续这一边界,不能把 candidateKey 改造成可解码 ID,也不能允许企业在请求中提交学生 ID。
创建邀约时,后端应在同一次事务前置校验中重新生成当前岗位的合格候选池,对候选池内每位学生按 V41 相同日期桶和算法计算 candidateKey,使用常量时间比较找到唯一候选人,并再次校验授权、岗位画像、历史投递和账号状态。匹配成功后,内部可将 student_user_id 写入邀约表用于归属和学生收件箱,但该字段永不出现在企业响应、日志或审计 details 中。
本版采用“邀约先行,接受后建站内会话”:
企业发现候选人
→ 创建 PENDING 邀约
→ 学生查看并选择 ACCEPT / REJECT
→ ACCEPT:原子创建或复用专用站内会话,状态 ACCEPTED
→ REJECT:状态 REJECTED,不建立会话
→ 到期未处理:状态 EXPIRED,不建立会话
不允许通过现有通用 POST /conversations 绕过邀约。现有 MessagingService 要求企业与学生已有 MATCHED 投递,V42 应新增一个仅供内部调用的受限会话创建组件,凭已接受的邀约创建 TALENT_INVITATION 类型会话;不得开放“传 studentUserId 创建会话”的公网接口。
2. 范围
2.1 本版包含
- 企业对 V41 当前仍合格的候选人发出邀约;
- 企业查看本公司发出的邀约列表和单条详情;
- 学生查看本人收到的邀约列表和详情;
- 学生接受或拒绝邀约;
- 接受后原子创建或复用站内会话;
- 幂等、频率限制、并发状态机、过期处理;
- 全链路权限、脱敏、历史与审计;
- V41
candidateKey的安全解析替代方案:当前候选池内重算并匹配。
2.2 本版不包含
- 企业查看学生手机号、邮箱、住址、简历文件、真实姓名;
- 企业通过 candidateKey 查询候选人详情;
- 企业直接发送站内消息或绕过学生同意创建会话;
- 企业撤回、编辑、删除已发送邀约;
- 学生反悔撤销已接受结果;
- 邀约收藏、批量邀约、自动邀约、AI 文案;
- 联系方式申请、简历解锁、外部短信或邮件;
- 推荐曝光历史、推荐模型训练、候选评分写入;
- ADMIN 人工审核或修改邀约;
- 修改 V35–V41 的测评、评分、推荐、评价回复语义。
3. 版本与迁移
当前最新 Flyway 为 V39。V42 建议新增:
V40__enterprise_talent_invitations.sql
新增两张表:
talent_invitations:当前邀约及终态;talent_invitation_history:不可变状态历史,不保存文案副本。
不得修改 V1–V39;不得写 candidate_dimension_scores。
3.1 talent_invitations
建议字段:
| 字段 | 类型 | 约束/说明 |
|---|---|---|
| id | BIGINT | AUTO_INCREMENT PK |
| company_id | BIGINT | NOT NULL,FK companies(id) |
| job_id | BIGINT | NOT NULL,FK jobs(id) |
| student_user_id | BIGINT | NOT NULL,FK users(id),仅内部使用 |
| candidate_key_digest | CHAR(64) | NOT NULL,仅保存收到 key 的 SHA-256 摘要,不保存原 key |
| message | VARCHAR(300) | NOT NULL,安全清洗后的邀约说明 |
| status | VARCHAR(20) | PENDING/ACCEPTED/REJECTED/EXPIRED |
| idempotency_key | VARCHAR(64) | NOT NULL,UUID |
| active_key | VARCHAR(200) | 活跃时唯一,终态置 NULL |
| conversation_id | BIGINT | NULL,接受后关联 conversations(id) |
| expires_at | DATETIME | NOT NULL,默认创建后 7 天 |
| decided_at | DATETIME | NULL |
| version | BIGINT | NOT NULL DEFAULT 0,JPA @Version |
| created_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP |
| updated_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE |
约束与索引:
CHECK(status IN ('PENDING','ACCEPTED','REJECTED','EXPIRED'));UNIQUE(company_id, idempotency_key);UNIQUE(active_key);- 活跃邀约的
active_key固定为companyId:jobId:studentUserId,进入任一终态时置 NULL; INDEX(student_user_id, status, created_at);INDEX(company_id, status, created_at);INDEX(job_id, status, created_at);INDEX(status, expires_at);candidate_key_digest只用于追溯请求是否对应同一 opaque key,不用于反查学生;- 删除岗位或公司时不建议级联删除审计性邀约;沿用项目 FK 策略并在编码前核实现有删除业务。若存在物理删除,使用 RESTRICT,避免历史丢失。
3.2 talent_invitation_history
| 字段 | 类型 | 约束/说明 |
|---|---|---|
| id | BIGINT | AUTO_INCREMENT PK |
| invitation_id | BIGINT | NOT NULL,FK invitations(id) ON DELETE CASCADE |
| action | VARCHAR(30) | CREATED/ACCEPTED/REJECTED/EXPIRED |
| actor_user_id | BIGINT | NULL;系统过期时为空 |
| created_at | DATETIME | NOT NULL DEFAULT CURRENT_TIMESTAMP |
要求:
CHECK(action IN ('CREATED','ACCEPTED','REJECTED','EXPIRED'));INDEX(invitation_id, created_at);- 不保存 message、candidateKey、studentUserId、联系方式、简历、测评或评分;
- 每次真实状态变化恰好写一条历史。
4. 状态机与时间规则
PENDING ──学生接受──> ACCEPTED
PENDING ──学生拒绝──> REJECTED
PENDING ──到期处理──> EXPIRED
ACCEPTED、REJECTED、EXPIRED均为终态;- 创建时间由数据库/服务端生成,不接受客户端时间;
- 默认有效期 7 天,建议配置
talent-invitation.ttl-days=7,但本版不增加功能开关; - 所有读取和决策先执行按 ID 的惰性过期:当
status=PENDING && expiresAt<=capturedNow时,以条件更新转为 EXPIRED; - 可增加定时批量过期任务,但正确性不能依赖调度器;
expiresAt == capturedNow视为已过期;- 学生决策采用
expectedVersion+ 条件更新,和过期任务竞争时只能一个成功; - 接受事务必须保证“状态 ACCEPTED、history、conversation、audit”要么全部成功,要么全部回滚。
5. 公网接口
建议新增 6 个 path、6 个 operation:
| 方法 | 路径 | 身份 | 作用 |
|---|---|---|---|
| POST | /companies/me/jobs/{jobId}/talent-invitations |
COMPANY | 使用 candidateKey 创建邀约 |
| GET | /companies/me/talent-invitations |
COMPANY | 分页查看本公司邀约 |
| GET | /companies/me/talent-invitations/{invitationId} |
COMPANY | 查看本公司单条邀约 |
| GET | /profile/me/talent-invitations |
STUDENT | 分页查看本人收到的邀约 |
| GET | /profile/me/talent-invitations/{invitationId} |
STUDENT | 查看本人单条邀约 |
| POST | /profile/me/talent-invitations/{invitationId}/decision |
STUDENT | 接受或拒绝 |
以 V41 的 161 paths / 199 operations 为基线,预计变为 167 paths / 205 operations。最终计数必须以静态 OpenAPI、运行时 /v3/api-docs 和校验脚本三方一致为准。
5.1 企业创建邀约
请求:
{
"candidateKey": "opaque-url-safe-value",
"message": "我们正在招聘后端开发实习生,欢迎了解岗位详情。",
"idempotencyKey": "3c989a31-6774-4520-9700-e58416cc1845"
}
校验:
- 当前用户必须是已认证企业;
jobId > 0,岗位属于当前企业且为 PUBLISHED;- 岗位画像必须
enabled=true && review_status='APPROVED'; - candidateKey 非空、长度设置合理上限(建议 128);
- message 去首尾空白后 1–300 字符;禁止手机号、邮箱、URL、微信/QQ 等绕过站内沟通的信息;
- idempotencyKey 必须为 UUID,最长 64;
- 使用 V41 当前日期桶重算候选池 key 并常量时间匹配;禁止查表反解、日志打印或返回匹配到的学生 ID;
- 找到候选人后再次校验:授权开启、账号正常、档案可展示、没有当前企业任何历史投递、至少一个推荐信号仍有效;
- 任一候选门禁失败对企业返回统一的“候选人当前不可邀约”,避免泄露关闭授权、注销、投递或信号失效的具体原因。
幂等和重复:
- 同公司、同 idempotencyKey、同 jobId、同 candidateKey 摘要、同 message:返回原邀约;
- 同公司、同 idempotencyKey 但业务内容不同:409;
- 同公司、同岗位、同学生已有 PENDING 邀约:返回 409;
- 旧邀约已进入终态后是否允许再次邀约,由冷却规则控制,不复用旧记录;
- 并发创建由
UNIQUE(active_key)和独立事务定判保证,不得返回 500。
5.2 企业邀约响应
企业只可见:
- invitationId;
- jobId、jobTitle;
- candidateKey(仅列表需要继续定位卡片时返回;推荐默认不回显,使用 invitationId);
- candidateLabel,固定“开放人才候选人”;
- message;
- status;
- createdAt、expiresAt、decidedAt;
- conversationId,仅 ACCEPTED 后可见。
企业严禁看到 studentUserId、姓名、联系方式、简历 ID/URL、测评结果 ID/答卷 ID、RIASEC/能力细分、候选评分、fitScore、拒绝原因内部数据。
5.3 学生决策
请求:
{
"decision": "ACCEPT",
"expectedVersion": 0
}
或:
{
"decision": "REJECT",
"expectedVersion": 0
}
规则:
- invitationId 必须属于当前学生;
- decision 仅 ACCEPT/REJECT;
- 只有未过期 PENDING 可决策;
- expectedVersion 必填;
- 同请求幂等重放:若已处于请求目标终态,可返回当前结果;不同决策或版本冲突返回 409;
- REJECT 不要求填写理由,且企业端不展示拒绝原因;
- ACCEPT 成功后返回 conversationId;
- 接受时若双方存在屏蔽关系,应拒绝接受并保持 PENDING,或由产品明确选择直接 REJECTED。推荐保持 PENDING 并返回统一禁止操作,避免泄露谁屏蔽谁。
6. candidateKey 匹配与防枚举
新增内部组件建议:
TalentInvitationCandidateResolver:复用 V41 候选资格与 CandidateKeyGenerator;TalentInvitationService:状态机、幂等、权限和事务;TalentInvitationConversationService:只接受已锁定且即将 ACCEPTED 的 invitation,不对 Controller 暴露;TalentInvitationExpiryService:惰性和批量过期;TalentInvitationController:白名单 DTO,不写 SQL。
解析流程:
- 单次请求生成
capturedNow与日期桶; - 读取岗位及企业门禁;
- 通过 V41 CandidateSource 批量读取当前全部合格候选;
- 分批重算 candidateKey;
- 用
MessageDigest.isEqual比较 UTF-8 字节,找到唯一匹配; - 未匹配、已失效、重复匹配均返回统一不可邀约错误;
- 仅在服务层内获得 studentUserId,落库后不写日志、不进入 HTTP 企业 DTO。
防护:
- 每公司每岗位每天最多 20 次成功邀约;
- 每公司每天最多 100 次成功邀约;
- 每个学生对同一公司终态后 30 天内不得再次邀约;
- 无效 candidateKey 请求也必须纳入拒绝型安全审计和速率限制,避免枚举;
- 不在错误信息中区分 key 不存在、授权关闭、已投递、账号注销或信号过期;
- 限额应通过数据库聚合 + 必要索引判断,不能仅靠单机内存。
以上限额属于推荐初值,编码前需由负责人最终确认。
7. 接受后会话
接受邀约后创建 conversations.type='TALENT_INVITATION',成员仅为:
- 当前公司的 owner user;
- 当前学生。
要求:
- 新会话只能由内部服务创建,不复用允许客户端传
participantUserId的入口; talent_invitations.conversation_id唯一或通过 invitationId 锁保证一份邀约只生成一个会话;- 并发 ACCEPT 只产生一个会话和两条成员记录;
- 建议会话 title 使用岗位标题的安全模板,不包含学生真实身份;
- 创建会话后不自动发送企业自由文本消息,邀约 message 已在学生邀约详情显示;如需首条系统消息,使用固定模板且 sender 语义需单独确认;
- 后续消息继续走既有 MessagingService 的成员校验、屏蔽规则和消息长度限制;
- 需要调整现有企业—学生会话的 MATCHED 投递守卫:对
TALENT_INVITATION已接受会话放行,但 DIRECT 新建规则保持原样;不能全局取消 MATCH_REQUIRED。
8. 查询、分页与 N+1
企业和学生列表统一 page 从 0 开始,size 1–50。
- 企业列表按 companyId + createdAt DESC + id DESC 分页;
- 学生列表按 studentUserId + createdAt DESC + id DESC 分页;
- 岗位与公司展示信息按当前页 ID 集合批量加载;
- 不逐邀约
findById查询岗位、公司、会话; - 响应组装使用投影或固定次数批量查询;
- 详情查询在 SQL 层绑定归属条件,避免先返回“存在”再报无权导致枚举;
- 定时过期采用固定批次条件更新,不能全表加载实体。
9. 权限矩阵
| 场景 | STUDENT | COMPANY | ADMIN | 匿名 |
|---|---|---|---|---|
| 企业创建/列表/详情 | 403/2002 | 仅本公司 | 403/2002 | 401 |
| 学生列表/详情/决策 | 仅本人 | 403/2002 | 403/2002 | 401 |
| 跨公司岗位或邀约 | — | 统一 403/2002 | — | — |
| 他人学生邀约 | 统一不可见 | — | — | — |
SecurityConfig 只按角色收口;企业认证、岗位归属、邀约归属必须在 Service 再校验。请求 DTO 不得接受 companyId、studentUserId、ownerUserId 或 conversationId。
10. DTO 与脱敏
建议 DTO:
CreateTalentInvitationRequest;TalentInvitationDecisionRequest;CompanyTalentInvitationItemResponse;CompanyTalentInvitationDetailResponse;StudentTalentInvitationItemResponse;StudentTalentInvitationDetailResponse;TalentInvitationDecisionResponse;- 对应分页包装。
学生可以看到企业公开名称、岗位标题、工作城市、岗位公开摘要、邀约 message、状态和期限。学生端不返回企业 ownerUserId、内部画像、风险信息或审计字段。
对完整序列化 JSON 断言不得包含:
studentUserId, ownerUserId, candidateId, phone, email, resumeUrl,
attemptId, resultId, answerKey, scoringRule, questionCode,
competencyTag, dimensionScores, overallScore, percentile, fitScore,
candidateKeyDigest, activeKey, password, token, secret
11. 错误码提案
建议使用新范围 10201–10209,编码前确认:
| code | 名称 | HTTP | 场景 |
|---|---|---|---|
| 10201 | TALENT_INVITATION_NOT_FOUND | 404 | 对本人/本公司不可见或不存在 |
| 10202 | TALENT_INVITATION_CANDIDATE_UNAVAILABLE | 409 | key 无效或候选当前不再可邀约 |
| 10203 | TALENT_INVITATION_ALREADY_ACTIVE | 409 | 已有 PENDING |
| 10204 | TALENT_INVITATION_INVALID_STATE | 409 | 非 PENDING 决策 |
| 10205 | TALENT_INVITATION_EXPIRED | 409 | 已到期 |
| 10206 | TALENT_INVITATION_CONCURRENT_UPDATE | 409 | 版本或条件更新冲突 |
| 10207 | TALENT_INVITATION_IDEMPOTENCY_CONFLICT | 409 | 同 key 不同业务内容 |
| 10208 | TALENT_INVITATION_CONTENT_INVALID | 400 | 文案或请求字段非法 |
| 10209 | TALENT_INVITATION_RATE_LIMITED | 429 | 企业邀约限额 |
跨企业、角色不符仍复用 2002;分页/路径参数非法复用 1001。不得把数据库异常直接返回 500 或暴露约束名。
12. 审计
新增动作:
TALENT_INVITATION_CREATED;TALENT_INVITATION_ACCEPTED;TALENT_INVITATION_REJECTED;TALENT_INVITATION_EXPIRED;TALENT_INVITATION_CREATE_REJECTED(拒绝型安全审计)。
成功审计 details 白名单建议仅含:
{
"invitationId": 42,
"jobId": 9,
"status": "ACCEPTED",
"requestId": "..."
}
禁止记录 candidateKey、摘要、studentUserId、message、联系方式、简历、测评、评分、IP/UA 明文、token/password/secret。只有条件更新胜者写 history 和成功审计。
13. 事务与并发
- 创建:幂等预查 → 候选重新解析 → 写入独立事务 → 唯一冲突精确定判;
- 不得在已被唯一键异常污染的 Hibernate Session 中继续查询;必要时使用
REQUIRES_NEWwriter 和独立定判事务; - 决策:
WHERE id=? AND student_user_id=? AND status='PENDING' AND version=? AND expires_at>?条件更新; - ACCEPT 若需要先获得 invitation ID,应锁定记录并在同一事务创建会话、成员、历史、审计;
- 并发 ACCEPT/REJECT/EXPIRE 只能一个状态变化成功;
- 幂等重放不能重复创建 conversation、history 或 audit;
- 所有时间比较复用同一个 capturedNow。
14. 必须新增的自动化测试
14.1 Candidate resolver
- 当前日期桶合法 key 可解析;
- 旧日期桶、随机 key、篡改 key 不可解析;
- candidateKey 常量时间比较;
- 授权关闭、账号注销、当前企业历史投递、信号失效后立即不可邀约;
- 其他企业历史投递不误排;
- 不读取答案、题目或候选评分表;
- 查询次数只与批次数有关,不与候选人数线性增长。
14.2 TalentInvitationServiceTest
- 安全文案创建 PENDING;
- 联系方式、URL、空白、超长文案拒绝;
- 同幂等键同请求返回同一邀约;
- 同幂等键不同请求冲突;
- 同岗位同学生并发创建只有一条成功,无 500;
- 企业/岗位/画像门禁;
- 每岗位每日、每公司每日、30 天冷却边界;
- 过期惰性转换;
- ACCEPT 创建唯一会话;
- REJECT 不创建会话;
- 并发 ACCEPT/REJECT/EXPIRE 仅一个胜者;
- 同目标终态重放不重复 history/audit/conversation;
- 屏蔽关系不被绕过。
14.3 Controller 权限与脱敏
- COMPANY 仅访问本公司邀约;
- STUDENT 仅访问本人邀约;
- ADMIN 和匿名矩阵;
- 跨企业、跨学生均不可枚举;
- page/size/jobId/decision/version 校验;
- 企业与学生完整 JSON 白名单断言;
- 企业响应无 candidateKey 之外的候选定位字段,且默认不回显 candidateKey;
- 不存在候选详情、联系方式解锁或企业直接创建会话接口。
14.4 PersistenceIntegrationTest
- V40 在 H2 MODE=MySQL 执行;
- FK、CHECK、UNIQUE(active_key)、UNIQUE(company,idempotencyKey);
- @Version 冲突;
- 终态 active_key 释放;
- conversation_id 唯一关联;
- 历史表不含正文和学生字段;
- 并发创建/决策无 500;
- 列表批量查询无 N+1;
candidate_dimension_scores行数不变。
14.5 回归
- V41 推荐与授权结果不变;
- V39 岗位画像审核不变;
- V40 企业评价回复不变;
- 现有 DIRECT 会话仍要求 MATCHED 投递;
- 只有 ACCEPTED 邀约可以创建 TALENT_INVITATION 会话;
- 屏蔽、举报、消息权限保持原语义;
- 不发送 RabbitMQ 消息(若后续确认需要通知,再单独设计 outbox)。
15. 真实 MySQL/Docker 黑盒门禁
自动化通过后必须独立执行:
- Flyway V40 success=1,两表字段、索引、FK、CHECK、唯一约束正确;
- V41 返回的 candidateKey 可创建邀约,篡改/过期 key 不可创建;
- 创建→学生列表/详情→接受→唯一站内会话完整主链路;
- 拒绝与过期不创建会话;
- 并发创建和并发决策逐请求校验 HTTP/status code 配对,无 500;
- 同企业历史投递、关闭人才发现、账号注销后不可邀约;
- 企业/学生/ADMIN/匿名/跨租户权限矩阵;
- 企业响应、学生响应、audit_logs、history 全部脱敏;
candidate_dimension_scores行数不变;- RabbitMQ 队列终态正常,V39–V41 关键接口回归;
- 静态/运行时 OpenAPI 对账,最终必须 FAIL=0。
16. 文档与发布门禁
实现时新增/更新:
docs/enterprise-talent-invitation-api.md;docs/frontend-integration.md;docs/backend-development-roadmap.md;- 本设计文档状态;
docs/openapi-rc.json;docs/verify_openapi.py。
发布顺序:编码与自动化 → OpenAPI 对账 → 真实 MySQL/Docker 黑盒 → 精确 Git 检查点。不得在黑盒未闭环时标记冻结。
17. 已确认的编码决策
- 批准 Flyway
V40__enterprise_talent_invitations.sql和两张新表; - 批准 6 paths / 6 operations,预计 OpenAPI 167/205;
- 只有学生 ACCEPT 后才创建
TALENT_INVITATION站内会话; - candidateKey 采用“当前候选池重算 + 常量时间匹配”,不新增 key→个人详情接口;
- 邀约有效期 7 天,同企业同学生终态后冷却 30 天;
- 每公司每岗位每天最多 20 次,每公司每天最多 100 次;
- message 为 1–300 字,禁止联系方式、URL、微信/QQ;
- 使用错误码 10201–10209;
- 本版不发送短信、邮件或 RabbitMQ 通知,仅在学生站内邀约列表展示;
- 存在屏蔽关系时,学生 ACCEPT 返回
403/2002,邀约保持 PENDING,不泄露屏蔽方向。
上述决策均为编码硬边界;如真实代码与其存在关键冲突,开发者必须停止并报告,不得自行改变迁移号、接口、状态机或隐私范围。