You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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。

解析流程:

  1. 单次请求生成 capturedNow 与日期桶;
  2. 读取岗位及企业门禁;
  3. 通过 V41 CandidateSource 批量读取当前全部合格候选;
  4. 分批重算 candidateKey;
  5. 用 MessageDigest.isEqual 比较 UTF-8 字节,找到唯一匹配;
  6. 未匹配、已失效、重复匹配均返回统一不可邀约错误;
  7. 仅在服务层内获得 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_NEW writer 和独立定判事务;
  • 决策: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 黑盒门禁

自动化通过后必须独立执行:

  1. Flyway V40 success=1,两表字段、索引、FK、CHECK、唯一约束正确;
  2. V41 返回的 candidateKey 可创建邀约,篡改/过期 key 不可创建;
  3. 创建→学生列表/详情→接受→唯一站内会话完整主链路;
  4. 拒绝与过期不创建会话;
  5. 并发创建和并发决策逐请求校验 HTTP/status code 配对,无 500;
  6. 同企业历史投递、关闭人才发现、账号注销后不可邀约;
  7. 企业/学生/ADMIN/匿名/跨租户权限矩阵;
  8. 企业响应、学生响应、audit_logs、history 全部脱敏;
  9. candidate_dimension_scores 行数不变;
  10. RabbitMQ 队列终态正常,V39–V41 关键接口回归;
  11. 静态/运行时 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. 已确认的编码决策

  1. 批准 Flyway V40__enterprise_talent_invitations.sql 和两张新表;
  2. 批准 6 paths / 6 operations,预计 OpenAPI 167/205;
  3. 只有学生 ACCEPT 后才创建 TALENT_INVITATION 站内会话;
  4. candidateKey 采用“当前候选池重算 + 常量时间匹配”,不新增 key→个人详情接口;
  5. 邀约有效期 7 天,同企业同学生终态后冷却 30 天;
  6. 每公司每岗位每天最多 20 次,每公司每天最多 100 次;
  7. message 为 1–300 字,禁止联系方式、URL、微信/QQ;
  8. 使用错误码 10201–10209;
  9. 本版不发送短信、邮件或 RabbitMQ 通知,仅在学生站内邀约列表展示;
  10. 存在屏蔽关系时,学生 ACCEPT 返回 403/2002,邀约保持 PENDING,不泄露屏蔽方向。

上述决策均为编码硬边界;如真实代码与其存在关键冲突,开发者必须停止并报告,不得自行改变迁移号、接口、状态机或隐私范围。