Browse Source

feat: deliver V42 enterprise talent invitations

master
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) |
| Flyway | **V1–V39 已成功执行**;V39 为企业人才发现授权迁移(功能版本 V41,真实 MySQL/Docker 已验收) |
| 全量测试 | V41 全量自动化测试 **1461/1461** 通过;真实 MySQL/Docker 黑盒 `PASS=37/FAIL=0`(V40 黑盒结论 `PASS=62/FAIL=0/SKIP=0` 保持有效) |
| OpenAPI | **167 paths、205 operations**(V42 静态、运行时与校验脚本已对账) |
| Flyway | **V1–V40 已成功执行**;V40 为企业人才邀约迁移(功能版本 V42,H2 与真实 MySQL 8.4 均已验证) |
| 全量测试 | 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、看板等核心闭环 |
| 当前结论 | 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 模块状态分类
@ -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 检查点)** |
| 已完成、待真实 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/规则解释、企业人才推荐 |
| 未开始 | 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)。
### 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`)。黑盒确认最新低分测评优先于历史高分、同企业任一历史投递均排除、其他企业投递不误排,并验证全局排序在分页之前完成。
### 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": {
@ -14004,6 +14264,294 @@
"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": {

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'))
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']
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
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())
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(" 26 operations all present")
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_CONTENT_INVALID(10105, "invalid review reply content"),
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 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.APPEAL_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
if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode()
|| errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode()
@ -109,7 +110,13 @@ public class GlobalExceptionHandler {
|| errorCode == ErrorCode.REVIEW_REPLY_ALREADY_EXISTS.getCode()
|| errorCode == ErrorCode.REVIEW_REPLY_INVALID_STATE.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;
// 越权 → 403(新增:企业-求职者建会话需匹配)
if (errorCode == ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION.getCode())
@ -221,6 +228,8 @@ public class GlobalExceptionHandler {
// 风险概览与运营规则配置:非法筛选 → 400(默认即 400,显式声明保证 9906)
if (errorCode == ErrorCode.RISK_INVALID_FILTER.getCode())
return HttpStatus.BAD_REQUEST;
if (errorCode == ErrorCode.TALENT_INVITATION_RATE_LIMITED.getCode())
return HttpStatus.TOO_MANY_REQUESTS;
return HttpStatus.BAD_REQUEST;
}

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

@ -1,6 +1,10 @@
package com.mydreamjob.company;
import jakarta.persistence.LockModeType;
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 java.util.Optional;
@ -10,5 +14,9 @@ public interface CompanyRepository extends JpaRepository<Company, Long> {
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);
}

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

@ -81,6 +81,10 @@ public class SecurityConfig {
// V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验)
.requestMatchers("/profile/me/talent-discovery").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)
.requestMatchers(HttpMethod.GET, "/companies/*/jobs").permitAll()
// 企业公开主页(匿名 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()));
List<Map<String, Object>> result = new ArrayList<>();
for (Conversation c : convs) {
Map<String, Object> v = toView(c);
Map<String, Object> v = toView(c, userId);
v.put("unreadCount", countUnread(c.getId(), userId));
result.add(v);
}
@ -70,7 +70,7 @@ public class MessagingService {
public Map<String, Object> getConversation(Long conversationId, Long userId) {
Conversation c = requireConversation(conversationId);
assertMember(conversationId, userId);
Map<String, Object> v = toView(c);
Map<String, Object> v = toView(c, userId);
v.put("unreadCount", countUnread(conversationId, userId));
return v;
}
@ -85,7 +85,7 @@ public class MessagingService {
for (ConversationMember m : my) {
if (memberRepository.existsByConversationIdAndUserId(m.getConversationId(), otherId)) {
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 会话
@ -174,16 +174,16 @@ public class MessagingService {
conversationRepository.findById(req.getConversationId()).ifPresent(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) {
requireConversation(conversationId);
Conversation conversation = requireConversation(conversationId);
assertMember(conversationId, userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST);
return messageRepository.findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(page, size))
.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));
return v;
});
@ -220,10 +220,40 @@ public class MessagingService {
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) {
Map<String, Object> v = new HashMap<>();
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());
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