From 96b31133639e781fa96628bc835564cf0eed6dc7 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Mon, 7 Sep 2026 14:22:57 +0800 Subject: [PATCH] feat: deliver V42 enterprise talent invitations --- docs/backend-development-roadmap.md | 11 +- docs/enterprise-talent-invitation-api.md | 59 ++ docs/frontend-integration.md | 10 + docs/openapi-rc.json | 548 ++++++++++++++++++ ...v42-enterprise-talent-invitation-design.md | 480 +++++++++++++++ docs/v42-implementation-instruction.md | 439 ++++++++++++++ docs/verify_openapi.py | 33 +- .../common/exception/ErrorCode.java | 13 +- .../exception/GlobalExceptionHandler.java | 13 +- .../mydreamjob/company/CompanyRepository.java | 8 + .../com/mydreamjob/config/SecurityConfig.java | 4 + .../messaging/MessagingService.java | 42 +- .../talentinvitation/TalentInvitation.java | 48 ++ .../TalentInvitationCandidateResolver.java | 68 +++ .../TalentInvitationController.java | 89 +++ .../TalentInvitationConversationService.java | 49 ++ .../TalentInvitationExpiryService.java | 55 ++ .../TalentInvitationHistory.java | 28 + .../TalentInvitationHistoryRepository.java | 9 + .../TalentInvitationRepository.java | 56 ++ .../TalentInvitationService.java | 377 ++++++++++++ .../dto/CompanyTalentInvitationResponse.java | 21 + .../dto/CreateTalentInvitationRequest.java | 12 + .../dto/StudentTalentInvitationResponse.java | 24 + .../dto/TalentInvitationDecisionRequest.java | 12 + .../dto/TalentInvitationPageResponse.java | 16 + .../V40__enterprise_talent_invitations.sql | 48 ++ ...TalentInvitationCandidateResolverTest.java | 56 ++ .../TalentInvitationControllerTest.java | 61 ++ .../TalentInvitationExpiryServiceTest.java | 38 ++ ...tInvitationPersistenceIntegrationTest.java | 48 ++ .../TalentInvitationSchemaMappingTest.java | 39 ++ .../TalentInvitationServiceTest.java | 143 +++++ 33 files changed, 2940 insertions(+), 17 deletions(-) create mode 100644 docs/enterprise-talent-invitation-api.md create mode 100644 docs/v42-enterprise-talent-invitation-design.md create mode 100644 docs/v42-implementation-instruction.md create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitation.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationCandidateResolver.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationConversationService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationExpiryService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationHistory.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationHistoryRepository.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationRepository.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/CompanyTalentInvitationResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/CreateTalentInvitationRequest.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/StudentTalentInvitationResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/TalentInvitationDecisionRequest.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/TalentInvitationPageResponse.java create mode 100644 mydreamjob-server/src/main/resources/db/migration/V40__enterprise_talent_invitations.sql create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationCandidateResolverTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationExpiryServiceTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationPersistenceIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationSchemaMappingTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationServiceTest.java diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index d85578f..44cb88c 100644 --- a/docs/backend-development-roadmap.md +++ b/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 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | diff --git a/docs/enterprise-talent-invitation-api.md b/docs/enterprise-talent-invitation-api.md new file mode 100644 index 0000000..a43862c --- /dev/null +++ b/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 通知,不保存候选人联系方式或正文以外的敏感数据。 diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index dfc0bae..040471a 100644 --- a/docs/frontend-integration.md +++ b/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 接口 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index af96c5f..c08fc42 100644 --- a/docs/openapi-rc.json +++ b/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": { diff --git a/docs/v42-enterprise-talent-invitation-design.md b/docs/v42-enterprise-talent-invitation-design.md new file mode 100644 index 0000000..625b1a1 --- /dev/null +++ b/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,不泄露屏蔽方向。 + +上述决策均为编码硬边界;如真实代码与其存在关键冲突,开发者必须停止并报告,不得自行改变迁移号、接口、状态机或隐私范围。 diff --git a/docs/v42-implementation-instruction.md b/docs/v42-implementation-instruction.md new file mode 100644 index 0000000..686943a --- /dev/null +++ b/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。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 72664d5..54438c3 100644 --- a/docs/verify_openapi.py +++ b/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)") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index fefe2f7..47654d7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/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; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index d95f071..e8e11bf 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/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; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java index 31aca0d..b91caf9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyRepository.java +++ b/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 { Optional findByOwnerUserId(Long ownerUserId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select c from Company c where c.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + boolean existsByOwnerUserId(Long ownerUserId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index e57d380..eb4f259 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/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) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java index 86f72a3..3f15d97 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java +++ b/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> result = new ArrayList<>(); for (Conversation c : convs) { - Map v = toView(c); + Map v = toView(c, userId); v.put("unreadCount", countUnread(c.getId(), userId)); result.add(v); } @@ -70,7 +70,7 @@ public class MessagingService { public Map getConversation(Long conversationId, Long userId) { Conversation c = requireConversation(conversationId); assertMember(conversationId, userId); - Map v = toView(c); + Map 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> 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 v = toView(m); + Map 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 toView(Conversation c, Long currentUserId) { + if (c == null || !"TALENT_INVITATION".equals(c.getType())) return toView(c); + Map 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 toView(Message m) { Map 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 toView(Message m, Long currentUserId) { + if (m == null) return null; + return toView(m, currentUserId, isTalentInvitationConversation(m.getConversationId())); + } + + private Map toView(Message m, Long currentUserId, boolean talentInvitation) { + if (!talentInvitation) return toView(m); + Map 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); + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitation.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitation.java new file mode 100644 index 0000000..ec307ea --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationCandidateResolver.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationCandidateResolver.java new file mode 100644 index 0000000..e81388f --- /dev/null +++ b/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 candidates = candidateSource.findEligibleCandidates(companyId); + List candidateIds = candidates.stream().map(TalentDiscoveryCandidateSource.CandidateSeed::studentUserId).toList(); + Map preferences = safeMap(candidateSource.findPreferences(candidateIds)); + Map riasec = safeMap(candidateSource.findLatestRiasec(candidateIds)); + Map 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 preferences, + Map riasec, + Map 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 Map safeMap(Map value) { + return value == null ? Map.of() : value; + } + + public record Resolution(Long studentUserId, boolean matched) { + public static Resolution none() { return new Resolution(null, false); } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java new file mode 100644 index 0000000..55b76cd --- /dev/null +++ b/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 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> 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 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> 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 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 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"); } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationConversationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationConversationService.java new file mode 100644 index 0000000..225a1be --- /dev/null +++ b/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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationExpiryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationExpiryService.java new file mode 100644 index 0000000..bdf2e91 --- /dev/null +++ b/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 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 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("\"", "\\\"") + "\""; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationHistory.java new file mode 100644 index 0000000..3ccf852 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationHistoryRepository.java new file mode 100644 index 0000000..4e980d7 --- /dev/null +++ b/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 { + long countByInvitationId(Long invitationId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationRepository.java new file mode 100644 index 0000000..c81715b --- /dev/null +++ b/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 { + Optional findByCompanyIdAndIdempotencyKey(Long companyId, String idempotencyKey); + Optional findByIdAndCompanyId(Long id, Long companyId); + Optional findByIdAndStudentUserId(Long id, Long studentUserId); + Optional findByCompanyIdAndJobIdAndStudentUserIdAndStatus( + Long companyId, Long jobId, Long studentUserId, String status); + Page findByCompanyIdOrderByCreatedAtDescIdDesc(Long companyId, Pageable pageable); + Page 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); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java new file mode 100644 index 0000000..475e094 --- /dev/null +++ b/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 listCompany(Long userId, int page, int size) { + validatePage(page, size); + Company company = requireVerifiedCompany(userId); + Page invitations = invitationRepository.findByCompanyIdOrderByCreatedAtDescIdDesc( + company.getId(), PageRequest.of(page, size)); + List jobIds = invitations.getContent().stream().map(TalentInvitation::getJobId).distinct().toList(); + Map jobs = new HashMap<>(); + jobRepository.findAllById(jobIds).forEach(job -> jobs.put(job.getId(), job)); + List 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 listStudent(Long userId, int page, int size) { + requireStudent(userId); + validatePage(page, size); + Page invitations = invitationRepository.findByStudentUserIdOrderByCreatedAtDescIdDesc( + userId, PageRequest.of(page, size)); + List jobIds = invitations.getContent().stream().map(TalentInvitation::getJobId).distinct().toList(); + List companyIds = invitations.getContent().stream().map(TalentInvitation::getCompanyId).distinct().toList(); + Map jobs = new HashMap<>(); + Map companies = new HashMap<>(); + jobRepository.findAllById(jobIds).forEach(job -> jobs.put(job.getId(), job)); + companyRepository.findAllById(companyIds).forEach(company -> companies.put(company.getId(), company)); + List 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 page(List items, Page p) { + return TalentInvitationPageResponse.builder().items(items).total(p.getTotalElements()) + .page(p.getNumber()).size(p.getSize()).totalPages(p.getTotalPages()).build(); + } + + private TalentInvitationPageResponse pageStudent(List items, Page p) { + return TalentInvitationPageResponse.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) { } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/CompanyTalentInvitationResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/CompanyTalentInvitationResponse.java new file mode 100644 index 0000000..4b7ed07 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/CreateTalentInvitationRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/CreateTalentInvitationRequest.java new file mode 100644 index 0000000..ac98ce6 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/StudentTalentInvitationResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/StudentTalentInvitationResponse.java new file mode 100644 index 0000000..943f062 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/TalentInvitationDecisionRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/TalentInvitationDecisionRequest.java new file mode 100644 index 0000000..90c6ca1 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/TalentInvitationPageResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/dto/TalentInvitationPageResponse.java new file mode 100644 index 0000000..dc2afa4 --- /dev/null +++ b/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 { + List items; + long total; + int page; + int size; + int totalPages; +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V40__enterprise_talent_invitations.sql b/mydreamjob-server/src/main/resources/db/migration/V40__enterprise_talent_invitations.sql new file mode 100644 index 0000000..dc32d8d --- /dev/null +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationCandidateResolverTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationCandidateResolverTest.java new file mode 100644 index 0000000..29098a5 --- /dev/null +++ b/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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java new file mode 100644 index 0000000..132f2f2 --- /dev/null +++ b/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 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.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)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationExpiryServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationExpiryServiceTest.java new file mode 100644 index 0000000..6cc0f9a --- /dev/null +++ b/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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationPersistenceIntegrationTest.java new file mode 100644 index 0000000..6efc8df --- /dev/null +++ b/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 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 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationSchemaMappingTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationSchemaMappingTest.java new file mode 100644 index 0000000..235fbff --- /dev/null +++ b/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"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationServiceTest.java new file mode 100644 index 0000000..4c071e3 --- /dev/null +++ b/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); } + } +}