33 changed files with 2940 additions and 17 deletions
@ -0,0 +1,59 @@ |
|||
# V42 企业人才邀约与授权沟通 API |
|||
|
|||
V42 允许已认证企业使用 V41 生成的 `candidateKey` 向开放人才候选人发起站内邀约;学生明确接受后才建立 `TALENT_INVITATION` 会话。候选人身份、测评明细和联系方式始终不向企业公开。 |
|||
|
|||
## 接口清单 |
|||
|
|||
| 方法 | 路径 | 身份 | 说明 | |
|||
| --- | --- | --- | --- | |
|||
| POST | `/companies/me/jobs/{jobId}/talent-invitations` | 已认证 COMPANY | 创建邀约 | |
|||
| 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 | 接受或拒绝邀约 | |
|||
|
|||
V42 新增 6 个 path、6 个 operation;静态 OpenAPI 基线为 167 paths / 205 operations。 |
|||
|
|||
> 验收状态:真实 MySQL/Docker 黑盒通过(`PASS=76 / FAIL=0`);运行时与静态 OpenAPI 均为 167 paths / 205 operations,待 Git 检查点。 |
|||
|
|||
## 创建邀约 |
|||
|
|||
```json |
|||
{ |
|||
"candidateKey": "V41-generated-opaque-key", |
|||
"message": "欢迎了解我们的后端开发岗位。", |
|||
"idempotencyKey": "3c989a31-6774-4520-9700-e58416cc1845" |
|||
} |
|||
``` |
|||
|
|||
服务端重新确认企业认证、岗位归属和 `PUBLISHED` 状态、岗位画像 `enabled=true && APPROVED`、候选授权、账号状态、当前企业全部历史投递排除和至少一个有效信号。`candidateKey` 按 V41 相同密钥、时区和日期桶重算,并使用常量时间比较;请求不得携带学生 ID。 |
|||
|
|||
邀约有效期为 7 天。相同公司和幂等键的相同请求返回原邀约,不重复占用限额;同键不同业务内容返回 `409/10207`。每公司每岗位每天最多 20 次、每公司每天最多 100 次成功创建;同企业同学生终态后 30 天内不可再次邀约。 |
|||
|
|||
`message` 去首尾空白后为 1–300 字符,禁止手机号、邮箱、URL、微信/QQ 等外部联系方式。无效文案统一返回 `400/10208`,不写入邀约。 |
|||
|
|||
## 学生决策 |
|||
|
|||
```json |
|||
{ |
|||
"decision": "ACCEPT", |
|||
"expectedVersion": 0 |
|||
} |
|||
``` |
|||
|
|||
只有未过期 `PENDING` 可决策。`ACCEPT`、`REJECT`、到期 `EXPIRED` 采用条件更新和版本控制;并发请求只有一方能改变状态。接受时若任一方向存在屏蔽,返回 `403/2002` 且保持 `PENDING`。接受成功后在同一事务中创建唯一会话和两条成员记录;拒绝、过期不创建会话。 |
|||
|
|||
学生详情包含 `version`,只有 `ACCEPTED` 返回 `conversationId`。企业端不返回 `candidateKey`、`studentUserId`、`candidateKeyDigest`、`activeKey`、测评/评分字段或拒绝内部原因。 |
|||
|
|||
## 会话隐私 |
|||
|
|||
`TALENT_INVITATION` 会话仅由接受流程的内部组件创建,不开放传学生 ID 的公网入口。既有 `DIRECT` 会话的 MATCHED 守卫保持不变。会话、消息列表和发送响应对邀约会话仅返回 `CURRENT_USER`/`OTHER_PARTICIPANT` 相对身份,不返回 `createdBy` 或 `senderUserId`,也不提供学生姓名、联系方式或简历信息。 |
|||
|
|||
## 错误码 |
|||
|
|||
`10201` 不存在或无权查看;`10202` 候选当前不可邀约;`10203` 活跃/冷却冲突;`10204` 状态不允许决策;`10205` 已过期;`10206` 版本或条件更新冲突;`10207` 幂等键业务内容冲突;`10208` 请求或文案非法;`10209` 达到每日限额。角色、归属和屏蔽继续使用 `401/2001`、`403/2002`。 |
|||
|
|||
## 事务与数据边界 |
|||
|
|||
创建事务先锁定公司行,再执行幂等、冷却、限额和写入;数据库唯一约束作为最终防线。过期转换使用独立事务,确保 `EXPIRED`、释放 `activeKey`、history 和审计先提交再返回错误。V42 不写 `candidate_dimension_scores`,不发送 RabbitMQ 通知,不保存候选人联系方式或正文以外的敏感数据。 |
|||
@ -0,0 +1,480 @@ |
|||
# 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,不泄露屏蔽方向。 |
|||
|
|||
上述决策均为编码硬边界;如真实代码与其存在关键冲突,开发者必须停止并报告,不得自行改变迁移号、接口、状态机或隐私范围。 |
|||
@ -0,0 +1,439 @@ |
|||
# V42 企业人才邀约与授权沟通——编码执行指令 |
|||
|
|||
开始实现 V42「企业人才邀约与授权沟通」。 |
|||
|
|||
权威设计文档: |
|||
|
|||
```text |
|||
C:\Users\15642\Desktop\zhilai\docs\v42-enterprise-talent-invitation-design.md |
|||
``` |
|||
|
|||
请先完整阅读权威设计文档,再核查现有 V41 人才发现、V39 岗位画像、企业认证、账号注销、屏蔽关系、消息会话、审计及安全配置代码。设计中的 10 项产品决策已经负责人确认,禁止自行扩大或改变范围。 |
|||
|
|||
## 一、开始前只读核查 |
|||
|
|||
先执行并记录: |
|||
|
|||
```powershell |
|||
git status --short |
|||
git diff --cached --name-only |
|||
git log -5 --oneline |
|||
``` |
|||
|
|||
当前 Git 基线应包含: |
|||
|
|||
```text |
|||
5cc2c86 feat: deliver V41 enterprise talent discovery |
|||
2698d06 feat: deliver V40 company review replies |
|||
55e3975 feat: deliver V39 job recommendations |
|||
``` |
|||
|
|||
要求: |
|||
|
|||
1. 确认当前最新 Flyway 是 V39;V42 使用 `V40__enterprise_talent_invitations.sql`。 |
|||
2. 确认 V41 仅有 2 个 path / 3 个 operation,当前 OpenAPI 为 161 paths / 199 operations。 |
|||
3. 确认 `CandidateKeyGenerator` 的域隔离串、日期桶、HMAC 算法和 URL-safe 无 padding 编码。 |
|||
4. 确认 `TalentDiscoveryCandidateSource` 的资格过滤、同企业历史投递排除、账号注销排除和批量查询能力。 |
|||
5. 确认现有 MessagingService 对企业—学生 DIRECT 会话要求 MATCHED 投递,不能为 V42 全局取消该守卫。 |
|||
6. 确认 conversation、conversation_members、屏蔽关系、公司 owner、岗位状态、岗位画像状态的真实字段和仓库方法。 |
|||
7. 确认现有审计服务中“成功审计”和“拒绝型安全审计”的事务语义。 |
|||
8. 确认以下路径不存在: |
|||
- `POST /companies/me/jobs/{jobId}/talent-invitations` |
|||
- `GET /companies/me/talent-invitations` |
|||
- `GET /companies/me/talent-invitations/{invitationId}` |
|||
- `GET /profile/me/talent-invitations` |
|||
- `GET /profile/me/talent-invitations/{invitationId}` |
|||
- `POST /profile/me/talent-invitations/{invitationId}/decision` |
|||
|
|||
如果设计文档与真实代码存在关键冲突,立即停止并报告;不得自行改接口、迁移号、状态机、错误码或隐私边界。 |
|||
|
|||
不得 reset、restore、checkout、删除或覆盖既有未跟踪文件;不得执行 `git add -A`、commit 或 push。 |
|||
|
|||
## 二、硬性业务边界 |
|||
|
|||
1. 企业只能使用 V41 当前返回的 opaque `candidateKey` 创建邀约,绝不接收 `studentUserId`。 |
|||
2. candidateKey 不可逆;服务端必须在当前合格候选池中重新生成 key 并使用常量时间比较匹配。 |
|||
3. 只有已认证 COMPANY、自己的 PUBLISHED 岗位、APPROVED 且 enabled 的岗位画像可以创建邀约。 |
|||
4. 学生关闭人才发现、账号进入注销限制、与该企业已有任意历史投递、推荐信号全部失效时,不可创建邀约。 |
|||
5. 只有学生 ACCEPT 后才建立 `TALENT_INVITATION` 站内会话。 |
|||
6. REJECTED、EXPIRED 不创建会话。 |
|||
7. 企业不得看到学生身份、联系方式、简历、测评明细、候选评分或 fitScore。 |
|||
8. 本版不做企业撤回/编辑/删除、批量或自动邀约、联系方式申请、短信邮件、RabbitMQ 通知、ADMIN 审核、邀约曝光历史或 AI 文案。 |
|||
9. 不修改 V1–V39;不改变 V35–V41 既有业务语义。 |
|||
10. 不写 `candidate_dimension_scores`,不新增 feature flag。 |
|||
11. 不启动或停止 Docker,不做真实 MySQL 黑盒。 |
|||
12. 不提交、不推送、不进入 V43。 |
|||
|
|||
## 三、Flyway V40 |
|||
|
|||
新增: |
|||
|
|||
```text |
|||
mydreamjob-server/src/main/resources/db/migration/V40__enterprise_talent_invitations.sql |
|||
``` |
|||
|
|||
新增两张表。 |
|||
|
|||
### 3.1 talent_invitations |
|||
|
|||
至少包含: |
|||
|
|||
- `id BIGINT AUTO_INCREMENT PRIMARY KEY` |
|||
- `company_id BIGINT NOT NULL` |
|||
- `job_id BIGINT NOT NULL` |
|||
- `student_user_id BIGINT NOT NULL` |
|||
- `candidate_key_digest CHAR(64) NOT NULL` |
|||
- `message VARCHAR(300) NOT NULL` |
|||
- `status VARCHAR(20) NOT NULL` |
|||
- `idempotency_key VARCHAR(64) NOT NULL` |
|||
- `active_key VARCHAR(200) NULL` |
|||
- `conversation_id BIGINT NULL` |
|||
- `expires_at DATETIME NOT NULL` |
|||
- `decided_at DATETIME NULL` |
|||
- `version BIGINT NOT NULL DEFAULT 0` |
|||
- `created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP` |
|||
- `updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` |
|||
|
|||
约束: |
|||
|
|||
- FK company/job/student/conversation; |
|||
- `CHECK(status IN ('PENDING','ACCEPTED','REJECTED','EXPIRED'))`; |
|||
- `UNIQUE(company_id,idempotency_key)`; |
|||
- `UNIQUE(active_key)`; |
|||
- 建议 `UNIQUE(conversation_id)`,NULL 可重复; |
|||
- PENDING 时 `active_key=companyId:jobId:studentUserId`,终态必须置 NULL; |
|||
- 索引 `(student_user_id,status,created_at)`、`(company_id,status,created_at)`、`(job_id,status,created_at)`、`(status,expires_at)`; |
|||
- 不保存原始 candidateKey,只保存 SHA-256 十六进制摘要。 |
|||
|
|||
### 3.2 talent_invitation_history |
|||
|
|||
至少包含: |
|||
|
|||
- id、invitation_id、action、actor_user_id、created_at; |
|||
- action 仅 `CREATED/ACCEPTED/REJECTED/EXPIRED`; |
|||
- FK invitation,`ON DELETE CASCADE`; |
|||
- 索引 `(invitation_id,created_at)`; |
|||
- 不保存 message、candidateKey、studentUserId、联系方式、测评或评分。 |
|||
|
|||
Flyway 必须兼容 MySQL 8 与 H2 MODE=MySQL,不修改已执行迁移。 |
|||
|
|||
## 四、实体、仓库与内部组件 |
|||
|
|||
建议新增包 `com.mydreamjob.talentinvitation`,至少包含: |
|||
|
|||
- `TalentInvitation` |
|||
- `TalentInvitationHistory` |
|||
- 两个 Repository |
|||
- `TalentInvitationCandidateResolver` |
|||
- `TalentInvitationConversationService` |
|||
- `TalentInvitationExpiryService` |
|||
- `TalentInvitationService` |
|||
- `TalentInvitationController` |
|||
- 白名单 DTO |
|||
|
|||
要求: |
|||
|
|||
- invitation 使用 JPA `@Version`; |
|||
- 状态和历史 action 使用字符串枚举; |
|||
- Entity 不得直接返回 HTTP; |
|||
- Repository 支持带 companyId/studentUserId 的归属查询、分页、条件状态更新、幂等查询、活跃/冷却/每日限额统计和批量过期; |
|||
- 列表批量加载岗位/公司/会话信息,不得逐条 findById; |
|||
- Controller 不写 SQL、不接收 companyId/studentUserId/ownerUserId/conversationId。 |
|||
|
|||
## 五、candidateKey 安全解析 |
|||
|
|||
`TalentInvitationCandidateResolver` 必须: |
|||
|
|||
1. 单次请求生成一个 `capturedNow`; |
|||
2. 完成企业、岗位、画像门禁; |
|||
3. 复用 V41 候选资格和批量数据源,获取该岗位当前全部合格候选; |
|||
4. 使用 V41 完全相同的域隔离、companyId、jobId、studentUserId 和 `yyyy-MM-dd` 日期桶重新生成 key; |
|||
5. 使用 `MessageDigest.isEqual` 做常量时间字节比较; |
|||
6. 必须恰好匹配一个候选人;零个或异常重复均返回统一的候选不可邀约; |
|||
7. 解析成功后的 studentUserId 只能留在服务内部和数据库 FK 中,不得写日志、审计 details 或企业 DTO; |
|||
8. 禁止添加 candidateKey→学生详情接口,禁止普通 Base64 解码、可逆加密或字符串拼接; |
|||
9. 禁止读取 assessment/ability answers、attempt questions、question feedback、candidate scoring 表。 |
|||
|
|||
无效 key、授权关闭、注销限制、历史投递、信号失效对企业使用相同安全错误,不解释具体原因。 |
|||
|
|||
## 六、接口与权限 |
|||
|
|||
新增 6 paths / 6 operations: |
|||
|
|||
```text |
|||
POST /companies/me/jobs/{jobId}/talent-invitations |
|||
GET /companies/me/talent-invitations |
|||
GET /companies/me/talent-invitations/{invitationId} |
|||
GET /profile/me/talent-invitations |
|||
GET /profile/me/talent-invitations/{invitationId} |
|||
POST /profile/me/talent-invitations/{invitationId}/decision |
|||
``` |
|||
|
|||
### 6.1 创建请求 |
|||
|
|||
```json |
|||
{ |
|||
"candidateKey": "opaque-url-safe-value", |
|||
"message": "我们正在招聘后端开发实习生,欢迎了解岗位详情。", |
|||
"idempotencyKey": "3c989a31-6774-4520-9700-e58416cc1845" |
|||
} |
|||
``` |
|||
|
|||
校验: |
|||
|
|||
- candidateKey 非空且长度不超过 128; |
|||
- message trim 后 1–300 字符; |
|||
- message 禁止手机号、邮箱、URL、QQ/微信等外部联系方式;复用现有安全规则的公共能力,禁止在 Controller 复制正则; |
|||
- idempotencyKey 为 UUID,最长 64; |
|||
- jobId > 0;分页 page>=0、size 1–50。 |
|||
|
|||
### 6.2 学生决策请求 |
|||
|
|||
```json |
|||
{ |
|||
"decision": "ACCEPT", |
|||
"expectedVersion": 0 |
|||
} |
|||
``` |
|||
|
|||
decision 仅 ACCEPT/REJECT,expectedVersion 必填且必须匹配。 |
|||
|
|||
权限矩阵: |
|||
|
|||
- 企业接口:仅已认证 COMPANY,且仅本公司;STUDENT/ADMIN 403/2002,匿名 401; |
|||
- 学生接口:仅本人 STUDENT;COMPANY/ADMIN 403/2002,匿名 401; |
|||
- 跨公司岗位和邀约统一 403/2002; |
|||
- 他人学生邀约统一按不可见处理,不能泄露存在性。 |
|||
|
|||
## 七、状态、过期、冷却与限额 |
|||
|
|||
状态机: |
|||
|
|||
```text |
|||
PENDING -> ACCEPTED |
|||
PENDING -> REJECTED |
|||
PENDING -> EXPIRED |
|||
``` |
|||
|
|||
- 三个终态不可再次改变; |
|||
- 默认有效期 7 天;`expiresAt <= capturedNow` 已过期; |
|||
- 读取和决策必须支持惰性过期;调度器只能改善及时性,不能承担正确性; |
|||
- 同企业同学生任何终态后 30 天内不能再次邀约; |
|||
- 每公司每岗位每天最多 20 次成功创建; |
|||
- 每公司每天最多 100 次成功创建; |
|||
- 所有限制使用数据库查询和索引,不依赖单机内存; |
|||
- 并发创建由 `UNIQUE(active_key)` 兜底; |
|||
- 状态决策采用 expectedVersion + status + expiresAt 条件更新; |
|||
- 并发 ACCEPT/REJECT/EXPIRE 只能一个胜者,无 500。 |
|||
|
|||
屏蔽关系:学生点击 ACCEPT 时若双方任一方向存在屏蔽,返回 `403/2002`,邀约保持 PENDING,不泄露谁屏蔽谁。 |
|||
|
|||
## 八、幂等与事务 |
|||
|
|||
创建: |
|||
|
|||
- 同 companyId、idempotencyKey、jobId、candidateKeyDigest、message 返回原邀约; |
|||
- 同 companyId、idempotencyKey 但业务内容不同返回 409/10207; |
|||
- 同公司、同岗位、同学生已有 PENDING 返回 409/10203; |
|||
- 唯一键异常后不得在污染的 Hibernate Session 中继续查询;必要时使用 `REQUIRES_NEW` writer 和独立定判事务。 |
|||
|
|||
决策: |
|||
|
|||
- 同目标终态幂等重放可返回原结果; |
|||
- 已是另一终态或版本冲突返回 409; |
|||
- 只有条件更新胜者写 history 和 audit; |
|||
- ACCEPT 必须在单一事务内完成状态、activeKey 清空、conversation、members、history、audit;任一步失败整体回滚; |
|||
- 重放和并发不得重复创建会话、成员、历史或审计。 |
|||
|
|||
## 九、接受后站内会话 |
|||
|
|||
- conversation.type=`TALENT_INVITATION`; |
|||
- 成员仅企业 owner user 与当前学生; |
|||
- 通过内部服务创建,绝不开放传 participantUserId 的 V42 公网入口; |
|||
- 每份 ACCEPTED invitation 只对应一个 conversation; |
|||
- 保留现有 DIRECT 会话 `MATCH_REQUIRED_FOR_CONVERSATION` 规则;只允许已 ACCEPTED 的 V42 内部流程创建 TALENT_INVITATION 会话; |
|||
- 会话标题使用岗位标题安全模板; |
|||
- 不自动把企业自由文案写成首条消息; |
|||
- 后续发消息继续遵守成员、屏蔽和内容规则。 |
|||
|
|||
## 十、DTO 与脱敏 |
|||
|
|||
企业 DTO 只允许: |
|||
|
|||
- invitationId、jobId、jobTitle; |
|||
- candidateLabel=`开放人才候选人`; |
|||
- message、status、createdAt、expiresAt、decidedAt; |
|||
- conversationId 仅 ACCEPTED 后返回; |
|||
- 默认不回显 candidateKey。 |
|||
|
|||
学生 DTO 可包含企业公开名称、岗位公开信息、message、状态、期限、conversationId。 |
|||
|
|||
对企业和学生真实序列化 JSON 断言不得包含: |
|||
|
|||
```text |
|||
studentUserId, ownerUserId, candidateId, phone, email, resumeUrl, |
|||
attemptId, resultId, answerKey, scoringRule, questionCode, |
|||
competencyTag, dimensionScores, overallScore, percentile, fitScore, |
|||
candidateKeyDigest, activeKey, password, token, secret |
|||
``` |
|||
|
|||
## 十一、错误码 |
|||
|
|||
在 ErrorCode 新增并同步 GlobalExceptionHandler: |
|||
|
|||
- 10201 TALENT_INVITATION_NOT_FOUND → 404 |
|||
- 10202 TALENT_INVITATION_CANDIDATE_UNAVAILABLE → 409 |
|||
- 10203 TALENT_INVITATION_ALREADY_ACTIVE → 409 |
|||
- 10204 TALENT_INVITATION_INVALID_STATE → 409 |
|||
- 10205 TALENT_INVITATION_EXPIRED → 409 |
|||
- 10206 TALENT_INVITATION_CONCURRENT_UPDATE → 409 |
|||
- 10207 TALENT_INVITATION_IDEMPOTENCY_CONFLICT → 409 |
|||
- 10208 TALENT_INVITATION_CONTENT_INVALID → 400 |
|||
- 10209 TALENT_INVITATION_RATE_LIMITED → 429 |
|||
|
|||
不得新增其他 V42 错误码,不得改变既有错误码含义。跨角色与屏蔽复用 2002,基础参数非法复用 1001。 |
|||
|
|||
## 十二、审计与隐私 |
|||
|
|||
新增动作: |
|||
|
|||
- TALENT_INVITATION_CREATED |
|||
- TALENT_INVITATION_ACCEPTED |
|||
- TALENT_INVITATION_REJECTED |
|||
- TALENT_INVITATION_EXPIRED |
|||
- TALENT_INVITATION_CREATE_REJECTED |
|||
|
|||
成功审计 details 仅允许 invitationId、jobId、status、requestId。禁止记录 candidateKey/digest、studentUserId、message、联系方式、简历、测评、评分、IP/UA 明文或凭据。 |
|||
|
|||
拒绝型安全审计沿用独立事务,内容同样不得包含 candidateKey 或学生 ID。每次真实状态变化最多一条 history 和一条对应成功审计。 |
|||
|
|||
## 十三、查询与 N+1 |
|||
|
|||
- 企业列表按 companyId 分页;学生列表按 studentUserId 分页; |
|||
- 排序 `createdAt DESC, id DESC`; |
|||
- 当前页岗位、公司、会话信息批量读取; |
|||
- 不逐邀请查询 Job/Company/User/Conversation; |
|||
- 详情查询直接绑定归属条件; |
|||
- 批量过期固定批次条件更新; |
|||
- 测试用 Spy/统计断言查询次数不随 items 数线性增长。 |
|||
|
|||
## 十四、必须新增测试 |
|||
|
|||
至少新增: |
|||
|
|||
1. `TalentInvitationCandidateResolverTest` |
|||
- 正确、篡改、过期日期桶 key; |
|||
- 常量时间比较; |
|||
- 授权/注销/历史投递/信号变化后的重新校验; |
|||
- 其他企业投递不误排; |
|||
- 无答案表、题目表、候选评分查询; |
|||
- 批量查询无 N+1。 |
|||
|
|||
2. `TalentInvitationServiceTest` |
|||
- 创建、文案安全、幂等和冲突; |
|||
- 活跃唯一、30 天冷却、20/100 日限额边界; |
|||
- 惰性过期; |
|||
- ACCEPT 唯一会话、REJECT/EXPIRE 无会话; |
|||
- 并发创建和并发决策无 500; |
|||
- 重放不重复 history/audit/conversation; |
|||
- 屏蔽关系保持 PENDING。 |
|||
|
|||
3. `TalentInvitationControllerTest` |
|||
- COMPANY/STUDENT/ADMIN/匿名和跨租户矩阵; |
|||
- 参数校验; |
|||
- 企业/学生真实 JSON 脱敏; |
|||
- 不存在详情解锁、联系方式或直接会话接口。 |
|||
|
|||
4. `TalentInvitationPersistenceIntegrationTest` |
|||
- V40 迁移、FK、CHECK、UNIQUE、@Version; |
|||
- activeKey 终态释放; |
|||
- conversation 唯一; |
|||
- history 不含敏感列; |
|||
- 并发创建/决策; |
|||
- 列表 N+1; |
|||
- candidate_dimension_scores 不变。 |
|||
|
|||
5. 回归测试 |
|||
- V41 推荐/授权; |
|||
- V39 岗位画像; |
|||
- V40 评价回复; |
|||
- DIRECT 会话仍要求 MATCHED; |
|||
- 只有 ACCEPTED 邀约可创建 TALENT_INVITATION 会话; |
|||
- 屏蔽与消息权限不变。 |
|||
|
|||
## 十五、SecurityConfig、OpenAPI 和文档 |
|||
|
|||
SecurityConfig 仅新增: |
|||
|
|||
- `/companies/me/**/talent-invitations/**` → COMPANY; |
|||
- `/profile/me/talent-invitations/**` → STUDENT。 |
|||
|
|||
路径匹配必须覆盖实际 6 个 operation,但不能误放宽其他 `/companies/me/**` 或 `/profile/me/**` 接口。 |
|||
|
|||
新增: |
|||
|
|||
- `docs/enterprise-talent-invitation-api.md` |
|||
|
|||
更新: |
|||
|
|||
- `docs/frontend-integration.md` |
|||
- `docs/backend-development-roadmap.md` |
|||
- `docs/v42-enterprise-talent-invitation-design.md` |
|||
- `docs/openapi-rc.json` |
|||
- `docs/verify_openapi.py` |
|||
|
|||
OpenAPI 目标:从 161/199 增加 6 paths / 6 operations,预计 **167 paths / 205 operations**,以实际结果为准。 |
|||
|
|||
校验脚本必须断言: |
|||
|
|||
- V42 恰好 6 paths / 6 operations; |
|||
- POST 请求均有非空 requestBody schema; |
|||
- 6 个成功响应均有非空 schema; |
|||
- 企业/学生 schema 不含敏感字段; |
|||
- 不存在候选详情、联系方式解锁、企业直接建会话、撤回/编辑/删除/批量邀约接口; |
|||
- OpenAPI 总数与实际一致。 |
|||
|
|||
## 十六、自动化验证命令 |
|||
|
|||
依次执行并记录真实结果: |
|||
|
|||
```powershell |
|||
mvn -Dtest="*TalentInvitation*Test,*TalentDiscovery*Test,*Messaging*Test" test |
|||
mvn test |
|||
mvn clean verify |
|||
python docs/verify_openapi.py |
|||
``` |
|||
|
|||
若 `clean verify` 因运行中的 JAR 锁定 target 失败,不得结束未知进程;只报告 PID、端口和阻塞原因,等待负责人确认。 |
|||
|
|||
## 十七、最终停止条件 |
|||
|
|||
完成代码、测试、OpenAPI 和文档后立即停止: |
|||
|
|||
- 不启动/停止 Docker; |
|||
- 不做真实 MySQL 黑盒; |
|||
- 不提交或推送 Git; |
|||
- 不执行 `git add -A`; |
|||
- 不删除未跟踪文件; |
|||
- 不进入 V43。 |
|||
|
|||
最终报告必须包含: |
|||
|
|||
1. 精确修改文件; |
|||
2. Flyway V40 在测试环境的执行情况; |
|||
3. 实际测试数、Failures、Errors、Skipped; |
|||
4. OpenAPI 实际 paths/operations; |
|||
5. candidateKey 重算匹配与失效重新校验证据; |
|||
6. 7 天过期、30 天冷却、20/100 限额结果; |
|||
7. 并发创建/决策及会话唯一性; |
|||
8. 企业/学生权限矩阵; |
|||
9. 完整 JSON、审计、history 脱敏结果; |
|||
10. N+1 查询证明; |
|||
11. DIRECT 与 TALENT_INVITATION 会话规则回归; |
|||
12. 是否写 `candidate_dimension_scores`; |
|||
13. 是否修改 V1–V39; |
|||
14. 是否执行 Docker/MySQL 黑盒; |
|||
15. 当前 git status; |
|||
16. 明确说明未执行 `git add -A`、commit、push。 |
|||
|
|||
任何失败或 SKIP 必须如实报告,不得伪造 PASS。 |
|||
@ -0,0 +1,48 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import jakarta.persistence.*; |
|||
import lombok.*; |
|||
import org.hibernate.annotations.JdbcTypeCode; |
|||
import org.hibernate.type.SqlTypes; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.annotation.LastModifiedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "talent_invitations") |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class TalentInvitation { |
|||
public static final String PENDING = "PENDING"; |
|||
public static final String ACCEPTED = "ACCEPTED"; |
|||
public static final String REJECTED = "REJECTED"; |
|||
public static final String EXPIRED = "EXPIRED"; |
|||
|
|||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
@Column(name = "company_id", nullable = false) private Long companyId; |
|||
@Column(name = "job_id", nullable = false) private Long jobId; |
|||
@Column(name = "student_user_id", nullable = false) private Long studentUserId; |
|||
/** |
|||
* V40 已在 MySQL 中以 CHAR(64) 建表;显式声明 JDBC 类型,避免 |
|||
* Hibernate 将 String 默认推断为 VARCHAR(64) 导致 ddl-auto=validate 失败。 |
|||
*/ |
|||
@JdbcTypeCode(SqlTypes.CHAR) |
|||
@Column(name = "candidate_key_digest", nullable = false, length = 64, columnDefinition = "CHAR(64)") |
|||
private String candidateKeyDigest; |
|||
@Column(nullable = false, length = 300) private String message; |
|||
@Column(nullable = false, length = 20) @Builder.Default private String status = PENDING; |
|||
@Column(name = "idempotency_key", nullable = false, length = 64) private String idempotencyKey; |
|||
@Column(name = "active_key", length = 200) private String activeKey; |
|||
@Column(name = "conversation_id", unique = true) private Long conversationId; |
|||
@Column(name = "expires_at", nullable = false) private LocalDateTime expiresAt; |
|||
@Column(name = "decided_at") private LocalDateTime decidedAt; |
|||
@Version @Column(nullable = false) @Builder.Default private Long version = 0L; |
|||
@CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; |
|||
@LastModifiedDate @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; |
|||
} |
|||
@ -0,0 +1,68 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import com.mydreamjob.talentdiscovery.CandidateKeyGenerator; |
|||
import com.mydreamjob.talentdiscovery.TalentDiscoveryCandidateSource; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.security.MessageDigest; |
|||
import java.time.LocalDate; |
|||
import java.time.LocalDateTime; |
|||
import java.util.Map; |
|||
import java.util.List; |
|||
|
|||
/** 在当前 V41 合格候选池内重算 opaque key,绝不通过 key 反解学生 ID。 */ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class TalentInvitationCandidateResolver { |
|||
private final TalentDiscoveryCandidateSource candidateSource; |
|||
private final CandidateKeyGenerator candidateKeyGenerator; |
|||
|
|||
public Resolution resolve(Long companyId, Long jobId, String candidateKey, LocalDateTime capturedNow) { |
|||
if (companyId == null || jobId == null || candidateKey == null || capturedNow == null) { |
|||
return Resolution.none(); |
|||
} |
|||
byte[] expected = candidateKey.getBytes(StandardCharsets.UTF_8); |
|||
Long match = null; |
|||
int matches = 0; |
|||
List<TalentDiscoveryCandidateSource.CandidateSeed> candidates = candidateSource.findEligibleCandidates(companyId); |
|||
List<Long> candidateIds = candidates.stream().map(TalentDiscoveryCandidateSource.CandidateSeed::studentUserId).toList(); |
|||
Map<Long, TalentDiscoveryCandidateSource.PreferenceSignal> preferences = safeMap(candidateSource.findPreferences(candidateIds)); |
|||
Map<Long, TalentDiscoveryCandidateSource.ScoreSignal> riasec = safeMap(candidateSource.findLatestRiasec(candidateIds)); |
|||
Map<Long, TalentDiscoveryCandidateSource.ScoreSignal> ability = safeMap(candidateSource.findLatestAbility(candidateIds, capturedNow)); |
|||
LocalDate bucket = capturedNow.toLocalDate(); |
|||
for (TalentDiscoveryCandidateSource.CandidateSeed candidate : candidates) { |
|||
// 候选池要求至少有一个可用推荐信号;缺失信号不得按 0 分放行。
|
|||
if (!hasSignal(candidate.studentUserId(), preferences, riasec, ability)) continue; |
|||
String generated = candidateKeyGenerator.generate(companyId, jobId, candidate.studentUserId(), bucket); |
|||
if (MessageDigest.isEqual(expected, generated.getBytes(StandardCharsets.UTF_8))) { |
|||
match = candidate.studentUserId(); |
|||
matches++; |
|||
} |
|||
} |
|||
return matches == 1 ? new Resolution(match, true) : Resolution.none(); |
|||
} |
|||
|
|||
private boolean hasSignal(Long studentId, |
|||
Map<Long, TalentDiscoveryCandidateSource.PreferenceSignal> preferences, |
|||
Map<Long, TalentDiscoveryCandidateSource.ScoreSignal> riasec, |
|||
Map<Long, TalentDiscoveryCandidateSource.ScoreSignal> ability) { |
|||
TalentDiscoveryCandidateSource.PreferenceSignal p = preferences.get(studentId); |
|||
if (p != null && (p.expectedSalaryMin() != null || p.expectedSalaryMax() != null |
|||
|| !p.cities().isEmpty() || !p.employmentTypes().isEmpty() |
|||
|| !p.industries().isEmpty() || !p.jobTypes().isEmpty())) return true; |
|||
TalentDiscoveryCandidateSource.ScoreSignal i = riasec.get(studentId); |
|||
if (i != null && i.scores() != null && !i.scores().isEmpty()) return true; |
|||
TalentDiscoveryCandidateSource.ScoreSignal a = ability.get(studentId); |
|||
return a != null && a.scores() != null && !a.scores().isEmpty(); |
|||
} |
|||
|
|||
private <K, V> Map<K, V> safeMap(Map<K, V> value) { |
|||
return value == null ? Map.of() : value; |
|||
} |
|||
|
|||
public record Resolution(Long studentUserId, boolean matched) { |
|||
public static Resolution none() { return new Resolution(null, false); } |
|||
} |
|||
} |
|||
@ -0,0 +1,89 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.talentinvitation.dto.*; |
|||
import io.swagger.v3.oas.annotations.Operation; |
|||
import io.swagger.v3.oas.annotations.media.Content; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import io.swagger.v3.oas.annotations.responses.ApiResponses; |
|||
import io.swagger.v3.oas.annotations.security.SecurityRequirement; |
|||
import io.swagger.v3.oas.annotations.tags.Tag; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.validation.Valid; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.security.core.Authentication; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
import org.springframework.web.bind.annotation.*; |
|||
|
|||
@RestController |
|||
@RequiredArgsConstructor |
|||
@Tag(name = "企业人才邀约", description = "企业发起邀约,学生同意后建立站内会话") |
|||
public class TalentInvitationController { |
|||
private final TalentInvitationService service; |
|||
|
|||
@PostMapping("/companies/me/jobs/{jobId}/talent-invitations") |
|||
@Operation(summary = "创建人才邀约", security = @SecurityRequirement(name = "bearerAuth")) |
|||
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseCompanyTalentInvitationResponse")))) |
|||
public ApiResponse<CompanyTalentInvitationResponse> create(@PathVariable Long jobId, |
|||
@Valid @RequestBody CreateTalentInvitationRequest body, |
|||
HttpServletRequest request) { |
|||
return ApiResponse.ok(service.create(userId(), jobId, body, AuditContext.from(request)), requestId(request)); |
|||
} |
|||
|
|||
@GetMapping("/companies/me/talent-invitations") |
|||
@Operation(summary = "企业邀约列表", security = @SecurityRequirement(name = "bearerAuth")) |
|||
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseCompanyTalentInvitationPageResponse")))) |
|||
public ApiResponse<TalentInvitationPageResponse<CompanyTalentInvitationResponse>> listCompany( |
|||
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, |
|||
HttpServletRequest request) { |
|||
return ApiResponse.ok(service.listCompany(userId(), page, size), requestId(request)); |
|||
} |
|||
|
|||
@GetMapping("/companies/me/talent-invitations/{invitationId}") |
|||
@Operation(summary = "企业邀约详情", security = @SecurityRequirement(name = "bearerAuth")) |
|||
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseCompanyTalentInvitationResponse")))) |
|||
public ApiResponse<CompanyTalentInvitationResponse> getCompany(@PathVariable Long invitationId, |
|||
HttpServletRequest request) { |
|||
return ApiResponse.ok(service.getCompany(userId(), invitationId, AuditContext.from(request)), requestId(request)); |
|||
} |
|||
|
|||
@GetMapping("/profile/me/talent-invitations") |
|||
@Operation(summary = "学生收到的邀约列表", security = @SecurityRequirement(name = "bearerAuth")) |
|||
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseStudentTalentInvitationPageResponse")))) |
|||
public ApiResponse<TalentInvitationPageResponse<StudentTalentInvitationResponse>> listStudent( |
|||
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, |
|||
HttpServletRequest request) { |
|||
return ApiResponse.ok(service.listStudent(userId(), page, size), requestId(request)); |
|||
} |
|||
|
|||
@GetMapping("/profile/me/talent-invitations/{invitationId}") |
|||
@Operation(summary = "学生邀约详情", security = @SecurityRequirement(name = "bearerAuth")) |
|||
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseStudentTalentInvitationResponse")))) |
|||
public ApiResponse<StudentTalentInvitationResponse> getStudent(@PathVariable Long invitationId, |
|||
HttpServletRequest request) { |
|||
return ApiResponse.ok(service.getStudent(userId(), invitationId, AuditContext.from(request)), requestId(request)); |
|||
} |
|||
|
|||
@PostMapping("/profile/me/talent-invitations/{invitationId}/decision") |
|||
@Operation(summary = "处理人才邀约", description = "学生接受后才创建 TALENT_INVITATION 站内会话", security = @SecurityRequirement(name = "bearerAuth")) |
|||
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", content = @Content(schema = @Schema(ref = "#/components/schemas/ApiResponseStudentTalentInvitationResponse")))) |
|||
public ApiResponse<StudentTalentInvitationResponse> decide(@PathVariable Long invitationId, |
|||
@Valid @RequestBody TalentInvitationDecisionRequest body, |
|||
HttpServletRequest request) { |
|||
return ApiResponse.ok(service.decide(userId(), invitationId, body, AuditContext.from(request)), requestId(request)); |
|||
} |
|||
|
|||
private Long userId() { |
|||
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
|||
if (auth == null || !auth.isAuthenticated() || auth.getName() == null) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
try { return Long.valueOf(auth.getName()); } |
|||
catch (NumberFormatException e) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } |
|||
} |
|||
|
|||
private String requestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } |
|||
} |
|||
@ -0,0 +1,49 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.messaging.Conversation; |
|||
import com.mydreamjob.messaging.ConversationMember; |
|||
import com.mydreamjob.messaging.ConversationMemberRepository; |
|||
import com.mydreamjob.messaging.ConversationRepository; |
|||
import com.mydreamjob.safety.BlockRelationRepository; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.dao.DataIntegrityViolationException; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** 仅供已接受邀约调用的会话创建器;不会放宽现有 DIRECT 会话规则。 */ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class TalentInvitationConversationService { |
|||
private final ConversationRepository conversationRepository; |
|||
private final ConversationMemberRepository memberRepository; |
|||
private final BlockRelationRepository blockRelationRepository; |
|||
|
|||
@Transactional |
|||
public Long createForAcceptedInvitation(TalentInvitation invitation, Long companyOwnerUserId, Job job) { |
|||
if (invitation.getConversationId() != null) return invitation.getConversationId(); |
|||
if (blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(companyOwnerUserId, invitation.getStudentUserId()) |
|||
|| blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(invitation.getStudentUserId(), companyOwnerUserId)) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
Conversation conversation = conversationRepository.saveAndFlush(Conversation.builder() |
|||
.createdBy(companyOwnerUserId) |
|||
.title(job == null ? "人才邀约沟通" : job.getTitle()) |
|||
.type("TALENT_INVITATION") |
|||
.build()); |
|||
try { |
|||
memberRepository.saveAll(List.of( |
|||
ConversationMember.builder().conversationId(conversation.getId()).userId(companyOwnerUserId).build(), |
|||
ConversationMember.builder().conversationId(conversation.getId()).userId(invitation.getStudentUserId()).build())); |
|||
memberRepository.flush(); |
|||
} catch (DataIntegrityViolationException ex) { |
|||
// invitation 行由上游锁定;若数据库异常,事务回滚,调用方不会看到半成品会话。
|
|||
throw ex; |
|||
} |
|||
return conversation.getId(); |
|||
} |
|||
} |
|||
@ -0,0 +1,55 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.transaction.annotation.Propagation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.Map; |
|||
|
|||
/** 把 EXPIRED 状态、历史和审计提交在独立事务中,避免随后返回错误导致回滚。 */ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class TalentInvitationExpiryService { |
|||
private final TalentInvitationRepository invitationRepository; |
|||
private final TalentInvitationHistoryRepository historyRepository; |
|||
private final AuditService auditService; |
|||
|
|||
@Transactional(propagation = Propagation.REQUIRES_NEW) |
|||
public boolean expireIfDue(Long invitationId, LocalDateTime now, AuditContext context) { |
|||
if (invitationId == null || now == null) return false; |
|||
TalentInvitation current = invitationRepository.findById(invitationId).orElse(null); |
|||
if (current == null || !TalentInvitation.PENDING.equals(current.getStatus()) || current.getExpiresAt().isAfter(now)) { |
|||
return false; |
|||
} |
|||
if (invitationRepository.expire(invitationId, now) != 1) return false; |
|||
historyRepository.save(TalentInvitationHistory.builder() |
|||
.invitationId(invitationId).action(TalentInvitationHistory.EXPIRED).actorUserId(null).build()); |
|||
Map<String, Object> details = new LinkedHashMap<>(); |
|||
details.put("invitationId", invitationId); |
|||
details.put("jobId", current.getJobId()); |
|||
details.put("status", TalentInvitation.EXPIRED); |
|||
details.put("requestId", context == null ? null : context.getRequestId()); |
|||
auditService.log(null, "TALENT_INVITATION_EXPIRED", "TALENT_INVITATION", invitationId, |
|||
toJson(details), requestContext(context)); |
|||
return true; |
|||
} |
|||
|
|||
private AuditContext requestContext(AuditContext context) { |
|||
return context == null ? null : AuditContext.builder().requestId(context.getRequestId()).build(); |
|||
} |
|||
|
|||
private String toJson(Map<String, Object> values) { |
|||
return "{\"invitationId\":" + values.get("invitationId") + ",\"jobId\":" + values.get("jobId") |
|||
+ ",\"status\":\"EXPIRED\",\"requestId\":" |
|||
+ (values.get("requestId") == null ? "null" : quote(String.valueOf(values.get("requestId")))) + "}"; |
|||
} |
|||
|
|||
private String quote(String value) { |
|||
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import jakarta.persistence.*; |
|||
import lombok.*; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "talent_invitation_history") |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class TalentInvitationHistory { |
|||
public static final String CREATED = "CREATED"; |
|||
public static final String ACCEPTED = "ACCEPTED"; |
|||
public static final String REJECTED = "REJECTED"; |
|||
public static final String EXPIRED = "EXPIRED"; |
|||
|
|||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; |
|||
@Column(name = "invitation_id", nullable = false) private Long invitationId; |
|||
@Column(nullable = false, length = 30) private String action; |
|||
@Column(name = "actor_user_id") private Long actorUserId; |
|||
@CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
@Repository |
|||
public interface TalentInvitationHistoryRepository extends JpaRepository<TalentInvitationHistory, Long> { |
|||
long countByInvitationId(Long invitationId); |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import jakarta.persistence.LockModeType; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.data.domain.Pageable; |
|||
import org.springframework.data.jpa.repository.*; |
|||
import org.springframework.data.repository.query.Param; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.Optional; |
|||
|
|||
@Repository |
|||
public interface TalentInvitationRepository extends JpaRepository<TalentInvitation, Long> { |
|||
Optional<TalentInvitation> findByCompanyIdAndIdempotencyKey(Long companyId, String idempotencyKey); |
|||
Optional<TalentInvitation> findByIdAndCompanyId(Long id, Long companyId); |
|||
Optional<TalentInvitation> findByIdAndStudentUserId(Long id, Long studentUserId); |
|||
Optional<TalentInvitation> findByCompanyIdAndJobIdAndStudentUserIdAndStatus( |
|||
Long companyId, Long jobId, Long studentUserId, String status); |
|||
Page<TalentInvitation> findByCompanyIdOrderByCreatedAtDescIdDesc(Long companyId, Pageable pageable); |
|||
Page<TalentInvitation> findByStudentUserIdOrderByCreatedAtDescIdDesc(Long studentUserId, Pageable pageable); |
|||
|
|||
@Query("select count(t) from TalentInvitation t where t.companyId=:companyId and t.createdAt>=:from and t.createdAt<:to") |
|||
long countCreatedBetween(@Param("companyId") Long companyId, @Param("from") LocalDateTime from, |
|||
@Param("to") LocalDateTime to); |
|||
|
|||
@Query("select count(t) from TalentInvitation t where t.companyId=:companyId and t.jobId=:jobId " |
|||
+ "and t.createdAt>=:from and t.createdAt<:to") |
|||
long countJobCreatedBetween(@Param("companyId") Long companyId, @Param("jobId") Long jobId, |
|||
@Param("from") LocalDateTime from, @Param("to") LocalDateTime to); |
|||
|
|||
@Query("select case when count(t)>0 then true else false end from TalentInvitation t " |
|||
+ "where t.companyId=:companyId and t.studentUserId=:studentUserId and t.expiresAt>:since") |
|||
boolean existsInCooldown(@Param("companyId") Long companyId, @Param("studentUserId") Long studentUserId, |
|||
@Param("since") LocalDateTime since); |
|||
|
|||
@Modifying(clearAutomatically = true, flushAutomatically = true) |
|||
@Query("update TalentInvitation t set t.status=:status, t.activeKey=null, t.decidedAt=:decidedAt, " |
|||
+ "t.version=t.version+1 where t.id=:id and t.studentUserId=:studentUserId " |
|||
+ "and t.status='PENDING' and t.version=:version and t.expiresAt>:now") |
|||
int decide(@Param("id") Long id, @Param("studentUserId") Long studentUserId, |
|||
@Param("status") String status, @Param("version") Long version, |
|||
@Param("decidedAt") LocalDateTime decidedAt, @Param("now") LocalDateTime now); |
|||
|
|||
@Modifying(clearAutomatically = true, flushAutomatically = true) |
|||
@Query("update TalentInvitation t set t.conversationId=:conversationId " |
|||
+ "where t.id=:id and t.studentUserId=:studentUserId and t.status='ACCEPTED' " |
|||
+ "and t.version=:version and t.conversationId is null") |
|||
int attachConversation(@Param("id") Long id, @Param("studentUserId") Long studentUserId, |
|||
@Param("conversationId") Long conversationId, @Param("version") Long version); |
|||
|
|||
@Modifying(clearAutomatically = true, flushAutomatically = true) |
|||
@Query("update TalentInvitation t set t.status='EXPIRED', t.activeKey=null, t.decidedAt=:now, " |
|||
+ "t.version=t.version+1 where t.id=:id and t.status='PENDING' and t.expiresAt<=:now") |
|||
int expire(@Param("id") Long id, @Param("now") LocalDateTime now); |
|||
} |
|||
@ -0,0 +1,377 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import com.fasterxml.jackson.core.JsonProcessingException; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.audit.SecurityAuditService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.recommendation.JobRecommendationProfile; |
|||
import com.mydreamjob.recommendation.JobRecommendationProfileRepository; |
|||
import com.mydreamjob.review.ReviewModerationService; |
|||
import com.mydreamjob.safety.BlockRelationRepository; |
|||
import com.mydreamjob.talentinvitation.dto.CompanyTalentInvitationResponse; |
|||
import com.mydreamjob.talentinvitation.dto.CreateTalentInvitationRequest; |
|||
import com.mydreamjob.talentinvitation.dto.StudentTalentInvitationResponse; |
|||
import com.mydreamjob.talentinvitation.dto.TalentInvitationDecisionRequest; |
|||
import com.mydreamjob.talentinvitation.dto.TalentInvitationPageResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.dao.DataIntegrityViolationException; |
|||
import org.springframework.orm.ObjectOptimisticLockingFailureException; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.data.domain.PageRequest; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.security.MessageDigest; |
|||
import java.time.LocalDate; |
|||
import java.time.LocalDateTime; |
|||
import java.time.ZoneId; |
|||
import java.util.*; |
|||
|
|||
/** V42 邀约状态机、归属校验、幂等和接受后会话编排。 */ |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class TalentInvitationService { |
|||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); |
|||
private static final int TTL_DAYS = 7; |
|||
private static final int COOL_DOWN_DAYS = 30; |
|||
private static final int DAILY_COMPANY_LIMIT = 100; |
|||
private static final int DAILY_JOB_LIMIT = 20; |
|||
private static final ObjectMapper JSON = new ObjectMapper(); |
|||
|
|||
private final TalentInvitationRepository invitationRepository; |
|||
private final TalentInvitationHistoryRepository historyRepository; |
|||
private final UserRepository userRepository; |
|||
private final CompanyRepository companyRepository; |
|||
private final JobRepository jobRepository; |
|||
private final JobRecommendationProfileRepository profileRepository; |
|||
private final TalentInvitationCandidateResolver candidateResolver; |
|||
private final TalentInvitationConversationService conversationService; |
|||
private final TalentInvitationExpiryService expiryService; |
|||
private final BlockRelationRepository blockRelationRepository; |
|||
private final ReviewModerationService moderationService; |
|||
private final AuditService auditService; |
|||
private final SecurityAuditService securityAuditService; |
|||
|
|||
@Transactional |
|||
public CompanyTalentInvitationResponse create(Long userId, Long jobId, CreateTalentInvitationRequest request, |
|||
AuditContext context) { |
|||
if (jobId == null || jobId <= 0) throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
Company company = requireVerifiedCompany(userId); |
|||
CreateValues values = validateCreateRequest(request); |
|||
|
|||
// 公司行锁把幂等、冷却和每日限额串在同一锁顺序内;避免并发 COUNT 后同时 INSERT。
|
|||
companyRepository.findByIdForUpdate(company.getId()).orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); |
|||
Job job = requireOwnedJob(company, jobId); |
|||
String digest = sha256(values.candidateKey()); |
|||
TalentInvitation existingByKey = invitationRepository.findByCompanyIdAndIdempotencyKey(company.getId(), values.idempotencyKey()).orElse(null); |
|||
if (existingByKey != null) { |
|||
if (existingByKey.getJobId().equals(jobId) |
|||
&& digest.equals(existingByKey.getCandidateKeyDigest()) |
|||
&& values.message().equals(existingByKey.getMessage())) { |
|||
return toCompanyResponse(existingByKey, job); |
|||
} |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT); |
|||
} |
|||
if (!"PUBLISHED".equals(job.getStatus())) throw new BusinessException(ErrorCode.INVALID_JOB_STATUS_TRANSITION); |
|||
|
|||
JobRecommendationProfile profile = profileRepository.findByJobId(jobId).orElse(null); |
|||
if (profile == null || !Boolean.TRUE.equals(profile.getEnabled()) |
|||
|| !JobRecommendationProfile.APPROVED.equals(profile.getReviewStatus())) { |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_CANDIDATE_UNAVAILABLE); |
|||
} |
|||
LocalDateTime now = LocalDateTime.now(BUSINESS_ZONE); |
|||
TalentInvitationCandidateResolver.Resolution resolution = candidateResolver.resolve(company.getId(), jobId, values.candidateKey(), now); |
|||
if (!resolution.matched()) throw new BusinessException(ErrorCode.TALENT_INVITATION_CANDIDATE_UNAVAILABLE); |
|||
|
|||
if (invitationRepository.findByCompanyIdAndJobIdAndStudentUserIdAndStatus( |
|||
company.getId(), jobId, resolution.studentUserId(), TalentInvitation.PENDING).isPresent()) { |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_ALREADY_ACTIVE); |
|||
} |
|||
LocalDateTime cooldownSince = now.minusDays(COOL_DOWN_DAYS); |
|||
if (invitationRepository.existsInCooldown(company.getId(), resolution.studentUserId(), cooldownSince)) { |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_ALREADY_ACTIVE); |
|||
} |
|||
LocalDateTime dayStart = now.toLocalDate().atStartOfDay(); |
|||
LocalDateTime nextDay = dayStart.plusDays(1); |
|||
if (invitationRepository.countJobCreatedBetween(company.getId(), jobId, dayStart, nextDay) >= DAILY_JOB_LIMIT |
|||
|| invitationRepository.countCreatedBetween(company.getId(), dayStart, nextDay) >= DAILY_COMPANY_LIMIT) { |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_RATE_LIMITED); |
|||
} |
|||
ReviewModerationService.ModerationResult moderation = moderationService.moderateCompanyReply(values.message()); |
|||
if (!moderation.getReasonCodes().isEmpty()) { |
|||
auditRejected(userId, company.getId(), jobId, context); |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_CONTENT_INVALID); |
|||
} |
|||
|
|||
TalentInvitation invitation = TalentInvitation.builder() |
|||
.companyId(company.getId()).jobId(jobId).studentUserId(resolution.studentUserId()) |
|||
.candidateKeyDigest(digest).message(values.message()).idempotencyKey(values.idempotencyKey()) |
|||
.activeKey(company.getId() + ":" + jobId + ":" + resolution.studentUserId()) |
|||
.status(TalentInvitation.PENDING).expiresAt(now.plusDays(TTL_DAYS)).version(0L).build(); |
|||
try { |
|||
invitation = invitationRepository.saveAndFlush(invitation); |
|||
} catch (DataIntegrityViolationException ex) { |
|||
// 公司行锁正常情况下已串行;唯一约束是异常路径的最终防线,统一转换为业务冲突。
|
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_ALREADY_ACTIVE); |
|||
} |
|||
historyRepository.save(TalentInvitationHistory.builder().invitationId(invitation.getId()) |
|||
.action(TalentInvitationHistory.CREATED).actorUserId(userId).build()); |
|||
audit(userId, "TALENT_INVITATION_CREATED", invitation, context); |
|||
return toCompanyResponse(invitation, job); |
|||
} |
|||
|
|||
@Transactional(readOnly = true) |
|||
public TalentInvitationPageResponse<CompanyTalentInvitationResponse> listCompany(Long userId, int page, int size) { |
|||
validatePage(page, size); |
|||
Company company = requireVerifiedCompany(userId); |
|||
Page<TalentInvitation> invitations = invitationRepository.findByCompanyIdOrderByCreatedAtDescIdDesc( |
|||
company.getId(), PageRequest.of(page, size)); |
|||
List<Long> jobIds = invitations.getContent().stream().map(TalentInvitation::getJobId).distinct().toList(); |
|||
Map<Long, Job> jobs = new HashMap<>(); |
|||
jobRepository.findAllById(jobIds).forEach(job -> jobs.put(job.getId(), job)); |
|||
List<CompanyTalentInvitationResponse> items = invitations.getContent().stream() |
|||
.map(inv -> toCompanyResponse(inv, jobs.get(inv.getJobId()))).toList(); |
|||
return page(items, invitations); |
|||
} |
|||
|
|||
@Transactional(readOnly = true) |
|||
public CompanyTalentInvitationResponse getCompany(Long userId, Long invitationId, AuditContext context) { |
|||
Company company = requireVerifiedCompany(userId); |
|||
TalentInvitation invitation = invitationRepository.findById(invitationId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); |
|||
if (!company.getId().equals(invitation.getCompanyId())) { |
|||
// 跨企业邀约统一按越权处理,不返回岗位/候选等任何业务字段。
|
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
if (expireIfDue(invitation, context)) { |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_EXPIRED); |
|||
} |
|||
Job job = jobRepository.findById(invitation.getJobId()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); |
|||
return toCompanyResponse(invitationRepository.findById(invitationId).orElse(invitation), job); |
|||
} |
|||
|
|||
@Transactional(readOnly = true) |
|||
public TalentInvitationPageResponse<StudentTalentInvitationResponse> listStudent(Long userId, int page, int size) { |
|||
requireStudent(userId); |
|||
validatePage(page, size); |
|||
Page<TalentInvitation> invitations = invitationRepository.findByStudentUserIdOrderByCreatedAtDescIdDesc( |
|||
userId, PageRequest.of(page, size)); |
|||
List<Long> jobIds = invitations.getContent().stream().map(TalentInvitation::getJobId).distinct().toList(); |
|||
List<Long> companyIds = invitations.getContent().stream().map(TalentInvitation::getCompanyId).distinct().toList(); |
|||
Map<Long, Job> jobs = new HashMap<>(); |
|||
Map<Long, Company> companies = new HashMap<>(); |
|||
jobRepository.findAllById(jobIds).forEach(job -> jobs.put(job.getId(), job)); |
|||
companyRepository.findAllById(companyIds).forEach(company -> companies.put(company.getId(), company)); |
|||
List<StudentTalentInvitationResponse> items = invitations.getContent().stream() |
|||
.map(inv -> toStudentResponse(inv, jobs.get(inv.getJobId()), companies.get(inv.getCompanyId()))).toList(); |
|||
return pageStudent(items, invitations); |
|||
} |
|||
|
|||
@Transactional |
|||
public StudentTalentInvitationResponse getStudent(Long userId, Long invitationId, AuditContext context) { |
|||
requireStudent(userId); |
|||
TalentInvitation invitation = invitationRepository.findByIdAndStudentUserId(invitationId, userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); |
|||
if (expireIfDue(invitation, context)) { |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_EXPIRED); |
|||
} |
|||
TalentInvitation current = invitationRepository.findById(invitationId).orElse(invitation); |
|||
Job job = jobRepository.findById(current.getJobId()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); |
|||
Company company = companyRepository.findById(current.getCompanyId()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); |
|||
return toStudentResponse(current, job, company); |
|||
} |
|||
|
|||
@Transactional |
|||
public StudentTalentInvitationResponse decide(Long userId, Long invitationId, TalentInvitationDecisionRequest request, |
|||
AuditContext context) { |
|||
requireStudent(userId); |
|||
if (request == null || request.getDecision() == null || request.getExpectedVersion() == null |
|||
|| !("ACCEPT".equals(request.getDecision()) || "REJECT".equals(request.getDecision()))) { |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_CONTENT_INVALID); |
|||
} |
|||
TalentInvitation invitation = invitationRepository.findByIdAndStudentUserId(invitationId, userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); |
|||
LocalDateTime now = LocalDateTime.now(BUSINESS_ZONE); |
|||
if (TalentInvitation.PENDING.equals(invitation.getStatus()) && !invitation.getExpiresAt().isAfter(now)) { |
|||
if (expiryService.expireIfDue(invitationId, now, context)) { |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_EXPIRED); |
|||
} |
|||
} |
|||
TalentInvitation current = invitationRepository.findByIdAndStudentUserId(invitationId, userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); |
|||
String target = "ACCEPT".equals(request.getDecision()) ? TalentInvitation.ACCEPTED : TalentInvitation.REJECTED; |
|||
if (!TalentInvitation.PENDING.equals(current.getStatus())) { |
|||
if (target.equals(current.getStatus())) return studentResponse(current); |
|||
if (TalentInvitation.EXPIRED.equals(current.getStatus())) throw new BusinessException(ErrorCode.TALENT_INVITATION_EXPIRED); |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_INVALID_STATE); |
|||
} |
|||
if (TalentInvitation.ACCEPTED.equals(target) && isBlocked(current)) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
int updated; |
|||
try { |
|||
updated = invitationRepository.decide(invitationId, userId, target, request.getExpectedVersion(), now, now); |
|||
} catch (ObjectOptimisticLockingFailureException ex) { |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_CONCURRENT_UPDATE); |
|||
} |
|||
if (updated != 1) { |
|||
TalentInvitation after = invitationRepository.findByIdAndStudentUserId(invitationId, userId).orElse(null); |
|||
if (after != null && target.equals(after.getStatus())) return studentResponse(after); |
|||
if (after != null && TalentInvitation.EXPIRED.equals(after.getStatus())) throw new BusinessException(ErrorCode.TALENT_INVITATION_EXPIRED); |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_CONCURRENT_UPDATE); |
|||
} |
|||
TalentInvitation saved = invitationRepository.findByIdAndStudentUserId(invitationId, userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); |
|||
Job job = jobRepository.findById(saved.getJobId()).orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); |
|||
Company company = companyRepository.findById(saved.getCompanyId()).orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); |
|||
if (TalentInvitation.ACCEPTED.equals(target)) { |
|||
Long conversationId = conversationService.createForAcceptedInvitation(saved, company.getOwnerUserId(), job); |
|||
if (invitationRepository.attachConversation(saved.getId(), userId, conversationId, saved.getVersion()) != 1) { |
|||
throw new BusinessException(ErrorCode.TALENT_INVITATION_CONCURRENT_UPDATE); |
|||
} |
|||
saved = invitationRepository.findByIdAndStudentUserId(saved.getId(), userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.TALENT_INVITATION_NOT_FOUND)); |
|||
} |
|||
historyRepository.save(TalentInvitationHistory.builder().invitationId(saved.getId()).action( |
|||
TalentInvitation.ACCEPTED.equals(target) ? TalentInvitationHistory.ACCEPTED : TalentInvitationHistory.REJECTED) |
|||
.actorUserId(userId).build()); |
|||
audit(userId, TalentInvitation.ACCEPTED.equals(target) ? "TALENT_INVITATION_ACCEPTED" : "TALENT_INVITATION_REJECTED", saved, context); |
|||
return toStudentResponse(saved, job, company); |
|||
} |
|||
|
|||
private boolean expireIfDue(TalentInvitation invitation, AuditContext context) { |
|||
LocalDateTime now = LocalDateTime.now(BUSINESS_ZONE); |
|||
return TalentInvitation.PENDING.equals(invitation.getStatus()) && !invitation.getExpiresAt().isAfter(now) |
|||
&& expiryService.expireIfDue(invitation.getId(), now, context); |
|||
} |
|||
|
|||
private boolean isBlocked(TalentInvitation invitation) { |
|||
return companyRepository.findById(invitation.getCompanyId()).map(company -> |
|||
blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(company.getOwnerUserId(), invitation.getStudentUserId()) |
|||
|| blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(invitation.getStudentUserId(), company.getOwnerUserId()) |
|||
).orElse(false); |
|||
} |
|||
|
|||
private TalentInvitationPageResponse<CompanyTalentInvitationResponse> page(List<CompanyTalentInvitationResponse> items, Page<?> p) { |
|||
return TalentInvitationPageResponse.<CompanyTalentInvitationResponse>builder().items(items).total(p.getTotalElements()) |
|||
.page(p.getNumber()).size(p.getSize()).totalPages(p.getTotalPages()).build(); |
|||
} |
|||
|
|||
private TalentInvitationPageResponse<StudentTalentInvitationResponse> pageStudent(List<StudentTalentInvitationResponse> items, Page<?> p) { |
|||
return TalentInvitationPageResponse.<StudentTalentInvitationResponse>builder().items(items).total(p.getTotalElements()) |
|||
.page(p.getNumber()).size(p.getSize()).totalPages(p.getTotalPages()).build(); |
|||
} |
|||
|
|||
private CompanyTalentInvitationResponse toCompanyResponse(TalentInvitation inv, Job job) { |
|||
return CompanyTalentInvitationResponse.builder().invitationId(inv.getId()).jobId(inv.getJobId()) |
|||
.jobTitle(job == null ? null : job.getTitle()).candidateLabel("开放人才候选人") |
|||
.message(inv.getMessage()).status(inv.getStatus()).createdAt(inv.getCreatedAt()) |
|||
.expiresAt(inv.getExpiresAt()).decidedAt(inv.getDecidedAt()) |
|||
.conversationId(TalentInvitation.ACCEPTED.equals(inv.getStatus()) ? inv.getConversationId() : null).build(); |
|||
} |
|||
|
|||
private StudentTalentInvitationResponse toStudentResponse(TalentInvitation inv, Job job, Company company) { |
|||
return StudentTalentInvitationResponse.builder().invitationId(inv.getId()).companyId(inv.getCompanyId()) |
|||
.companyName(company == null ? null : company.getName()).jobId(inv.getJobId()) |
|||
.jobTitle(job == null ? null : job.getTitle()).jobCity(job == null ? null : job.getCity()) |
|||
.message(inv.getMessage()).status(inv.getStatus()).version(inv.getVersion()) |
|||
.createdAt(inv.getCreatedAt()).expiresAt(inv.getExpiresAt()).decidedAt(inv.getDecidedAt()) |
|||
.conversationId(TalentInvitation.ACCEPTED.equals(inv.getStatus()) ? inv.getConversationId() : null).build(); |
|||
} |
|||
|
|||
private StudentTalentInvitationResponse studentResponse(TalentInvitation inv) { |
|||
Job job = jobRepository.findById(inv.getJobId()).orElse(null); |
|||
Company company = companyRepository.findById(inv.getCompanyId()).orElse(null); |
|||
return toStudentResponse(inv, job, company); |
|||
} |
|||
|
|||
private Company requireVerifiedCompany(Long userId) { |
|||
User user = requireUser(userId); |
|||
if (!hasRole(user, User.Role.COMPANY)) throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
Company company = companyRepository.findByOwnerUserId(userId).orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); |
|||
if (!Boolean.TRUE.equals(company.getVerified())) throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
return company; |
|||
} |
|||
|
|||
private Job requireOwnedJob(Company company, Long jobId) { |
|||
Job job = jobRepository.findById(jobId).orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND)); |
|||
if (!company.getId().equals(job.getCompanyId())) throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
return job; |
|||
} |
|||
|
|||
private void requireStudent(Long userId) { |
|||
if (!hasRole(requireUser(userId), User.Role.STUDENT)) throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
|
|||
private User requireUser(Long userId) { |
|||
if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
return userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
} |
|||
|
|||
private boolean hasRole(User user, User.Role role) { |
|||
return user != null && ((user.getRoles() != null && user.getRoles().contains(role)) || user.getRole() == role); |
|||
} |
|||
|
|||
private void validatePage(int page, int size) { |
|||
if (page < 0 || size < 1 || size > 50) throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
|
|||
private CreateValues validateCreateRequest(CreateTalentInvitationRequest request) { |
|||
String key = request == null ? null : trim(request.getCandidateKey()); |
|||
String message = request == null ? null : trim(request.getMessage()); |
|||
String idempotency = request == null ? null : trim(request.getIdempotencyKey()); |
|||
if (key == null || key.length() > 128 || message == null || message.length() < 1 || message.length() > 300 |
|||
|| idempotency == null || idempotency.length() > 64) throw new BusinessException(ErrorCode.TALENT_INVITATION_CONTENT_INVALID); |
|||
try { UUID.fromString(idempotency); } catch (IllegalArgumentException ex) { throw new BusinessException(ErrorCode.TALENT_INVITATION_CONTENT_INVALID); } |
|||
return new CreateValues(key, message, idempotency); |
|||
} |
|||
|
|||
private String trim(String value) { if (value == null) return null; String v = value.trim(); return v.isEmpty() ? null : v; } |
|||
|
|||
private String sha256(String value) { |
|||
try { |
|||
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); |
|||
StringBuilder out = new StringBuilder(64); |
|||
for (byte b : bytes) out.append(String.format("%02x", b)); |
|||
return out.toString(); |
|||
} catch (Exception ex) { throw new IllegalStateException("SHA-256 unavailable", ex); } |
|||
} |
|||
|
|||
private void audit(Long userId, String action, TalentInvitation invitation, AuditContext context) { |
|||
auditService.log(userId, action, "TALENT_INVITATION", invitation.getId(), auditDetails(invitation, context), requestOnly(context)); |
|||
} |
|||
|
|||
private void auditRejected(Long userId, Long companyId, Long jobId, AuditContext context) { |
|||
securityAuditService.logRejected(userId, "TALENT_INVITATION_CREATE_REJECTED", "TALENT_INVITATION", null, |
|||
"{\"jobId\":" + jobId + ",\"status\":\"REJECTED\",\"requestId\":" |
|||
+ (context == null || context.getRequestId() == null ? "null" : quote(context.getRequestId())) + "}", |
|||
requestOnly(context)); |
|||
} |
|||
|
|||
private String auditDetails(TalentInvitation invitation, AuditContext context) { |
|||
return "{\"invitationId\":" + invitation.getId() + ",\"jobId\":" + invitation.getJobId() |
|||
+ ",\"status\":\"" + invitation.getStatus() + "\",\"requestId\":" |
|||
+ (context == null || context.getRequestId() == null ? "null" : quote(context.getRequestId())) + "}"; |
|||
} |
|||
|
|||
private AuditContext requestOnly(AuditContext context) { |
|||
return context == null ? null : AuditContext.builder().requestId(context.getRequestId()).build(); |
|||
} |
|||
|
|||
private String quote(String value) { return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; } |
|||
|
|||
private record CreateValues(String candidateKey, String message, String idempotencyKey) { } |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
package com.mydreamjob.talentinvitation.dto; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Value; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
@Value |
|||
@Builder |
|||
public class CompanyTalentInvitationResponse { |
|||
Long invitationId; |
|||
Long jobId; |
|||
String jobTitle; |
|||
String candidateLabel; |
|||
String message; |
|||
String status; |
|||
LocalDateTime createdAt; |
|||
LocalDateTime expiresAt; |
|||
LocalDateTime decidedAt; |
|||
Long conversationId; |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.mydreamjob.talentinvitation.dto; |
|||
|
|||
import jakarta.validation.constraints.NotBlank; |
|||
import jakarta.validation.constraints.Size; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class CreateTalentInvitationRequest { |
|||
@NotBlank @Size(max = 128) private String candidateKey; |
|||
@NotBlank @Size(max = 300) private String message; |
|||
@NotBlank @Size(max = 64) private String idempotencyKey; |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.mydreamjob.talentinvitation.dto; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Value; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
@Value |
|||
@Builder |
|||
public class StudentTalentInvitationResponse { |
|||
Long invitationId; |
|||
Long companyId; |
|||
String companyName; |
|||
Long jobId; |
|||
String jobTitle; |
|||
String jobCity; |
|||
String message; |
|||
String status; |
|||
Long version; |
|||
LocalDateTime createdAt; |
|||
LocalDateTime expiresAt; |
|||
LocalDateTime decidedAt; |
|||
Long conversationId; |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.mydreamjob.talentinvitation.dto; |
|||
|
|||
import jakarta.validation.constraints.NotBlank; |
|||
import jakarta.validation.constraints.NotNull; |
|||
import jakarta.validation.constraints.Pattern; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class TalentInvitationDecisionRequest { |
|||
@NotBlank @Pattern(regexp = "^(ACCEPT|REJECT)$") private String decision; |
|||
@NotNull private Long expectedVersion; |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
package com.mydreamjob.talentinvitation.dto; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Value; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Value |
|||
@Builder |
|||
public class TalentInvitationPageResponse<T> { |
|||
List<T> items; |
|||
long total; |
|||
int page; |
|||
int size; |
|||
int totalPages; |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
-- Flyway V40:企业人才邀约与授权沟通(功能版本 V42)。 |
|||
-- 仅新增邀约事实、状态历史和接受后的会话关联,不修改 V1-V39。 |
|||
|
|||
CREATE TABLE talent_invitations ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
|||
company_id BIGINT NOT NULL, |
|||
job_id BIGINT NOT NULL, |
|||
student_user_id BIGINT NOT NULL, |
|||
candidate_key_digest CHAR(64) NOT NULL, |
|||
message VARCHAR(300) NOT NULL, |
|||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', |
|||
idempotency_key VARCHAR(64) NOT NULL, |
|||
active_key VARCHAR(200) NULL, |
|||
conversation_id BIGINT NULL, |
|||
expires_at DATETIME NOT NULL, |
|||
decided_at DATETIME NULL, |
|||
version BIGINT NOT NULL DEFAULT 0, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
|||
CONSTRAINT fk_talent_invitations_company FOREIGN KEY (company_id) REFERENCES companies(id), |
|||
CONSTRAINT fk_talent_invitations_job FOREIGN KEY (job_id) REFERENCES jobs(id), |
|||
CONSTRAINT fk_talent_invitations_student FOREIGN KEY (student_user_id) REFERENCES users(id), |
|||
CONSTRAINT fk_talent_invitations_conversation FOREIGN KEY (conversation_id) REFERENCES conversations(id), |
|||
CONSTRAINT ck_talent_invitations_status CHECK (status IN ('PENDING', 'ACCEPTED', 'REJECTED', 'EXPIRED')), |
|||
CONSTRAINT uk_talent_invitations_idempotency UNIQUE (company_id, idempotency_key), |
|||
CONSTRAINT uk_talent_invitations_active UNIQUE (active_key), |
|||
CONSTRAINT uk_talent_invitations_conversation UNIQUE (conversation_id), |
|||
INDEX idx_talent_invitations_student_status_created (student_user_id, status, created_at), |
|||
INDEX idx_talent_invitations_company_status_created (company_id, status, created_at), |
|||
INDEX idx_talent_invitations_job_status_created (job_id, status, created_at), |
|||
INDEX idx_talent_invitations_status_expires (status, expires_at) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE talent_invitation_history ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
|||
invitation_id BIGINT NOT NULL, |
|||
action VARCHAR(30) NOT NULL, |
|||
actor_user_id BIGINT NULL, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
CONSTRAINT fk_talent_invitation_history_invitation FOREIGN KEY (invitation_id) |
|||
REFERENCES talent_invitations(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_talent_invitation_history_actor FOREIGN KEY (actor_user_id) |
|||
REFERENCES users(id), |
|||
CONSTRAINT ck_talent_invitation_history_action CHECK ( |
|||
action IN ('CREATED', 'ACCEPTED', 'REJECTED', 'EXPIRED') |
|||
), |
|||
INDEX idx_talent_invitation_history_invitation_created (invitation_id, created_at) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
@ -0,0 +1,56 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import com.mydreamjob.talentdiscovery.CandidateKeyGenerator; |
|||
import com.mydreamjob.talentdiscovery.TalentDiscoveryCandidateSource; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
class TalentInvitationCandidateResolverTest { |
|||
@Mock TalentDiscoveryCandidateSource source; |
|||
@Mock CandidateKeyGenerator keyGenerator; |
|||
|
|||
@Test |
|||
void resolvesOnlyCurrentKeyAndCandidateWithAtLeastOneSignal() { |
|||
TalentInvitationCandidateResolver resolver = new TalentInvitationCandidateResolver(source, keyGenerator); |
|||
LocalDateTime now = LocalDateTime.of(2026, 9, 7, 10, 0); |
|||
var enabled = new TalentDiscoveryCandidateSource.CandidateSeed(101L, "北京", "本科", 2026, "[]", now); |
|||
var noSignal = new TalentDiscoveryCandidateSource.CandidateSeed(102L, "上海", "本科", 2026, "[]", now); |
|||
when(source.findEligibleCandidates(9L)).thenReturn(List.of(enabled, noSignal)); |
|||
when(source.findPreferences(any())).thenReturn(Map.of(101L, |
|||
new TalentDiscoveryCandidateSource.PreferenceSignal(null, null, List.of("北京"), List.of(), List.of(), List.of(), false))); |
|||
when(source.findLatestRiasec(any())).thenReturn(Map.of()); |
|||
when(source.findLatestAbility(any(), eq(now))).thenReturn(Map.of()); |
|||
when(keyGenerator.generate(9L, 8L, 101L, now.toLocalDate())).thenReturn("key-101"); |
|||
|
|||
assertThat(resolver.resolve(9L, 8L, "key-101", now).matched()).isTrue(); |
|||
assertThat(resolver.resolve(9L, 8L, "key-102", now).matched()).isFalse(); |
|||
verify(keyGenerator, never()).generate(9L, 8L, 102L, now.toLocalDate()); |
|||
} |
|||
|
|||
@Test |
|||
void malformedOrCrossDayKeyDoesNotResolve() { |
|||
TalentInvitationCandidateResolver resolver = new TalentInvitationCandidateResolver(source, keyGenerator); |
|||
LocalDateTime now = LocalDateTime.of(2026, 9, 7, 10, 0); |
|||
var candidate = new TalentDiscoveryCandidateSource.CandidateSeed(101L, "北京", "本科", 2026, "[]", now); |
|||
when(source.findEligibleCandidates(9L)).thenReturn(List.of(candidate)); |
|||
when(source.findPreferences(any())).thenReturn(Map.of(101L, |
|||
new TalentDiscoveryCandidateSource.PreferenceSignal(null, null, List.of("北京"), List.of(), List.of(), List.of(), false))); |
|||
when(source.findLatestRiasec(any())).thenReturn(Map.of()); |
|||
when(source.findLatestAbility(any(), eq(now))).thenReturn(Map.of()); |
|||
when(keyGenerator.generate(9L, 8L, 101L, now.toLocalDate())).thenReturn("key-101"); |
|||
|
|||
assertThat(resolver.resolve(9L, 8L, "tampered", now).matched()).isFalse(); |
|||
verify(keyGenerator).generate(9L, 8L, 101L, now.toLocalDate()); |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.auth.JwtTokenProvider; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.talentinvitation.dto.TalentInvitationPageResponse; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class TalentInvitationControllerTest { |
|||
@Autowired MockMvc mockMvc; |
|||
@Autowired JwtTokenProvider jwtTokenProvider; |
|||
@MockBean TalentInvitationService service; |
|||
@MockBean RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Test |
|||
void anonymousInvitationList_isUnauthorized() throws Exception { |
|||
mockMvc.perform(get("/companies/me/talent-invitations")) |
|||
.andExpect(status().isUnauthorized()); |
|||
} |
|||
|
|||
@Test |
|||
void companyInvitationList_isReachableThroughAuthenticatedRoute() throws Exception { |
|||
when(service.listCompany(eq(100L), eq(0), eq(20))) |
|||
.thenReturn(TalentInvitationPageResponse.<com.mydreamjob.talentinvitation.dto.CompanyTalentInvitationResponse>builder() |
|||
.items(List.of()).total(0).page(0).size(20).totalPages(0).build()); |
|||
mockMvc.perform(get("/companies/me/talent-invitations").header("Authorization", token(100L, "COMPANY"))) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200)); |
|||
} |
|||
|
|||
@Test |
|||
void studentOnCompanyInvitationList_isForbiddenByServiceRoleGuard() throws Exception { |
|||
when(service.listCompany(eq(100L), eq(0), eq(20))).thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); |
|||
mockMvc.perform(get("/companies/me/talent-invitations").header("Authorization", token(100L, "STUDENT"))) |
|||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); |
|||
} |
|||
|
|||
private String token(Long userId, String role) { |
|||
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", role)); |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.Optional; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.*; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
class TalentInvitationExpiryServiceTest { |
|||
@Mock TalentInvitationRepository invitationRepository; |
|||
@Mock TalentInvitationHistoryRepository historyRepository; |
|||
@Mock AuditService auditService; |
|||
@InjectMocks TalentInvitationExpiryService service; |
|||
|
|||
@Test |
|||
void dueInvitationIsPersistedAsExpiredWithHistoryAndAudit() { |
|||
LocalDateTime now = LocalDateTime.of(2026, 9, 7, 10, 0); |
|||
TalentInvitation invitation = TalentInvitation.builder().id(9L).jobId(20L).status(TalentInvitation.PENDING) |
|||
.expiresAt(now.minusSeconds(1)).build(); |
|||
when(invitationRepository.findById(9L)).thenReturn(Optional.of(invitation)); |
|||
when(invitationRepository.expire(9L, now)).thenReturn(1); |
|||
|
|||
assertThat(service.expireIfDue(9L, now, null)).isTrue(); |
|||
verify(invitationRepository).expire(9L, now); |
|||
verify(historyRepository).save(argThat(h -> TalentInvitationHistory.EXPIRED.equals(h.getAction()))); |
|||
verify(auditService).log(isNull(), eq("TALENT_INVITATION_EXPIRED"), eq("TALENT_INVITATION"), eq(9L), contains("\"status\":\"EXPIRED\""), any()); |
|||
} |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.util.List; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
/** 只读验证 V42/V40 迁移在 H2 MODE=MySQL 中执行,且邀约历史不携带候选人敏感字段。 */ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class TalentInvitationPersistenceIntegrationTest { |
|||
@Autowired JdbcTemplate jdbcTemplate; |
|||
@MockBean RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Test |
|||
void v40Migration_createsInvitationTablesAndConstraints() { |
|||
assertThat(tableCount("TALENT_INVITATIONS")).isEqualTo(1); |
|||
assertThat(tableCount("TALENT_INVITATION_HISTORY")).isEqualTo(1); |
|||
String statusDefault = jdbcTemplate.queryForObject( |
|||
"SELECT COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='TALENT_INVITATIONS' AND COLUMN_NAME='STATUS'", |
|||
String.class); |
|||
assertThat(statusDefault).isIn("PENDING", "'PENDING'"); |
|||
Integer idempotencyUnique = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE TABLE_NAME='TALENT_INVITATIONS' " |
|||
+ "AND CONSTRAINT_NAME='UK_TALENT_INVITATIONS_IDEMPOTENCY' AND CONSTRAINT_TYPE='UNIQUE'", Integer.class); |
|||
assertThat(idempotencyUnique).isGreaterThanOrEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
void invitationHistory_containsNoStudentOrMessageColumns() { |
|||
List<String> columns = jdbcTemplate.queryForList( |
|||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='TALENT_INVITATION_HISTORY'", |
|||
String.class); |
|||
assertThat(columns).doesNotContain("STUDENT_USER_ID", "MESSAGE", "CANDIDATE_KEY", "PHONE", "EMAIL", "IP_ADDRESS", "USER_AGENT", "TOKEN", "PASSWORD"); |
|||
assertThat(columns).contains("INVITATION_ID", "ACTION", "ACTOR_USER_ID", "CREATED_AT"); |
|||
} |
|||
|
|||
private int tableCount(String table) { |
|||
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME=?", Integer.class, table); |
|||
} |
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import jakarta.persistence.Column; |
|||
import org.hibernate.annotations.JdbcTypeCode; |
|||
import org.hibernate.type.SqlTypes; |
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import java.lang.reflect.Field; |
|||
import java.util.Locale; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
/** |
|||
* V40 在真实 MySQL 中将 candidate_key_digest 建为 CHAR(64)。 |
|||
* 该测试锁定实体映射,避免后续把字段退回默认 VARCHAR 推断。 |
|||
*/ |
|||
class TalentInvitationSchemaMappingTest { |
|||
|
|||
@Test |
|||
void candidateKeyDigest_usesChar64Mapping() throws Exception { |
|||
Field field = TalentInvitation.class.getDeclaredField("candidateKeyDigest"); |
|||
JdbcTypeCode jdbcType = field.getAnnotation(JdbcTypeCode.class); |
|||
Column column = field.getAnnotation(Column.class); |
|||
|
|||
assertNotNull(jdbcType); |
|||
assertEquals(SqlTypes.CHAR, jdbcType.value()); |
|||
assertNotNull(column); |
|||
assertEquals(64, column.length()); |
|||
assertTrue(column.columnDefinition().toUpperCase(Locale.ROOT).contains("CHAR(64)")); |
|||
} |
|||
|
|||
@Test |
|||
void candidateKeyDigest_contract_isFixed64Hex() { |
|||
String digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; |
|||
assertEquals(64, digest.length()); |
|||
assertTrue(digest.matches("[0-9a-f]{64}")); |
|||
assertNotEquals(64, digest + "0"); |
|||
} |
|||
} |
|||
@ -0,0 +1,143 @@ |
|||
package com.mydreamjob.talentinvitation; |
|||
|
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.audit.SecurityAuditService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.recommendation.JobRecommendationProfile; |
|||
import com.mydreamjob.recommendation.JobRecommendationProfileRepository; |
|||
import com.mydreamjob.review.ReviewModerationService; |
|||
import com.mydreamjob.safety.BlockRelationRepository; |
|||
import com.mydreamjob.talentinvitation.dto.CreateTalentInvitationRequest; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.mockito.junit.jupiter.MockitoSettings; |
|||
import org.mockito.quality.Strictness; |
|||
|
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
class TalentInvitationServiceTest { |
|||
@Mock TalentInvitationRepository invitationRepository; |
|||
@Mock TalentInvitationHistoryRepository historyRepository; |
|||
@Mock UserRepository userRepository; |
|||
@Mock CompanyRepository companyRepository; |
|||
@Mock JobRepository jobRepository; |
|||
@Mock JobRecommendationProfileRepository profileRepository; |
|||
@Mock TalentInvitationCandidateResolver candidateResolver; |
|||
@Mock TalentInvitationConversationService conversationService; |
|||
@Mock TalentInvitationExpiryService expiryService; |
|||
@Mock BlockRelationRepository blockRelationRepository; |
|||
@Mock ReviewModerationService moderationService; |
|||
@Mock AuditService auditService; |
|||
@Mock SecurityAuditService securityAuditService; |
|||
@InjectMocks TalentInvitationService service; |
|||
|
|||
private final User companyUser = User.builder().id(1L).role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).build(); |
|||
private final Company company = Company.builder().id(10L).ownerUserId(1L).verified(true).build(); |
|||
private final Job publishedJob = Job.builder().id(20L).companyId(10L).status("PUBLISHED").title("后端工程师").build(); |
|||
|
|||
private void commonSetup() { |
|||
when(userRepository.findById(1L)).thenReturn(Optional.of(companyUser)); |
|||
when(companyRepository.findByOwnerUserId(1L)).thenReturn(Optional.of(company)); |
|||
when(companyRepository.findByIdForUpdate(10L)).thenReturn(Optional.of(company)); |
|||
when(jobRepository.findById(20L)).thenReturn(Optional.of(publishedJob)); |
|||
when(profileRepository.findByJobId(20L)).thenReturn(Optional.of(JobRecommendationProfile.builder().jobId(20L) |
|||
.enabled(true).reviewStatus(JobRecommendationProfile.APPROVED).build())); |
|||
when(candidateResolver.resolve(eq(10L), eq(20L), any(), any())) |
|||
.thenReturn(new TalentInvitationCandidateResolver.Resolution(101L, true)); |
|||
when(invitationRepository.findByCompanyIdAndIdempotencyKey(eq(10L), any())).thenReturn(Optional.empty()); |
|||
when(invitationRepository.findByCompanyIdAndJobIdAndStudentUserIdAndStatus(10L, 20L, 101L, TalentInvitation.PENDING)) |
|||
.thenReturn(Optional.empty()); |
|||
when(invitationRepository.existsInCooldown(eq(10L), eq(101L), any())).thenReturn(false); |
|||
when(invitationRepository.countJobCreatedBetween(eq(10L), eq(20L), any(), any())).thenReturn(0L); |
|||
when(invitationRepository.countCreatedBetween(eq(10L), any(), any())).thenReturn(0L); |
|||
when(moderationService.moderateCompanyReply(any())).thenReturn( |
|||
new ReviewModerationService.ModerationResult(TalentInvitation.PENDING, java.util.List.of(), java.math.BigDecimal.ZERO, null)); |
|||
when(invitationRepository.saveAndFlush(any())).thenAnswer(invocation -> { |
|||
TalentInvitation value = invocation.getArgument(0); |
|||
value.setId(55L); |
|||
return value; |
|||
}); |
|||
when(invitationRepository.attachConversation(anyLong(), anyLong(), anyLong(), anyLong())).thenReturn(1); |
|||
} |
|||
|
|||
private CreateTalentInvitationRequest request(String key, String message, String idempotency) { |
|||
CreateTalentInvitationRequest request = new CreateTalentInvitationRequest(); |
|||
request.setCandidateKey(key); request.setMessage(message); request.setIdempotencyKey(idempotency); |
|||
return request; |
|||
} |
|||
|
|||
@Test |
|||
void safeMessage_createsPendingInvitationAndHistory() { |
|||
commonSetup(); |
|||
var response = service.create(1L, 20L, |
|||
request("candidate-key", "欢迎了解岗位", "3c989a31-6774-4520-9700-e58416cc1845"), null); |
|||
assertThat(response.getInvitationId()).isEqualTo(55L); |
|||
assertThat(response.getStatus()).isEqualTo(TalentInvitation.PENDING); |
|||
verify(historyRepository).save(any(TalentInvitationHistory.class)); |
|||
verify(auditService).log(eq(1L), eq("TALENT_INVITATION_CREATED"), any(), eq(55L), any(), any()); |
|||
} |
|||
|
|||
@Test |
|||
void riskyMessage_isRejectedBeforeInsert() { |
|||
commonSetup(); |
|||
when(moderationService.moderateCompanyReply(any())).thenReturn( |
|||
new ReviewModerationService.ModerationResult("PENDING", java.util.List.of(ReviewModerationService.REASON_CONTACT_INFO), java.math.BigDecimal.valueOf(2), "MEDIUM")); |
|||
assertThatThrownBy(() -> service.create(1L, 20L, |
|||
request("candidate-key", "请加微信wx_12345", "3c989a31-6774-4520-9700-e58416cc1846"), null)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.extracting("errorCode").isEqualTo(ErrorCode.TALENT_INVITATION_CONTENT_INVALID.getCode()); |
|||
verify(invitationRepository, never()).saveAndFlush(any()); |
|||
verify(securityAuditService).logRejected(eq(1L), eq("TALENT_INVITATION_CREATE_REJECTED"), |
|||
eq("TALENT_INVITATION"), isNull(), contains("\"jobId\":20"), any()); |
|||
} |
|||
|
|||
@Test |
|||
void idempotentReplayReturnsOriginalBeforeCurrentJobStatusGate() { |
|||
commonSetup(); |
|||
publishedJob.setStatus("DRAFT"); |
|||
TalentInvitation existing = TalentInvitation.builder().id(77L).companyId(10L).jobId(20L) |
|||
.candidateKeyDigest(sha256("candidate-key")).message("欢迎了解岗位") |
|||
.status(TalentInvitation.PENDING).idempotencyKey("3c989a31-6774-4520-9700-e58416cc1847").build(); |
|||
when(invitationRepository.findByCompanyIdAndIdempotencyKey(10L, existing.getIdempotencyKey())).thenReturn(Optional.of(existing)); |
|||
var response = service.create(1L, 20L, |
|||
request("candidate-key", "欢迎了解岗位", existing.getIdempotencyKey()), null); |
|||
assertThat(response.getInvitationId()).isEqualTo(77L); |
|||
verifyNoInteractions(candidateResolver, moderationService); |
|||
} |
|||
|
|||
@Test |
|||
void nonPositiveJobId_isBadRequestBeforeAnyLookup() { |
|||
assertThatThrownBy(() -> service.create(1L, 0L, |
|||
request("candidate-key", "欢迎了解岗位", "3c989a31-6774-4520-9700-e58416cc1850"), null)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.extracting("errorCode").isEqualTo(ErrorCode.BAD_REQUEST.getCode()); |
|||
verifyNoInteractions(userRepository, companyRepository, jobRepository, invitationRepository); |
|||
} |
|||
|
|||
private String sha256(String value) { |
|||
try { |
|||
byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); |
|||
StringBuilder result = new StringBuilder(); |
|||
for (byte b : bytes) result.append(String.format("%02x", b)); |
|||
return result.toString(); |
|||
} catch (Exception e) { throw new AssertionError(e); } |
|||
} |
|||
} |
|||
Loading…
Reference in new issue