# 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 中。 本版采用“**邀约先行,接受后建站内会话**”: ```text 企业发现候选人 → 创建 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 建议新增: ```text 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. 状态机与时间规则 ```text 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 企业创建邀约 请求: ```json { "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 学生决策 请求: ```json { "decision": "ACCEPT", "expectedVersion": 0 } ``` 或: ```json { "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 断言不得包含: ```text 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 白名单建议仅含: ```json { "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,不泄露屏蔽方向。 上述决策均为编码硬边界;如真实代码与其存在关键冲突,开发者必须停止并报告,不得自行改变迁移号、接口、状态机或隐私范围。