Browse Source

feat: deliver V42 enterprise talent invitations

feat/message-recall
Zhang Shun 2 months ago
parent
commit
96b3113363
  1. 11
      docs/backend-development-roadmap.md
  2. 59
      docs/enterprise-talent-invitation-api.md
  3. 10
      docs/frontend-integration.md
  4. 548
      docs/openapi-rc.json
  5. 480
      docs/v42-enterprise-talent-invitation-design.md
  6. 439
      docs/v42-implementation-instruction.md
  7. 33
      docs/verify_openapi.py
  8. 13
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  9. 13
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  10. 8
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java
  11. 4
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  12. 42
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java
  13. 48
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitation.java
  14. 68
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationCandidateResolver.java
  15. 89
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java
  16. 49
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationConversationService.java
  17. 55
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationExpiryService.java
  18. 28
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationHistory.java
  19. 9
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationHistoryRepository.java
  20. 56
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationRepository.java
  21. 377
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java
  22. 21
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/CompanyTalentInvitationResponse.java
  23. 12
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/CreateTalentInvitationRequest.java
  24. 24
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/StudentTalentInvitationResponse.java
  25. 12
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/TalentInvitationDecisionRequest.java
  26. 16
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/TalentInvitationPageResponse.java
  27. 48
      mydreamjob-server/src/main/resources/db/migration/V40__enterprise_talent_invitations.sql
  28. 56
      mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationCandidateResolverTest.java
  29. 61
      mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java
  30. 38
      mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationExpiryServiceTest.java
  31. 48
      mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationPersistenceIntegrationTest.java
  32. 39
      mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationSchemaMappingTest.java
  33. 143
      mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationServiceTest.java

11
docs/backend-development-roadmap.md

@ -26,11 +26,11 @@
| 项目 | 当前状态 | | 项目 | 当前状态 |
|---|---| |---|---|
| OpenAPI | **161 paths、199 operations**(V41 静态、运行时与校验脚本对账通过;学生 GET/PUT 共用一个 path) | | OpenAPI | **167 paths、205 operations**(V42 静态、运行时与校验脚本已对账) |
| Flyway | **V1–V39 已成功执行**;V39 为企业人才发现授权迁移(功能版本 V41,真实 MySQL/Docker 已验收) | | Flyway | **V1–V40 已成功执行**;V40 为企业人才邀约迁移(功能版本 V42,H2 与真实 MySQL 8.4 均已验证) |
| 全量测试 | V41 全量自动化测试 **1461/1461** 通过;真实 MySQL/Docker 黑盒 `PASS=37/FAIL=0`(V40 黑盒结论 `PASS=62/FAIL=0/SKIP=0` 保持有效) | | 全量测试 | V42 全量自动化测试 **1475/1475** 通过;真实 MySQL/Docker 黑盒 `PASS=76/FAIL=0`(含单岗位每日 20 次与公司每日 100 次限额) |
| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,已真实验收)**、**V36 四维能力测评(Flyway V35)**、**V37 防刷风险概览与运营规则配置(Flyway V36)**、**V38 四维能力测评有限接入候选评分(无新 Flyway、无新公网接口)**,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 | | 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,已真实验收)**、**V36 四维能力测评(Flyway V35)**、**V37 防刷风险概览与运营规则配置(Flyway V36)**、**V38 四维能力测评有限接入候选评分(无新 Flyway、无新公网接口)**,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
| 当前结论 | V35–V40 的既有验收结论保持不变。**V41 企业人才发现推荐已完成实现、自动化测试、静态/运行时 OpenAPI 对账与真实 MySQL/Docker 黑盒验收(PASS=37/FAIL=0),可建立 Git 检查点。** 当前 OpenAPI 为 161/199。 | | 当前结论 | V35–V41 的既有验收结论保持不变。**V42 企业人才邀约与授权沟通已完成代码、自动化测试、静态/运行时 OpenAPI 对账与真实 MySQL/Docker 黑盒验收,待 Git 检查点。** 当前 OpenAPI 为 167/205。 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 | | 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 2.0 模块状态分类 ### 2.0 模块状态分类
@ -39,7 +39,8 @@
|---|---| |---|---|
| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,PASS=39/FAIL=0/SKIP=1)**、**V36 四维能力测评(Flyway V35,PASS=93/FAIL=0/SKIP=0)**、**V37 防刷风险概览与运营规则配置(Flyway V36,PASS=76/FAIL=0/SKIP=0)**、**V38 四维能力测评有限接入候选评分(无新 Flyway/公网接口,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)**、**V39 测评驱动岗位推荐(Flyway V37,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)** | | 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,PASS=39/FAIL=0/SKIP=1)**、**V36 四维能力测评(Flyway V35,PASS=93/FAIL=0/SKIP=0)**、**V37 防刷风险概览与运营规则配置(Flyway V36,PASS=76/FAIL=0/SKIP=0)**、**V38 四维能力测评有限接入候选评分(无新 Flyway/公网接口,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)**、**V39 测评驱动岗位推荐(Flyway V37,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)** |
| 已完成、待真实 MySQL/Docker 复验 | — | | 已完成、待真实 MySQL/Docker 复验 | — |
| 真实黑盒已验收、待 Git 检查点 | **V40 企业评价管理与官方回复(Flyway V38,5 paths / 6 operations,PASS=62/FAIL=0/SKIP=0)**;**V41 企业人才发现推荐(Flyway V39,2 paths / 3 operations,PASS=37/FAIL=0)** | | 真实黑盒已验收、待 Git 检查点 | **V40 企业评价管理与官方回复(Flyway V38,5 paths / 6 operations,PASS=62/FAIL=0/SKIP=0)**;**V41 企业人才发现推荐(Flyway V39,2 paths / 3 operations,PASS=37/FAIL=0)**;**V42 企业人才邀约与授权沟通(Flyway V40,6 paths / 6 operations,PASS=76/FAIL=0;不写 candidate_dimension_scores)** |
| 自动化完成、待真实黑盒 | 无 |
| 已设计未编码 | 公平性检测、SHAP/规则解释、企业人才推荐 | | 已设计未编码 | 公平性检测、SHAP/规则解释、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | | 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |

59
docs/enterprise-talent-invitation-api.md

@ -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 通知,不保存候选人联系方式或正文以外的敏感数据。

10
docs/frontend-integration.md

@ -872,6 +872,16 @@ POST /admin/scoring/risk/events/{eventId}/actions 人工标记(ACKNOWLEDGE/DI
V41 服务端先对全部合格候选计算并全局排序,再切分页;`fitScore` 不下发。无可用岗位画像返回 `UNAVAILABLE`,无候选返回 `NO_ELIGIBLE_CANDIDATE`。OpenAPI 实际为 161 paths / 199 operations(学生 GET/PUT 共用一个 path)。 V41 服务端先对全部合格候选计算并全局排序,再切分页;`fitScore` 不下发。无可用岗位画像返回 `UNAVAILABLE`,无候选返回 `NO_ELIGIBLE_CANDIDATE`。OpenAPI 实际为 161 paths / 199 operations(学生 GET/PUT 共用一个 path)。
### V42 企业人才邀约与授权沟通
V42 新增 6 个 path / 6 个 operation,静态 OpenAPI 计数为 167 paths / 205 operations。已认证企业只能对本公司 `PUBLISHED` 且画像 `enabled=true && APPROVED` 的岗位使用 V41 `candidateKey` 创建邀约;请求只提交 `candidateKey`、站内 `message`(1–300 字)和 UUID `idempotencyKey`,不得提交学生 ID。服务端会重新校验授权、账号状态、当前企业全部历史投递、有效推荐信号和每日限额。
> 验收状态:自动化测试 1475/1475 通过;真实 MySQL/Docker 黑盒通过(`PASS=76 / FAIL=0`),运行时与静态 OpenAPI 均为 167 paths / 205 operations。
学生通过 `/profile/me/talent-invitations` 查看邀约,详情中的 `version` 必须原样带入决策请求的 `expectedVersion`。只有 `PENDING` 可 `ACCEPT` 或 `REJECT`;接受成功才返回 `conversationId` 并建立受限 `TALENT_INVITATION` 会话,拒绝或过期不建会话。邀约有效期 7 天,终态后同企业同学生冷却 30 天;重复幂等请求返回原邀约,不重复占用额度。
企业响应不包含 `candidateKey`、`studentUserId`、测评/评分详情、联系方式或内部原因;邀约会话的会话、消息列表和发送响应只返回 `CURRENT_USER`/`OTHER_PARTICIPANT` 相对身份,不暴露 `createdBy`/`senderUserId`。V42 不写 `candidate_dimension_scores`,不发送通知。
> 验收状态:自动化测试 1461/1461 通过;真实 MySQL/Docker 黑盒已通过(`PASS=37 / FAIL=0`)。黑盒确认最新低分测评优先于历史高分、同企业任一历史投递均排除、其他企业投递不误排,并验证全局排序在分页之前完成。 > 验收状态:自动化测试 1461/1461 通过;真实 MySQL/Docker 黑盒已通过(`PASS=37 / FAIL=0`)。黑盒确认最新低分测评优先于历史高分、同企业任一历史投递均排除、其他企业投递不误排,并验证全局排序在分页之前完成。
### 24.1 接口 ### 24.1 接口

548
docs/openapi-rc.json

@ -8683,6 +8683,266 @@
} }
] ]
} }
},
"/companies/me/jobs/{jobId}/talent-invitations": {
"post": {
"tags": [
"企业人才邀约"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "创建人才邀约",
"parameters": [
{
"name": "jobId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateTalentInvitationRequest"
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCompanyTalentInvitationResponse"
}
}
}
}
}
}
},
"/companies/me/talent-invitations": {
"get": {
"tags": [
"企业人才邀约"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "企业邀约列表",
"parameters": [
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 0,
"minimum": 0
}
},
{
"name": "size",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 20,
"minimum": 1,
"maximum": 50
}
}
],
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCompanyTalentInvitationPageResponse"
}
}
}
}
}
}
},
"/companies/me/talent-invitations/{invitationId}": {
"get": {
"tags": [
"企业人才邀约"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "企业邀约详情",
"parameters": [
{
"name": "invitationId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseCompanyTalentInvitationResponse"
}
}
}
}
}
}
},
"/profile/me/talent-invitations": {
"get": {
"tags": [
"企业人才邀约"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "学生收到的邀约列表",
"parameters": [
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 0,
"minimum": 0
}
},
{
"name": "size",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 20,
"minimum": 1,
"maximum": 50
}
}
],
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseStudentTalentInvitationPageResponse"
}
}
}
}
}
}
},
"/profile/me/talent-invitations/{invitationId}": {
"get": {
"tags": [
"企业人才邀约"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "学生邀约详情",
"parameters": [
{
"name": "invitationId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseStudentTalentInvitationResponse"
}
}
}
}
}
}
},
"/profile/me/talent-invitations/{invitationId}/decision": {
"post": {
"tags": [
"企业人才邀约"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "处理人才邀约",
"parameters": [
{
"name": "invitationId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TalentInvitationDecisionRequest"
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseStudentTalentInvitationResponse"
}
}
}
}
}
}
} }
}, },
"components": { "components": {
@ -14004,6 +14264,294 @@
"format": "date-time" "format": "date-time"
} }
} }
},
"CreateTalentInvitationRequest": {
"type": "object",
"required": [
"candidateKey",
"message",
"idempotencyKey"
],
"properties": {
"candidateKey": {
"type": "string",
"maxLength": 128
},
"message": {
"type": "string",
"minLength": 1,
"maxLength": 300
},
"idempotencyKey": {
"type": "string",
"format": "uuid",
"maxLength": 64
}
}
},
"TalentInvitationDecisionRequest": {
"type": "object",
"required": [
"decision",
"expectedVersion"
],
"properties": {
"decision": {
"type": "string",
"enum": [
"ACCEPT",
"REJECT"
]
},
"expectedVersion": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"CompanyTalentInvitationResponse": {
"type": "object",
"properties": {
"invitationId": {
"type": "integer",
"format": "int64"
},
"jobId": {
"type": "integer",
"format": "int64"
},
"jobTitle": {
"type": "string"
},
"candidateLabel": {
"type": "string"
},
"message": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"PENDING",
"ACCEPTED",
"REJECTED",
"EXPIRED"
]
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"expiresAt": {
"type": "string",
"format": "date-time"
},
"decidedAt": {
"type": "string",
"format": "date-time"
},
"conversationId": {
"type": "integer",
"format": "int64"
}
}
},
"StudentTalentInvitationResponse": {
"type": "object",
"properties": {
"invitationId": {
"type": "integer",
"format": "int64"
},
"companyId": {
"type": "integer",
"format": "int64"
},
"companyName": {
"type": "string"
},
"jobId": {
"type": "integer",
"format": "int64"
},
"jobTitle": {
"type": "string"
},
"jobCity": {
"type": "string"
},
"message": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"PENDING",
"ACCEPTED",
"REJECTED",
"EXPIRED"
]
},
"version": {
"type": "integer",
"format": "int64"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"expiresAt": {
"type": "string",
"format": "date-time"
},
"decidedAt": {
"type": "string",
"format": "date-time"
},
"conversationId": {
"type": "integer",
"format": "int64"
}
}
},
"TalentInvitationPageResponseCompanyTalentInvitationResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CompanyTalentInvitationResponse"
}
},
"total": {
"type": "integer",
"format": "int64"
},
"page": {
"type": "integer",
"format": "int32"
},
"size": {
"type": "integer",
"format": "int32"
},
"totalPages": {
"type": "integer",
"format": "int32"
}
}
},
"TalentInvitationPageResponseStudentTalentInvitationResponse": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StudentTalentInvitationResponse"
}
},
"total": {
"type": "integer",
"format": "int64"
},
"page": {
"type": "integer",
"format": "int32"
},
"size": {
"type": "integer",
"format": "int32"
},
"totalPages": {
"type": "integer",
"format": "int32"
}
}
},
"ApiResponseCompanyTalentInvitationResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/CompanyTalentInvitationResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"ApiResponseStudentTalentInvitationResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/StudentTalentInvitationResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"ApiResponseCompanyTalentInvitationPageResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/TalentInvitationPageResponseCompanyTalentInvitationResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"ApiResponseStudentTalentInvitationPageResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/TalentInvitationPageResponseStudentTalentInvitationResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
} }
}, },
"securitySchemes": { "securitySchemes": {

480
docs/v42-enterprise-talent-invitation-design.md

@ -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,不泄露屏蔽方向。
上述决策均为编码硬边界;如真实代码与其存在关键冲突,开发者必须停止并报告,不得自行改变迁移号、接口、状态机或隐私范围。

439
docs/v42-implementation-instruction.md

@ -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。

33
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 161, f"Expected 161 paths, got {len(d['paths'])}" assert len(d['paths']) == 167, f"Expected 167 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
required = [ required = [
@ -372,10 +372,37 @@ for f in ['studentUserId', 'candidateId', 'phone', 'email', 'resumeUrl', 'attemp
assert 'candidateKey' in candidate_schema and 'studentUserId' not in candidate_schema assert 'candidateKey' in candidate_schema and 'studentUserId' not in candidate_schema
print(" V41 exactly 2 paths / 3 operations; candidate card schema is redacted") print(" V41 exactly 2 paths / 3 operations; candidate card schema is redacted")
# 统计 operation 总数并校验 V41 基线 199 # V42 企业人才邀约:6 个唯一 path / 6 个 operation。
v42 = [
'/companies/me/jobs/{jobId}/talent-invitations',
'/companies/me/talent-invitations',
'/companies/me/talent-invitations/{invitationId}',
'/profile/me/talent-invitations',
'/profile/me/talent-invitations/{invitationId}',
'/profile/me/talent-invitations/{invitationId}/decision',
]
assert all(k in paths for k in v42), "One or more V42 paths are missing"
assert len(v42) == 6, f"Expected exactly 6 V42 paths, got {len(v42)}"
assert sum(len(paths[k]) for k in v42) == 6, "Expected exactly 6 V42 operations"
assert 'post' in paths[v42[0]], "V42 company invitation POST missing"
assert 'get' in paths[v42[1]] and 'get' in paths[v42[2]], "V42 company GET endpoints missing"
assert 'get' in paths[v42[3]] and 'get' in paths[v42[4]], "V42 student GET endpoints missing"
assert 'post' in paths[v42[5]], "V42 student decision endpoint missing"
for pt, method in [(v42[0], 'post'), (v42[5], 'post')]:
assert paths[pt][method].get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref'), f"{method.upper()} {pt} requestBody schema missing"
for pt, method in [(v42[0], 'post'), (v42[1], 'get'), (v42[2], 'get'), (v42[3], 'get'), (v42[4], 'get'), (v42[5], 'post')]:
assert response_ref(paths[pt][method]), f"{method.upper()} {pt} 200 schema missing"
for name in ('CompanyTalentInvitationResponse', 'StudentTalentInvitationResponse', 'TalentInvitationPageResponseCompanyTalentInvitationResponse', 'TalentInvitationPageResponseStudentTalentInvitationResponse'):
assert name in d['components']['schemas'], f"Missing V42 schema {name}"
company_invitation_schema = json.dumps(d['components']['schemas']['CompanyTalentInvitationResponse'])
for f in ['studentUserId', 'candidateKey', 'candidateKeyDigest', 'activeKey', 'moderationScore', 'reasonCodes', 'fitScore']:
assert f not in company_invitation_schema, f"V42 company invitation schema exposes {f}"
print(" V42 exactly 6 paths / 6 operations; invitation schemas are present and redacted")
# 统计 operation 总数并校验 V42 计数 205
operations = sum(len(methods) for methods in d['paths'].values()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) print('operations:', operations)
assert operations == 199, f"Expected 199 operations, got {operations}" assert operations == 205, f"Expected 205 operations, got {operations}"
print(" Offer advance endpoints all present") print(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") print(" Account deletion endpoints present (3 operations)")

13
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java

@ -216,7 +216,18 @@ public enum ErrorCode {
REVIEW_REPLY_CONCURRENT_UPDATE(10104, "review reply was modified concurrently"), REVIEW_REPLY_CONCURRENT_UPDATE(10104, "review reply was modified concurrently"),
REVIEW_REPLY_CONTENT_INVALID(10105, "invalid review reply content"), REVIEW_REPLY_CONTENT_INVALID(10105, "invalid review reply content"),
REVIEW_REPLY_IDEMPOTENCY_CONFLICT(10106, "review reply idempotency conflict"), REVIEW_REPLY_IDEMPOTENCY_CONFLICT(10106, "review reply idempotency conflict"),
REVIEW_REPLY_DECISION_INVALID(10107, "invalid review reply decision"); REVIEW_REPLY_DECISION_INVALID(10107, "invalid review reply decision"),
// 企业人才邀约 102xx
TALENT_INVITATION_NOT_FOUND(10201, "talent invitation not found"),
TALENT_INVITATION_CANDIDATE_UNAVAILABLE(10202, "candidate is not currently available for invitation"),
TALENT_INVITATION_ALREADY_ACTIVE(10203, "an active or cooling invitation already exists"),
TALENT_INVITATION_INVALID_STATE(10204, "talent invitation is not in a valid state"),
TALENT_INVITATION_EXPIRED(10205, "talent invitation has expired"),
TALENT_INVITATION_CONCURRENT_UPDATE(10206, "talent invitation was modified concurrently"),
TALENT_INVITATION_IDEMPOTENCY_CONFLICT(10207, "talent invitation idempotency conflict"),
TALENT_INVITATION_CONTENT_INVALID(10208, "invalid talent invitation content"),
TALENT_INVITATION_RATE_LIMITED(10209, "talent invitation rate limited");
private final int code; private final int code;
private final String message; private final String message;

13
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java

@ -77,7 +77,8 @@ public class GlobalExceptionHandler {
|| errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode() || errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode()
|| errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode() || errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode()
|| errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode() || errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode()
|| errorCode == ErrorCode.REVIEW_REPLY_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; || errorCode == ErrorCode.REVIEW_REPLY_NOT_FOUND.getCode()
|| errorCode == ErrorCode.TALENT_INVITATION_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND;
// 冲突 → 409 // 冲突 → 409
if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode()
|| errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode()
@ -109,7 +110,13 @@ public class GlobalExceptionHandler {
|| errorCode == ErrorCode.REVIEW_REPLY_ALREADY_EXISTS.getCode() || errorCode == ErrorCode.REVIEW_REPLY_ALREADY_EXISTS.getCode()
|| errorCode == ErrorCode.REVIEW_REPLY_INVALID_STATE.getCode() || errorCode == ErrorCode.REVIEW_REPLY_INVALID_STATE.getCode()
|| errorCode == ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE.getCode() || errorCode == ErrorCode.REVIEW_REPLY_CONCURRENT_UPDATE.getCode()
|| errorCode == ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT.getCode()) || errorCode == ErrorCode.REVIEW_REPLY_IDEMPOTENCY_CONFLICT.getCode()
|| errorCode == ErrorCode.TALENT_INVITATION_CANDIDATE_UNAVAILABLE.getCode()
|| errorCode == ErrorCode.TALENT_INVITATION_ALREADY_ACTIVE.getCode()
|| errorCode == ErrorCode.TALENT_INVITATION_INVALID_STATE.getCode()
|| errorCode == ErrorCode.TALENT_INVITATION_EXPIRED.getCode()
|| errorCode == ErrorCode.TALENT_INVITATION_CONCURRENT_UPDATE.getCode()
|| errorCode == ErrorCode.TALENT_INVITATION_IDEMPOTENCY_CONFLICT.getCode())
return HttpStatus.CONFLICT; return HttpStatus.CONFLICT;
// 越权 → 403(新增:企业-求职者建会话需匹配) // 越权 → 403(新增:企业-求职者建会话需匹配)
if (errorCode == ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode()) if (errorCode == ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode())
@ -221,6 +228,8 @@ public class GlobalExceptionHandler {
// 风险概览与运营规则配置:非法筛选 → 400(默认即 400,显式声明保证 9906) // 风险概览与运营规则配置:非法筛选 → 400(默认即 400,显式声明保证 9906)
if (errorCode == ErrorCode.RISK_INVALID_FILTER.getCode()) if (errorCode == ErrorCode.RISK_INVALID_FILTER.getCode())
return HttpStatus.BAD_REQUEST; return HttpStatus.BAD_REQUEST;
if (errorCode == ErrorCode.TALENT_INVITATION_RATE_LIMITED.getCode())
return HttpStatus.TOO_MANY_REQUESTS;
return HttpStatus.BAD_REQUEST; return HttpStatus.BAD_REQUEST;
} }

8
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java

@ -1,6 +1,10 @@
package com.mydreamjob.company; package com.mydreamjob.company;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional; import java.util.Optional;
@ -10,5 +14,9 @@ public interface CompanyRepository extends JpaRepository<Company, Long> {
Optional<Company> findByOwnerUserId(Long ownerUserId); Optional<Company> findByOwnerUserId(Long ownerUserId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Company c where c.id = :id")
Optional<Company> findByIdForUpdate(@Param("id") Long id);
boolean existsByOwnerUserId(Long ownerUserId); boolean existsByOwnerUserId(Long ownerUserId);
} }

4
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -81,6 +81,10 @@ public class SecurityConfig {
// V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验) // V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验)
.requestMatchers("/profile/me/talent-discovery").authenticated() .requestMatchers("/profile/me/talent-discovery").authenticated()
.requestMatchers("/companies/me/jobs/*/talent-recommendations").authenticated() .requestMatchers("/companies/me/jobs/*/talent-recommendations").authenticated()
// V42 企业人才邀约与学生决策(角色和租户归属由 Service 二次校验)
.requestMatchers("/companies/me/jobs/*/talent-invitations/**").authenticated()
.requestMatchers("/companies/me/talent-invitations/**").authenticated()
.requestMatchers("/profile/me/talent-invitations/**").authenticated()
// 企业公开岗位列表(匿名 GET) // 企业公开岗位列表(匿名 GET)
.requestMatchers(HttpMethod.GET, "/companies/*/jobs").permitAll() .requestMatchers(HttpMethod.GET, "/companies/*/jobs").permitAll()
// 企业公开主页(匿名 GET) // 企业公开主页(匿名 GET)

42
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java

@ -60,7 +60,7 @@ public class MessagingService {
convs.sort((a, b) -> b.getUpdatedAt().compareTo(a.getUpdatedAt())); convs.sort((a, b) -> b.getUpdatedAt().compareTo(a.getUpdatedAt()));
List<Map<String, Object>> result = new ArrayList<>(); List<Map<String, Object>> result = new ArrayList<>();
for (Conversation c : convs) { for (Conversation c : convs) {
Map<String, Object> v = toView(c); Map<String, Object> v = toView(c, userId);
v.put("unreadCount", countUnread(c.getId(), userId)); v.put("unreadCount", countUnread(c.getId(), userId));
result.add(v); result.add(v);
} }
@ -70,7 +70,7 @@ public class MessagingService {
public Map<String, Object> getConversation(Long conversationId, Long userId) { public Map<String, Object> getConversation(Long conversationId, Long userId) {
Conversation c = requireConversation(conversationId); Conversation c = requireConversation(conversationId);
assertMember(conversationId, userId); assertMember(conversationId, userId);
Map<String, Object> v = toView(c); Map<String, Object> v = toView(c, userId);
v.put("unreadCount", countUnread(conversationId, userId)); v.put("unreadCount", countUnread(conversationId, userId));
return v; return v;
} }
@ -85,7 +85,7 @@ public class MessagingService {
for (ConversationMember m : my) { for (ConversationMember m : my) {
if (memberRepository.existsByConversationIdAndUserId(m.getConversationId(), otherId)) { if (memberRepository.existsByConversationIdAndUserId(m.getConversationId(), otherId)) {
Conversation existing = conversationRepository.findById(m.getConversationId()).orElse(null); Conversation existing = conversationRepository.findById(m.getConversationId()).orElse(null);
if (existing != null && "DIRECT".equals(existing.getType())) return toView(existing); if (existing != null && "DIRECT".equals(existing.getType())) return toView(existing, userId);
} }
} }
// 新建会话:任意方向存在屏蔽 → 禁止新建 DIRECT 会话 // 新建会话:任意方向存在屏蔽 → 禁止新建 DIRECT 会话
@ -174,16 +174,16 @@ public class MessagingService {
conversationRepository.findById(req.getConversationId()).ifPresent(c -> { conversationRepository.findById(req.getConversationId()).ifPresent(c -> {
c.setUpdatedAt(msg.getCreatedAt()); conversationRepository.save(c); c.setUpdatedAt(msg.getCreatedAt()); conversationRepository.save(c);
}); });
return toView(msg); return toView(msg, userId);
} }
public Page<Map<String, Object>> listMessages(Long conversationId, Long userId, int page, int size) { public Page<Map<String, Object>> listMessages(Long conversationId, Long userId, int page, int size) {
requireConversation(conversationId); Conversation conversation = requireConversation(conversationId);
assertMember(conversationId, userId); assertMember(conversationId, userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST); if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST);
return messageRepository.findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(page, size)) return messageRepository.findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(page, size))
.map(m -> { .map(m -> {
Map<String, Object> v = toView(m); Map<String, Object> v = toView(m, userId, "TALENT_INVITATION".equals(conversation.getType()));
v.put("isRead", readRepository.existsByMessageIdAndReaderUserId(m.getId(), userId)); v.put("isRead", readRepository.existsByMessageIdAndReaderUserId(m.getId(), userId));
return v; return v;
}); });
@ -220,10 +220,40 @@ public class MessagingService {
return v; return v;
} }
/** V42 会话隐私映射:TALENT_INVITATION 不返回 createdBy(其中可能是另一方学生 ID)。 */
public Map<String, Object> toView(Conversation c, Long currentUserId) {
if (c == null || !"TALENT_INVITATION".equals(c.getType())) return toView(c);
Map<String, Object> v = new HashMap<>();
v.put("id", c.getId()); v.put("title", c.getTitle()); v.put("type", c.getType());
v.put("participantScope", "CURRENT_USER_AND_OTHER_PARTICIPANT");
v.put("createdAt", c.getCreatedAt()); v.put("updatedAt", c.getUpdatedAt());
return v;
}
public Map<String, Object> toView(Message m) { public Map<String, Object> toView(Message m) {
Map<String, Object> v = new HashMap<>(); Map<String, Object> v = new HashMap<>();
v.put("id", m.getId()); v.put("conversationId", m.getConversationId()); v.put("id", m.getId()); v.put("conversationId", m.getConversationId());
v.put("senderUserId", m.getSenderUserId()); v.put("content", m.getContent()); v.put("createdAt", m.getCreatedAt()); v.put("senderUserId", m.getSenderUserId()); v.put("content", m.getContent()); v.put("createdAt", m.getCreatedAt());
return v; return v;
} }
/** V42 消息隐私映射:TALENT_INVITATION 仅返回相对发送者身份。 */
public Map<String, Object> toView(Message m, Long currentUserId) {
if (m == null) return null;
return toView(m, currentUserId, isTalentInvitationConversation(m.getConversationId()));
}
private Map<String, Object> toView(Message m, Long currentUserId, boolean talentInvitation) {
if (!talentInvitation) return toView(m);
Map<String, Object> v = new HashMap<>();
v.put("id", m.getId()); v.put("conversationId", m.getConversationId());
v.put("senderScope", Objects.equals(m.getSenderUserId(), currentUserId) ? "CURRENT_USER" : "OTHER_PARTICIPANT");
v.put("content", m.getContent()); v.put("createdAt", m.getCreatedAt());
return v;
}
private boolean isTalentInvitationConversation(Long conversationId) {
return conversationId != null && conversationRepository.findById(conversationId)
.map(c -> "TALENT_INVITATION".equals(c.getType())).orElse(false);
}
} }

48
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitation.java

@ -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;
}

68
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationCandidateResolver.java

@ -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); }
}
}

89
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java

@ -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"); }
}

49
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationConversationService.java

@ -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();
}
}

55
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationExpiryService.java

@ -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("\"", "\\\"") + "\"";
}
}

28
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationHistory.java

@ -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;
}

9
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationHistoryRepository.java

@ -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);
}

56
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationRepository.java

@ -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);
}

377
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java

@ -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) { }
}

21
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/CompanyTalentInvitationResponse.java

@ -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;
}

12
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/CreateTalentInvitationRequest.java

@ -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;
}

24
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/StudentTalentInvitationResponse.java

@ -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;
}

12
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/TalentInvitationDecisionRequest.java

@ -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;
}

16
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/TalentInvitationPageResponse.java

@ -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;
}

48
mydreamjob-server/src/main/resources/db/migration/V40__enterprise_talent_invitations.sql

@ -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;

56
mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationCandidateResolverTest.java

@ -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());
}
}

61
mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java

@ -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));
}
}

38
mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationExpiryServiceTest.java

@ -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());
}
}

48
mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationPersistenceIntegrationTest.java

@ -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);
}
}

39
mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationSchemaMappingTest.java

@ -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");
}
}

143
mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationServiceTest.java

@ -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…
Cancel
Save