Browse Source

feat: gate recruiting operations on company verification

master
Zhang Shun 4 weeks ago
parent
commit
9c14abd498
  1. 163
      docs/phase-b-company-verification-gate-implementation-report.md
  2. 136
      docs/phase-b-company-verification-gate-independent-verification-20260923.md
  3. 17
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  4. 16
      mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java
  5. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java
  6. 18
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java
  7. 6
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java
  8. 3
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java
  9. 2
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java
  10. 4
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java
  11. 1
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  12. 1
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
  13. 41
      mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java
  14. 28
      mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java
  15. 27
      mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java
  16. 18
      mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreService.java
  17. 4
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
  18. 4
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java
  19. 31
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java
  20. 14
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java
  21. 16
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java
  22. 25
      mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java
  23. 8
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java
  24. 4
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java
  25. 4
      mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java
  26. 30
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java
  27. 17
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java
  28. 18
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  29. 13
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java
  30. 2
      mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java
  31. 8
      mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchServiceTest.java
  32. 10
      mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java
  33. 4
      mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java
  34. 2
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java
  35. 8
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java
  36. 2
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java
  37. 13
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java
  38. 6
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java
  39. 32
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java
  40. 24
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java
  41. 4
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java
  42. 2
      mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java
  43. 2
      mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java
  44. 27
      mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java
  45. 6
      mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java
  46. 4
      mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScorePersistenceIntegrationTest.java
  47. 4
      mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreServiceTest.java
  48. 47
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobServiceTest.java
  49. 14
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java
  50. 4
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java
  51. 4
      mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java
  52. 19
      mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java
  53. 156
      mydreamjob-server/src/test/java/com/mydreamjob/phaseb/PhaseBVerifiedCompanyGateContractTest.java
  54. 336
      mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGateMySqlBlackboxTest.java
  55. 193
      mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGatePersistenceIntegrationTest.java
  56. 49
      mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGuardTest.java
  57. 16
      mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationServiceTest.java

163
docs/phase-b-company-verification-gate-implementation-report.md

@ -0,0 +1,163 @@
# Phase B:已认证企业业务门禁实施报告
日期:2026-09-23
范围:仅 Phase B;未暂存、未提交、未推送、未部署。
## 结论
Phase B 门禁已在服务层实施并通过全量验证。`mvn test` 与 `mvn clean verify` 均为 **2880 / 0 failures / 0 errors / 0 skipped**;真实 MySQL/Testcontainers 用例实际运行。OpenAPI 检查为 **182 paths / 220 operations,All checks passed**。API 路径和 operation 集合未改,`docs/openapi-rc.json` 无差异。
本轮没有启动应用部署或连接生产 RabbitMQ。拒绝路径的事件/通知调用通过测试中的 mock `never()` 断言验证;本机 RabbitMQ 容器当时为停止状态,因此不报告未经测量的 broker 队列深度。没有数据库迁移,也没有新增业务状态字段。
## 1. 开工核查与基线
- 分支:`master`
- HEAD:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`(Phase A 已交付基线)
- 暂存区:空;`git diff --check`:exit 0
- 工作区:除本轮明确列出的改动外,原有历史未跟踪文件全部保留;未运行 `reset`、`restore`、`checkout`、`clean` 或任何目录级 `git add`。
- `git status` 曾将 `CandidateOverviewStatusPolicy.java` 与 `JobPreferenceScoreCalculator.java` 标成修改;逐文件 `git hash-object` 与 HEAD blob 相同,且 `git diff --quiet`,确认是 Windows stat 缓存假阳性,内容未改。
- 迁移库存最高为 **V46**;V1–V46 未改、无 V47。
- 上线健康接口可见版本仅为 `0.0.1-SNAPSHOT`,不能据此映射出精确线上 commit。Phase B 未部署,线上版本未改变。
- Docker 可用;最终全量验证中的 MySQL/Testcontainers 测试真实执行,Skipped=0。
## 2. 权威设计和门禁语义
以 `docs/company-onboarding-admin-user-management-design.md` §7 及独立评审为准:企业认证事实只读 `companies.verified`;`users.is_verified` 不用于企业认证判断。
检查顺序保持为:现有认证/活动账号校验 → `user_roles` 的 COMPANY 身份 → 当前企业资料存在及 owner 归属 → 仅对受限招聘业务检查 `companies.verified=true`。认证门禁不能取代任何角色、归属、资源可见性判断。资源查询前优先校验调用者所属企业;按资源授权的入口仍保留其原归属检查,避免跨企业数据探测。
未认证、审核中、已拒绝均按 `companies.verified=false` 统一拒绝受限操作,固定响应 `COMPANY_NOT_VERIFIED` / code **5004** / HTTP **403** / 文案 `company not verified`。该错误码与已有数字码无冲突,HTTP 状态集中登记在 `ErrorHttpStatusRegistry`,没有在 Controller 中复制异常映射。
## 3. 端点库存与允许/禁止矩阵
逐个检查了 Controller 映射及其 Service 路径。下表中“COMPANY”表示现有 Controller/服务中的 COMPANY 身份要求仍保留;“own/资源归属”表示原企业 owner、岗位、投递或参与者校验仍执行。受限入口在服务端、候选人/Offer/面试等敏感查询或副作用之前执行认证门禁。
| 接口范围(HTTP method + path) | Controller → Service | Phase B 结果及既有校验 |
|---|---|---|
| `POST /jobs`;`PUT /jobs/{jobId}`;`PATCH /jobs/{jobId}/status` | `JobController` → `JobService` | 仍要求 COMPANY 和本企业岗位归属。创建 DRAFT、编辑自己的 DRAFT 允许;已发布内容编辑以及向 `PUBLISHED` 转换要求认证。将自己已发布岗位转为 `CLOSED` 仍允许,保留安全下架能力和旧状态语义。跨企业岗位仍按旧归属错误拒绝。公开 `GET /jobs`、`GET /jobs/search`、`GET /jobs/public/{jobId}`、`GET /companies/{companyId}/jobs` 不改变。 |
| `GET /company/applications`;`PATCH /applications/{id}/status`;`GET /company/candidates`;`GET /company/candidates/{applicationId}`;`GET /company/candidates/{applicationId}/resume`;`GET /applications/{id}/timeline`(企业分支) | `ApplicationController` → `ApplicationService` / `CompanyResumeAccessPolicy` | COMPANY、公司/岗位归属与既有可见性校验保留。企业认证先于敏感投递/候选人查询;下载门禁先于简历存储读取。拒绝时不读文件;学生本人的投递/timeline 分支不加企业门禁。 |
| `GET /company/candidates/{applicationId}/scoring`;`GET /company/scoring/dashboard/{overview,job-rankings,job-rankings/{jobId},candidate-rankings,candidate-rankings/{applicationId},trend}` | `CandidateScoringController`、`CompanyScoringDashboardController` → scoring services | 企业评分/候选人评分属于候选人敏感数据,所有公司入口要求 verified;原公司归属与管理员分支保持不变。 |
| `GET/POST/PUT/DELETE /applications/{applicationId}/feedback` | `CandidateFeedbackController` → `CandidateFeedbackService` | 企业侧 feedback 先校验公司归属及认证,再访问投递或写反馈;学生自己的反馈路径/权限保持原语义。 |
| `GET /companies/me/jobs/{jobId}/talent-recommendations`;`POST /companies/me/jobs/{jobId}/talent-invitations`;`GET /companies/me/talent-invitations`;`GET /companies/me/talent-invitations/{invitationId}` | `TalentDiscoveryController`、`TalentInvitationController` → respective services | 企业身份、岗位/邀请归属后要求认证;门禁先于候选人匹配、资料加载及邀请写入/通知。`/profile/me/talent-discovery` 与求职者邀请列表/决定接口仍为求职者自身功能。 |
| `POST/GET /applications/{applicationId}/interviews`;`GET /interviews/me`;`GET/PUT /interviews/{interviewId}`;`POST /interviews/{interviewId}/cancel`、`/complete`;`GET /interviews/{interviewId}/timeline` | `InterviewController` → `InterviewService` | 企业安排、修改、取消、完成及查看面试时先检查企业认证及原面试/投递归属;求职者查看和回应自己的面试保留原路径与行为。 |
| `PUT/GET /interviews/{interviewId}/score`;`POST /interviews/{interviewId}/score/submit`;`GET /interviews/{interviewId}/score/history` | `InterviewScoreController` → `InterviewScoreService` | 企业侧评分读写要求认证,并保留面试参与者/归属检查;求职者侧不被企业门禁误伤。 |
| `POST/GET /offers`;`GET/PUT /offers/{id}`;`PATCH /offers/{id}/status`;`GET /offers/{id}/timeline`;`POST /offers/{id}/remind`;`GET/POST /offers/{id}/counter-offers`;`PATCH /offers/{id}/counter-offers/{coId}/accept`、`/reject`;`GET /offers/statistics` | `OfferController` → `OfferService` | 对公司发起、读取、修改、发送/流转 Offer 及统计入口,在查询/写入/提醒副作用之前验证公司。原 application/offer/counter-offer 所有权检查保留。`GET /offers/compare` 为求职者接口,不改。 |
| `GET/PUT /companies/me/jobs/{jobId}/recommendation-profile`;`GET/PUT /companies/me/match-rule` | recommendation、`MatchRuleController` → services | 公司侧候选人推荐/匹配配置要求 verified,并保留 owner 校验;`/profile/me/match-rule` 等求职者配置不改。 |
| `PUT /applications/{id}/hr-suitability-score`;`GET /applications/{id}/match`;`POST /applications/{id}/conversation`;`GET/POST /conversations`;`GET /conversations/{id}`、`/{id}/messages`;`POST /conversations/messages`、`/{id}/read`、`/{conversationId}/messages/{messageId}/recall` | match、conversation、`MessagingController` → services | 企业候选人匹配、发起/读取招聘会话和消息均需 verified。会话成员、投递和消息归属校验保留;求职者自己的候选兴趣分/匹配路径不变。消息列表读取还使用明确加载 `user_roles` 的查询,避免 detached lazy roles 导致角色分支错误。 |
| `GET /notifications`、`/unread-count`;`POST /notifications/{id}/read`、`/read-all`(企业主体) | `NotificationController` → `NotificationService` | 企业用户侧通知查看/读状态操作要求认证,拒绝先于通知列表读取或读状态写入;其他主体语义保留。系统 worker/事件生产路径不套用户发起的企业门禁。 |
| `GET/POST/PUT /companies/me`;`GET /companies/me/onboarding`;`POST/GET /companies/me/verification` | `CompanyController`、`CompanyVerificationController` → company/onboarding/verification services | 未认证企业仍可查改自己的资料、查询聚合状态、提交和查询自己的认证申请。身份及 owner 关系照旧验证;认证审核状态机未改。 |
### 明确未加门禁的入口
- 公开岗位、公司资料和公开评价查询没有候选人隐私或企业招聘副作用,保持公开行为。
- `CompanyReviewManagementController` 的本企业公开岗位评价/回复用于企业声誉运营,不读取候选人敏感资料,也不是招聘动作;按 Phase B 设计范围不设认证门禁,既有公司归属照旧。
- `ApplicationMatchController` 中求职者的 candidate-interest-score、求职者岗位匹配与个人设置仍按原 STUDENT 路径处理。
- `OfferExpiryScheduler`/expiry service、匹配通知重试、候选评分 refresh worker、邀请过期清理和通知消费是系统维护/既有任务,不是未认证企业发起的招聘请求;逐一检查没有从这些后台入口调用受限用户写 API,因此不加企业认证门禁,避免误伤既有系统处理。
## 4. 实施内容和精确文件范围
新增服务层共用规则 `mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java`。它只依据传入的 `Company.verified` 判定;角色、活动状态、公司归属和资源归属仍由现有 service/guard 流程负责。
修改的生产文件(20 个):
- `application/ApplicationService.java`、`application/CompanyResumeAccessPolicy.java`
- `auth/UserRepository.java`
- `bidirectionalmatch/BidirectionalMatchService.java`、`MatchConversationService.java`、`MatchRuleService.java`
- `candidatescore/CandidateScoringEngineService.java`、`candidatescore/dashboard/CompanyScoringDashboardService.java`
- `common/exception/ErrorCode.java`、`ErrorHttpStatusRegistry.java`
- `feedback/CandidateFeedbackService.java`
- `interview/InterviewService.java`、`interviewscore/InterviewScoreService.java`
- `job/JobService.java`
- `messaging/MessagingService.java`、`notification/NotificationService.java`
- `offer/OfferService.java`
- `recommendation/JobRecommendationService.java`
- `talentdiscovery/TalentDiscoveryService.java`、`talentinvitation/TalentInvitationService.java`
新增测试类(3 个):
- `phaseb/PhaseBVerifiedCompanyGateContractTest.java`:静态端点—Service 覆盖映射,并锁定认证只看 `companies.verified`。
- `phaseb/VerifiedCompanyGuardTest.java`:verified / false / null / company missing 与固定业务码和 403 映射。
- `phaseb/VerifiedCompanyGatePersistenceIntegrationTest.java`:真实 HTTP `PATCH /jobs/{id}/status` 对 UNVERIFIED、PENDING、REJECTED 均返回 403/code 5004,并确认 DRAFT 仍未被发布;verified 企业发布成功。
另有既有 service/controller/persistence 测试被扩展:求职者、企业、管理员认证角色及旧行为回归;简历读取计数与租户边界;Job 发布;Offer、Interview、TalentInvitation 的拒绝副作用;feedback、matching、messaging、notification、scoring、resume、interview-score 的企业测试夹具现在明确使用已认证 Company,避免旧测试把默认 `verified=false` fixture 误当已认证企业。R01-B/R01-C/R01-E 错误映射和消息查询预算测试只调整了 Phase B 新码映射/现有 eager-role 读取所增加的固定查询基线。
被修改的既有测试文件(27 个):
- `application/ApplicationServiceTest.java`、`CompanyResumeAccessPolicyTest.java`、`CompanyResumeDownloadIntegrationTest.java`
- `bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java`、`BidirectionalMatchServiceTest.java`、`MatchConversationServiceTest.java`、`MatchRuleServiceTest.java`
- `candidatescore/CandidateScoringClockConsistencyTest.java`、`CandidateScoringEngineServiceTest.java`
- `common/exception/GlobalExceptionHandlerStatusContractTest.java`
- `common/r01b/ErrorHttpStatusRegistryTest.java`、`GlobalExceptionHandlerMappingParityTest.java`、`R01BIndependentHttpMappingParityTest.java`
- `common/r01e/R01EIndependentCrossDatabaseVerificationTest.java`、`R01EQueryBudgetAcceptanceTest.java`
- `feedback/CandidateFeedbackRefreshRegistrationTest.java`、`CandidateFeedbackServiceTest.java`
- `interview/InterviewServiceTest.java`
- `interviewscore/InterviewScoreControllerTest.java`、`InterviewScorePersistenceIntegrationTest.java`、`InterviewScoreServiceTest.java`
- `job/JobServiceTest.java`
- `messaging/MessageRecallHttpIntegrationTest.java`、`MessagingServiceTest.java`
- `notification/NotificationServiceTest.java`
- `offer/OfferServiceTest.java`
- `talentinvitation/TalentInvitationServiceTest.java`
没有修改 Controller mappings、DTO、`pom.xml`、Flyway、V1–V46、OpenAPI 文件、短信服务、Phase A 注册/验证码/Token/企业认证审核流程,也没有任何 Phase C 用户/角色管理代码。
## 5. TDD 失败证据与测试覆盖
- 先增加 `JobServiceTest.transitionStatus_unverifiedCompanyCannotPublish`,在尚无门禁时失败:期望 `BusinessException`,旧实现没有抛出。修复前失败日志保存在系统临时目录,不加入仓库。
- 对简历策略做单点变异(移除认证判断)后,`CompanyResumeAccessPolicyTest` 新增负向用例失败:未认证时仍访问 `applicationRepository.findById`,与“门禁先于候选人/简历数据读取”相违。源码恢复后,变异目标测试转绿;变异失败日志保存在系统临时目录。
- HTTP 集成覆盖 UNVERIFIED/PENDING/REJECTED 统一 403 及 verified 可发布。跨企业资源仍由原 service ownership tests 和既有集成回归覆盖。
- 简历测试包含 storage read probe,拒绝路径 `READ_CALLS=0`。Job 拒绝后状态仍 DRAFT。Offer、Interview、TalentInvitation 的未认证路径对投递/岗位/候选人/offer/interview/invitation/history/audit/notification/刷新等依赖均断言 `never()`,无业务写入或通知触发。
- 无效 Token、匿名、STUDENT、ADMIN 的 401/403 使用现有 SecurityFilterChain/Controller 回归;Phase B 没有放开匿名路由,也没有改 SecurityConfig。
本轮测试驱动过程中的非产品问题:部分旧单测严格 Mockito stubbing 仍 stub 旧的 `findById`;用户/角色集合在 service 离开事务后为 lazy,导致企业身份分支不能可靠判定。通过更新这些测试的 stub,并新增 `UserRepository.findByIdWithRoles` 明确加载 `user_roles` 修复;没有降低断言,也没有删角色检查。OpenAPI 初次调用时使用了不存在的 `docs/verify/_openapi.py` 路径;依据仓库文件清单改用 `docs/verify_openapi.py` 后全部通过,未改脚本。
## 6. 验证结果
| 检查 | 结果 |
|---|---|
| Phase B 定向(Contract + Guard + persistence + Job/Application/Resume/Offer/Interview/Invitation 及关联服务) | **189/189,F=0 E=0 S=0**;另外最新版 persistence HTTP 类 **2/2** 单独通过 |
| 全量 `mvn test` | **2880/2880,F=0 E=0 S=0,BUILD SUCCESS** |
| 最终 `mvn clean verify` | **2880/2880,F=0 E=0 S=0,BUILD SUCCESS**;MySQL/Testcontainers 实测执行 |
| `python ..\docs\verify_openapi.py`(从 server 模块目录) | **182 paths / 220 operations,All checks passed** |
| 数据库 schema/migration | V1–V46 无差异;无迁移需求;无 V47 |
| OpenAPI/API surface | Controller 路径和 operation 集合未改变;`docs/openapi-rc.json` 无差异 |
Phase B 新增用例共 **12**,全套从 2868 增至 2880。Docker 中 MySQL 8.4/Testcontainers 用例真实执行,Skipped=0。Hikari 在 Testcontainers 销毁/重建连接时输出连接已关闭的校验 WARN;两轮全量均以 0 errors 成功完成。
## 7. 副作用、安全与已知观测
- 认证事实静态断言确认 Guard 只读取 `Company.verified`,不读取 `User.isVerified`。
- 租户 A 即使已认证仍须通过原 owner/资源关系校验;新门禁不构成跨企业授权。服务先验证企业/权限,再查候选人敏感资源;简历 policy 在 `read(storagePath)` 之前失败关闭。
- 拒绝请求不会改变 DRAFT/Offer/Interview/Invitation 等业务记录;未调用 audit、notification、message/event publisher 或 scoring refresh 依赖。没有真实 RabbitMQ 运行,因此不声称 broker queue depth 被独立读取;拒绝路径的 publisher 无交互由测试逐项锁定。
- 新增代码和报告未包含凭据、JWT、密码、真实手机号、数据库连接秘密、容器动态口令或本机路径;测试数据使用非真实 fixture。修复前日志留在临时目录,未加入 Git。
- 没有通过设置 `verified=true` 绕过生产流程;“verified”测试场景仅在测试 fixture 中按显式场景构造,以验证认证企业原行为。
## 8. Git 结束状态
- HEAD 仍为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`,分支仍为 `master`。
- 暂存区为空,`git diff --check` exit 0;没有提交、推送、部署或合并。
- 新增生产组件和 Phase B 测试及本报告仍未跟踪;所有此前历史未跟踪文件原样保留。
- 工作区中 20 个已跟踪生产文件有 Phase B 改动;额外两个显示为修改的既有文件已通过 blob hash 证实内容与 HEAD 完全一致。
- 最终核查无 Java/Maven 残留进程,8080/8081 无监听;未停止或删除 Docker 容器/镜像。
Phase C 管理员用户与角色管理未进入;Phase B 未部署。下一步是独立代码复验,不是提交或部署。
## 9. 两项独立复验阻断修复(2026-09-23)
独立复验发现岗位发布门禁发生在岗位 ID/归属查询之后,可能通过业务码与 HTTP 状态差异探测跨企业岗位;另发现 `VerifiedCompanyGatePersistenceIntegrationTest` 使用全局 `TestAuthTokens` 数据源注册表,在组合运行时偶发无法签发会话。
- `JobService.transitionStatus` 对目标 `PUBLISHED` 先调用 `requireVerifiedCompany`,再查询岗位;未认证公司的有效 ID、其他公司 ID 和不存在 ID 均先得到固定认证拒绝,不触发岗位查询。
- 新增 `JobRepository.findByIdAndCompanyId`,并用于企业岗位读取、修改和状态操作。跨企业岗位和不存在岗位都按 `JOB_NOT_FOUND`/404 处理;`JobController.getJob` 改为单次归属限定查询,避免泄露资源存在性。
- 保留设计允许的未认证企业自有 DRAFT 岗位查看与编辑;已认证企业仍必须通过原企业归属条件,不获得跨企业访问权限。
- `VerifiedCompanyGatePersistenceIntegrationTest` 不再调用静态全局 `TestAuthTokens`,改由当前 Spring 测试上下文的真实 `AuthSessionService` 创建会话,Token 和会话记录落在当前事务数据源中,事务结束回滚,不依赖其他测试上下文的注册/销毁顺序。
- 先加 HTTP 回归再改生产代码时,新测试复现了原差异:未认证发布外企业岗位得到 400、读取/修改外企业岗位得到 400,而目标契约为认证门禁 403 或与不存在资源一致的 404。更新后的 HTTP 集成类 4/4 通过;岗位与认证边界组合 33/33 通过;原 10 类定向组合现为 **191/191**,F=0/E=0/S=0。
- 两条既有跨企业岗位 HTTP 断言改为校验 404/`JOB_NOT_FOUND`;夹具明确设成 VERIFIED,确保发布测试实际走到资源归属门禁而不是被未认证门禁短路。
修复后验证:
| 检查 | 结果 |
|---|---|
| `mvn test` | **2882/2882,F=0/E=0/S=0,BUILD SUCCESS** |
| `mvn clean verify` | **2882/2882,F=0/E=0/S=0,BUILD SUCCESS**;Testcontainers 真实执行 |
| `python ..\docs\verify_openapi.py` | **182 paths / 220 operations,All checks passed** |
两项阻断已修复并验证。本轮仍未暂存、提交、推送或部署;Phase B 后续仍需独立复验结论确认后才能进入相应检查点。

136
docs/phase-b-company-verification-gate-independent-verification-20260923.md

@ -0,0 +1,136 @@
# Phase B「已认证企业业务门禁」独立代码复验与真实 MySQL 黑盒验收
日期:2026-09-23
范围:Phase B 独立代码复验、缺陷修复后回归、MySQL 8.4 + HTTP 黑盒
结论:**通过;未暂存、未提交、未推送、未部署**
## 1. 结论摘要
独立复核确认,招聘类受限操作由 Service 层的共用企业认证门禁保护,判定依据为 `companies.verified`;角色、公司归属和资源归属仍分别执行。补充修复两处复验阻断后,新增真实 MySQL 8.4 + 随机端口 HTTP 黑盒测试通过;Phase B 定向回归 **208/208**,全量 `mvn test` 与 `mvn clean verify` 各 **2884/2884,Failures=0、Errors=0、Skipped=0**。OpenAPI 检查 **182 paths / 220 operations,All checks passed**。
本轮没有连接或修改服务器生产数据库,没有部署服务,没有修改迁移文件或 `docs/openapi-rc.json`。测试中 Redis 使用 mock,RabbitMQ listener 被关闭,因此本文不把本轮结论扩展为真实 Redis/RabbitMQ 黑盒验收。
## 2. Git 与工作区边界
- 分支:`master`
- HEAD:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`
- upstream:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`;ahead/behind `0/0`
- 暂存区:空;`git diff --check`:exit 0(仅有 Git 的 LF/CRLF 提示)
- 本地工作区存在既有未暂存改动与历史未跟踪材料。没有清理、覆盖、暂存或删除这些文件。
- Phase B 现有差异包含 **22 个已跟踪生产文件 + 新增 `VerifiedCompanyGuard.java`**,以及 **28 个已跟踪测试文件 + 4 个 Phase B 新测试类**。本报告和 Phase B 实施报告均未跟踪;本轮没有执行任何 Git 写入操作。
- V1–V46 未修改;未新增 V47;`docs/openapi-rc.json` 未修改。
- 未进入 Phase C。
## 3. 独立审查范围与规则
复核了 Phase B 实施报告、企业入驻设计及复审文档、`VerifiedCompanyGuard`、服务层调用点、岗位资源归属查询、错误码映射、认证边界测试,以及候选人/简历下载入口。
规则核对结果:
1. 未认证、审核中、已拒绝企业均以 `companies.verified=false` 拒绝受限招聘业务;未使用 `users.is_verified` 代替企业认证。
2. 认证门禁在 Service 业务路径执行,不只依赖 Controller;角色/企业归属/资源可见性仍单独保留。
3. 企业资料与认证申请、入驻状态等允许操作未被本轮认证门禁一并封锁。
4. 错误使用固定 `COMPANY_NOT_VERIFIED`(HTTP 403),经统一 `ErrorHttpStatusRegistry` 映射,不在 Controller 复制转换逻辑。
5. 岗位访问必须按企业范围查找;对外企业岗位记录不存在与不属于当前企业均按相同 `JOB_NOT_FOUND` / 404 处理。
## 4. 复验阻断及修复
### 4.1 岗位资源边界与检查顺序
复验期间确认岗位接口需要同时满足两个条件:未认证企业发布岗位时应被认证门禁拒绝;已认证企业也不能借岗位 ID 探测或操作其他企业的岗位。已调整为:
- 发布状态转换在读取目标岗位前先执行认证门禁,使未认证企业对目标 ID 不获得差异化响应;
- 已认证企业的岗位读取/更新使用 `findByIdAndCompanyId` 范围查询;跨企业与不存在资源统一返回 `JOB_NOT_FOUND` / 404;
- 企业自己的草稿维护及既有关闭行为按设计保留。
覆盖测试:`JobServiceTest`、`AuthenticationBoundaryIntegrationTest`,以及本报告新增的真实 MySQL + HTTP 黑盒。
### 4.2 持久化认证会话测试接线
原持久化测试通过静态 Token 测试助手建会话,不能确保会话写入当前 Testcontainers 数据源。测试现改为从 Spring 上下文调用 `AuthSessionService.createSession`,令令牌与当前测试数据库一致,避免测试仅凭伪造/错库 Token 得出授权结论。
## 5. 新增真实 MySQL + HTTP 黑盒
新增测试:`mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGateMySqlBlackboxTest.java`。
- Testcontainers 使用真实 `mysql:8.4`,创建隔离数据库 `phase_b_gate_blackbox`,正式执行 Flyway,并使用 Hibernate `ddl-auto=validate`。
- Spring Boot 完整应用在随机端口启动;通过 HTTP socket 请求 `/api/v1/...`,覆盖 Spring Security、Controller、Service、Repository 和真实 MySQL。
- 使用精确假号段夹具,测试结束按本类登记的主键清理;容器随后由 Testcontainers 回收。
结果 **2/2,F=0、E=0、S=0**:
1. UNVERIFIED、PENDING、REJECTED 三种 `companies.verified=false` 企业对岗位发布均返回 403 与 `COMPANY_NOT_VERIFIED`。每个拒绝前后比较 15 张表的稳定行快照,并核对岗位仍为 DRAFT。
2. 未认证企业读取候选人列表、候选人详情与简历下载均被拒绝。简历存储 `read()` 计数为 0;响应非 PDF;15 张表拒绝前后逐行快照相同。
3. VERIFIED 企业能够发布自己的岗位;不能读取、更新或发布另一企业岗位。外企业资源与不存在资源均返回相同 404/`JOB_NOT_FOUND`,跨企业拒绝前后数据库快照无变化。
4. 用户夹具的 `users.is_verified` 被有意设为 true,而公司认证状态分别按测试构造。这证明被测门禁取决于 `companies.verified`,而不是用户通用 verified 字段。
此黑盒未启动真实 Redis 或 RabbitMQ:RedisTemplate 为 mock,RabbitMQ listener 显式禁用。拒绝请求的数据库快照覆盖 `notifications`、`audit_logs`、`messages` 等表;但这不能替代 broker 队列深度检查,也不能证明真实 Redis 可用性。
## 6. 测试结果
| 验证 | 结果 |
|---|---:|
| Phase B 定向(门禁/角色/岗位/投递/简历/Offer/面试/邀约/消息等 12 类) | **208/208,F=0、E=0、S=0** |
| 真实 MySQL + HTTP 黑盒 | **2/2,F=0、E=0、S=0**(包含在定向结果内) |
| `mvn test` | **2884/2884,F=0、E=0、S=0** |
| `mvn clean verify` | **2884/2884,F=0、E=0、S=0** |
| 全量 Surefire XML | 277 个测试类;2884 项;F/E/S 均为 0 |
| `python ..\docs\verify_openapi.py` | **182 paths / 220 operations,All checks passed** |
MySQL/Testcontainers 在本机 Docker 上真实运行;新增黑盒类标记 `disabledWithoutDocker=false`,不会在 Docker 缺失时静默跳过。全量测试 `Skipped=0`。
## 7. 测试驱动器自身问题记录
初次黑盒执行出现 HTTP 404,是 `TestRestTemplate` 测试 URL 使用相对路径造成的 harness 错误;随后改为以 `@LocalServerPort` 构造 `127.0.0.1` 绝对 URL,黑盒 **2/2** 通过。测试初次编译还因 `DirtiesContext` 导入包路径错误失败,改为 Spring Test 正确包后通过。两项均为测试代码接线问题,不是产品缺陷;修正后定向与全量测试均已重跑通过。
## 8. 运行环境收尾
检查时 Docker 容器列表为空;8080/8081 无监听;无残留 `java` 或 `mvn` 进程。黑盒使用随机端口,并未占用上述端口。临时 MySQL 为 Testcontainers 隔离容器,没有触及本机开发库或服务器库。
## 9. 未执行/不在本轮结论内
- 未做生产服务器部署或真实公网 MySQL/Redis/RabbitMQ 验收。
- 未做运行时 OpenAPI 路径集合与静态 OpenAPI 的双集合比较;本轮运行的是仓库 OpenAPI 检查器,且 `docs/openapi-rc.json` 保持未改。
- 未覆盖到的每一个招聘入口仍以既有 Phase B 结构/服务测试清单和全量回归为依据;本轮新增真实黑盒聚焦岗位发布、候选人列表/详情/简历,以及跨企业岗位访问,不能解读为每个业务端点都由真实 HTTP 重放。
- 未实现 Phase C 管理员用户与角色管理;未修改短信服务;未伪造任何真实企业认证状态。
## 10. 最终判定
**Phase B 独立代码复验及真实 MySQL + HTTP 黑盒验收通过。** 两处复验阻断已修复;定向、全量测试及 OpenAPI 检查通过。当前仍是未暂存的本地工作区状态,后续如需提交,必须另行进行提交前精确文件范围审查;本轮没有暂存、提交、推送或部署。
## 11. 2026-09-29 精确暂存与隔离验证检查点
本节追加记录最新检查点,不覆盖第 1–10 节的历史执行结果与当时仓库状态。
### 11.1 批次边界与验证来源
- 当前分支为 `feat/phase-b-company-verification-gate`;基线 HEAD 为 `bcea6b02955f20f933572e8c8f2c85b1ad05843c`,包含此前单独本地提交的短信 P0 修复。
- Phase B 暂存范围为 **57 个文件:23 个生产文件、32 个测试文件、2 份报告**,没有删除文件。未纳入迁移、`pom.xml`、OpenAPI 文件、基础设施脚本或历史验收输出。
- 共用错误码与映射测试只暂存 Phase B 的 `COMPANY_NOT_VERIFIED` 及其 403 映射,未纳入统一注册/身份选择的新增错误码和接口。
- 验证目录由 HEAD 归档加暂存补丁生成,不读取原工作区未暂存的统一注册/身份选择实现。57 个暂存文件与验证快照按 Git 换行规范化后的 blob 逐项比较,**不一致数为 0**。原工作区其他批次改动与历史未跟踪文件均保留。
### 11.2 本次失败记录及纠正
1. 初次 32 类组合运行 **928 项,F=1、E=0、S=0**。简历下载测试的 OpenAPI 数量期望误混入未提交身份选择批次的 183/221,而当前隔离快照实际为 182/220。已从暂存区移除这两行无关 hunk,使用 HEAD 的 182/220 契约;原工作区 183/221 改动仍保留。没有修改生产实现或放宽授权断言。随后 12 类定向及全量验证通过。
2. 9 月 28 日首次全量尝试未完成,跨库测试报告 1 项容器启动错误,根因链包含 Docker 命名管道不存在(`docker_engine`)。该次没有 Maven 最终汇总,不记为通过,也不推算其全量测试总数。日志保留在系统临时验证目录,不纳入提交。Docker 恢复后确认服务版本 29.6.2,以原暂存代码快照重新执行完整 `clean verify`。
### 11.3 最新验证结果
| 验证 | 实测结果 |
|---|---:|
| Phase B 12 类定向测试 | **208 项,F=0、E=0、S=0,BUILD SUCCESS** |
| Phase B 真实 MySQL + HTTP 黑盒 | **2 项,F=0、E=0、S=0**(包含在定向与全量结果内) |
| 隔离暂存快照 `mvn clean verify` | **2889 项,F=0、E=0、S=0,BUILD SUCCESS** |
| 全量 Surefire XML 独立汇总 | **281 个测试类,2889 项,F/E/S 均为 0** |
| 短信错误次数真实 MySQL 并发测试 | **1 项,F=0、E=0、S=0**(基线短信批次回归) |
| 短信限流真实 Redis 测试 | **1 项,F=0、E=0、S=0**(基线短信批次回归) |
| 静态 OpenAPI 检查器 | **182 paths / 220 operations,All checks passed** |
| Spring 测试上下文运行时 OpenAPI 与静态集合比较 | **双方均 182/220,路径差集 0,路径/HTTP 方法 operation 差集 0** |
本次全量命令使用 `-Ds03.openapi.snapshot=<临时输出目录>` 留存既有简历测试通过 MockMvc 获取的运行时文档;比较以解析后的路径与 HTTP 方法集合为准,不只比较数量。该证据来自完整 Spring 测试上下文,**不是独立 JAR 或公网部署版本的 OpenAPI 验收**。此次没有另行宣称一次新的 `mvn test` 结果;`clean verify` 已实际执行全量测试并打包成功。
### 11.4 暂存安全扫描与结论边界
暂存新增行扫描发现 2 处手机号形状的既有测试夹具字面量、1 处明确仅用于临时 Testcontainers 数据库的测试口令;均不是实际用户或服务器凭据。疑似绝对路径命中均为路由数字正则或 loopback 随机端口 HTTP URL,不是本机文件路径。未发现真实 JWT、Bearer、邮箱、BCrypt 摘要、私钥、AWS key 或服务器连接密钥;原始运行日志和容器输出不暂存。
**Phase B 精确暂存与隔离验证检查点通过。** 本节完成后仅更新并暂存本报告,尚未创建 Phase B 本地提交,未推送、未部署,未进入 Phase C。真实 MySQL HTTP 黑盒的 Redis mock 与禁用 RabbitMQ listener 边界仍如第 5 节所述;本次全量中的真实 Redis 测试不能替代 Phase B 全链路三中间件或公网验收。

17
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java

@ -14,6 +14,9 @@ import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.notification.NotificationService; import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.profile.StudentProfile; import com.mydreamjob.profile.StudentProfile;
@ -44,6 +47,7 @@ public class ApplicationService {
private final ApplicationRepository applicationRepository; private final ApplicationRepository applicationRepository;
private final JobService jobService; private final JobService jobService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final AuditService auditService; private final AuditService auditService;
private final ApplicationStatusHistoryRepository historyRepository; private final ApplicationStatusHistoryRepository historyRepository;
private final NotificationService notificationService; private final NotificationService notificationService;
@ -132,12 +136,18 @@ public class ApplicationService {
/** 状态时间线 */ /** 状态时间线 */
public List<Map<String, Object>> getTimeline(Long applicationId, Long userId) { public List<Map<String, Object>> getTimeline(Long applicationId, Long userId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
Company actorCompany = null;
if (actor != null && actor.getRoles() != null && actor.getRoles().contains(User.Role.COMPANY)) {
actorCompany = VerifiedCompanyGuard.requireVerified(
companyRepository.findByOwnerUserId(userId).orElse(null));
}
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
// 仅学生本人或岗位所属企业可查看 // 仅学生本人或岗位所属企业可查看
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
boolean isStudent = app.getStudentUserId().equals(userId); boolean isStudent = app.getStudentUserId().equals(userId);
boolean isCompany = job.getCompanyId().equals(userId); boolean isCompany = actorCompany != null && job.getCompanyId().equals(actorCompany.getId());
if (!isStudent && !isCompany) throw new BusinessException(ErrorCode.FORBIDDEN); if (!isStudent && !isCompany) throw new BusinessException(ErrorCode.FORBIDDEN);
return historyRepository.findByApplicationIdOrderByCreatedAtAsc(applicationId) return historyRepository.findByApplicationIdOrderByCreatedAtAsc(applicationId)
@ -172,6 +182,7 @@ public class ApplicationService {
// ── 企业端 ──────────────────────────────────────────────── // ── 企业端 ────────────────────────────────────────────────
public List<Application> listCompanyApplications(Long companyId, Long jobId) { public List<Application> listCompanyApplications(Long companyId, Long jobId) {
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId); Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId)) if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -180,6 +191,7 @@ public class ApplicationService {
public Page<Application> listCompanyApplications(Long companyId, Long jobId, String status, int page, int size) { public Page<Application> listCompanyApplications(Long companyId, Long jobId, String status, int page, int size) {
if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST); if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST);
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId); Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId)) if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -197,6 +209,7 @@ public class ApplicationService {
@Transactional @Transactional
public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req, public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req,
com.mydreamjob.audit.AuditContext ctx) { com.mydreamjob.audit.AuditContext ctx) {
jobService.requireVerifiedCompany(companyId);
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
@ -265,6 +278,7 @@ public class ApplicationService {
public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status, public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status,
String keyword, String sort, String order, String keyword, String sort, String order,
int page, int size) { int page, int size) {
jobService.requireVerifiedCompany(companyId);
// 参数校验 // 参数校验
if (page < 0 || size < 1 || size > 100) if (page < 0 || size < 1 || size > 100)
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -349,6 +363,7 @@ public class ApplicationService {
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) { public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) {
jobService.requireVerifiedCompany(companyId);
// 单次 JOIN 查询:applications + jobs + student_profiles + users // 单次 JOIN 查询:applications + jobs + student_profiles + users
// 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询 // 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询
TypedQuery<Object[]> query = em.createQuery( TypedQuery<Object[]> query = em.createQuery(

16
mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java

@ -4,6 +4,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository; import com.mydreamjob.job.JobRepository;
import com.mydreamjob.privacy.ResumeVisibility; import com.mydreamjob.privacy.ResumeVisibility;
@ -69,10 +70,11 @@ public class CompanyResumeAccessPolicy {
if (company == null) { if (company == null) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }
VerifiedCompanyGuard.requireVerified(company);
Application application = applicationRepository.findById(applicationId) Application application = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!isVisibleToCompanyInternal(application, company.getId())) { if (!isVisibleToCompanyInternal(application, company)) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }
return new Decision(application.getStudentUserId()); return new Decision(application.getStudentUserId());
@ -88,17 +90,22 @@ public class CompanyResumeAccessPolicy {
if (studentUserId == null || companyId == null || applicationId == null) { if (studentUserId == null || companyId == null || applicationId == null) {
return false; return false;
} }
Company company = companyRepository.findById(companyId).orElse(null);
if (!VerifiedCompanyGuard.isVerified(company)) {
return false;
}
Application application = applicationRepository.findById(applicationId).orElse(null); Application application = applicationRepository.findById(applicationId).orElse(null);
if (application == null || !studentUserId.equals(application.getStudentUserId())) { if (application == null || !studentUserId.equals(application.getStudentUserId())) {
return false; return false;
} }
return isVisibleToCompanyInternal(application, companyId); return isVisibleToCompanyInternal(application, company);
} }
/** /**
* 核心判定:投递所属岗位必须属于该企业,再按学生隐私设置决定可见性。 * 核心判定:投递所属岗位必须属于该企业,再按学生隐私设置决定可见性。
*/ */
private boolean isVisibleToCompanyInternal(Application application, Long companyId) { private boolean isVisibleToCompanyInternal(Application application, Company company) {
Long companyId = company.getId();
Job job = jobRepository.findById(application.getJobId()).orElse(null); Job job = jobRepository.findById(application.getJobId()).orElse(null);
if (job == null || !companyId.equals(job.getCompanyId())) { if (job == null || !companyId.equals(job.getCompanyId())) {
// 岗位不存在或不属于当前企业:一律拒绝(含跨企业投递)。 // 岗位不存在或不属于当前企业:一律拒绝(含跨企业投递)。
@ -113,8 +120,7 @@ public class CompanyResumeAccessPolicy {
return application.getStatus() != null return application.getStatus() != null
&& APPLIED_COMPANY_VISIBLE_STATUSES.contains(application.getStatus()); && APPLIED_COMPANY_VISIBLE_STATUSES.contains(application.getStatus());
case VERIFIED_COMPANIES: case VERIFIED_COMPANIES:
Company company = companyRepository.findById(companyId).orElse(null); return VerifiedCompanyGuard.isVerified(company);
return company != null && Boolean.TRUE.equals(company.getVerified());
case PRIVATE: case PRIVATE:
default: default:
// 无设置记录、null 或 PRIVATE:按 PRIVATE 处理。 // 无设置记录、null 或 PRIVATE:按 PRIVATE 处理。

6
mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java

@ -2,6 +2,7 @@ package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@ -14,6 +15,11 @@ import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByPhone(String phone); Optional<User> findByPhone(String phone);
/** Load the role collection for service-layer authorization checks after repository return. */
@EntityGraph(attributePaths = "roles")
@Query("select u from User u where u.id = :id")
Optional<User> findByIdWithRoles(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE) @Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from User u where u.phone = :phone") @Query("select u from User u where u.phone = :phone")
Optional<User> findByPhoneForUpdate(@Param("phone") String phone); Optional<User> findByPhoneForUpdate(@Param("phone") String phone);

18
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java

@ -10,6 +10,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -78,10 +79,11 @@ public class BidirectionalMatchService {
Long userId, Long applicationId, int score, AuditContext ctx) { Long userId, Long applicationId, int score, AuditContext ctx) {
User user = loadUser(userId); User user = loadUser(userId);
assertRole(user, User.Role.COMPANY); assertRole(user, User.Role.COMPANY);
Company company = VerifiedCompanyGuard.requireVerified(
companyRepository.findByOwnerUserId(userId).orElse(null));
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Long companyId = companyRepository.findByOwnerUserId(userId) Long companyId = company.getId();
.map(Company::getId).orElse(null);
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
if (companyId == null || !companyId.equals(job.getCompanyId())) { if (companyId == null || !companyId.equals(job.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
@ -97,6 +99,10 @@ public class BidirectionalMatchService {
// ── 匹配状态查询(双方可读) ────────────────────────────── // ── 匹配状态查询(双方可读) ──────────────────────────────
public Map<String, Object> getMatchView(Long userId, Long applicationId) { public Map<String, Object> getMatchView(Long userId, Long applicationId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
if (actor != null && actor.getRoles() != null && actor.getRoles().contains(User.Role.COMPANY)) {
VerifiedCompanyGuard.requireVerified(companyRepository.findByOwnerUserId(userId).orElse(null));
}
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
@ -139,9 +145,11 @@ public class BidirectionalMatchService {
private void assertParticipant(Long userId, Application app, Job job) { private void assertParticipant(Long userId, Application app, Job job) {
if (app.getStudentUserId().equals(userId)) return; if (app.getStudentUserId().equals(userId)) return;
Long companyId = companyRepository.findByOwnerUserId(userId) Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
.map(Company::getId).orElse(null); if (company != null && company.getId().equals(job.getCompanyId())) {
if (companyId != null && companyId.equals(job.getCompanyId())) return; VerifiedCompanyGuard.requireVerified(company);
return;
}
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }

6
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java

@ -6,6 +6,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import com.mydreamjob.messaging.Conversation; import com.mydreamjob.messaging.Conversation;
@ -43,6 +44,8 @@ public class MatchConversationService {
@Transactional @Transactional
public Map<String, Object> createOrGetMatchConversation(Long userId, Long applicationId) { public Map<String, Object> createOrGetMatchConversation(Long userId, Long applicationId) {
Company ownedCompany = companyRepository.findByOwnerUserId(userId).orElse(null);
if (ownedCompany != null) VerifiedCompanyGuard.requireVerified(ownedCompany);
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
@ -56,8 +59,7 @@ public class MatchConversationService {
.map(Company::getOwnerUserId) .map(Company::getOwnerUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
} else { } else {
Long actorCompanyId = companyRepository.findByOwnerUserId(userId) Long actorCompanyId = ownedCompany == null ? null : ownedCompany.getId();
.map(Company::getId).orElse(null);
if (actorCompanyId == null || !actorCompanyId.equals(companyId)) { if (actorCompanyId == null || !actorCompanyId.equals(companyId)) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }

3
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java

@ -6,6 +6,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.security.UserRoleGuard; import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -33,6 +34,7 @@ public class MatchRuleService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Map<String, Object> getCompanyRule(Long userId) { public Map<String, Object> getCompanyRule(Long userId) {
Company company = requireCompany(userId); Company company = requireCompany(userId);
VerifiedCompanyGuard.requireVerified(company);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId()).orElse(null); CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId()).orElse(null);
int required = rule != null ? rule.getRequiredCandidateScore() int required = rule != null ? rule.getRequiredCandidateScore()
: CompanyMatchRule.DEFAULT_REQUIRED_CANDIDATE_SCORE; : CompanyMatchRule.DEFAULT_REQUIRED_CANDIDATE_SCORE;
@ -42,6 +44,7 @@ public class MatchRuleService {
@Transactional @Transactional
public Map<String, Object> updateCompanyRule(Long userId, int requiredCandidateScore) { public Map<String, Object> updateCompanyRule(Long userId, int requiredCandidateScore) {
Company company = requireCompany(userId); Company company = requireCompany(userId);
VerifiedCompanyGuard.requireVerified(company);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId()) CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId())
.orElseGet(() -> CompanyMatchRule.builder().companyId(company.getId()).build()); .orElseGet(() -> CompanyMatchRule.builder().companyId(company.getId()).build());
rule.setRequiredCandidateScore(requiredCandidateScore); rule.setRequiredCandidateScore(requiredCandidateScore);

2
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java

@ -7,6 +7,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -107,6 +108,7 @@ public class CandidateScoringEngineService {
public CompanyCandidateScoringResponse getCompanyScoring(Long companyUserId, Long applicationId) { public CompanyCandidateScoringResponse getCompanyScoring(Long companyUserId, Long applicationId) {
Company company = companyRepository.findByOwnerUserId(companyUserId) Company company = companyRepository.findByOwnerUserId(companyUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
VerifiedCompanyGuard.requireVerified(company);
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());

4
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java

@ -11,6 +11,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import com.mydreamjob.profile.StudentProfile; import com.mydreamjob.profile.StudentProfile;
@ -450,8 +451,9 @@ public class CompanyScoringDashboardService {
// ── 辅助 ──────────────────────────────────────────────────── // ── 辅助 ────────────────────────────────────────────────────
private Company requireCompany(Long userId) { private Company requireCompany(Long userId) {
return companyRepository.findByOwnerUserId(userId) Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
return VerifiedCompanyGuard.requireVerified(company);
} }
private void validatePage(int page, int size) { private void validatePage(int page, int size) {
if (page < 0 || size < 1 || size > 100) { if (page < 0 || size < 1 || size > 100) {

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

@ -37,6 +37,7 @@ public enum ErrorCode {
COMPANY_NOT_FOUND(5001, "company not found"), COMPANY_NOT_FOUND(5001, "company not found"),
COMPANY_OWNER_MISMATCH(5002, "company owner mismatch"), COMPANY_OWNER_MISMATCH(5002, "company owner mismatch"),
COMPANY_ALREADY_EXISTS(5003, "company already exists"), COMPANY_ALREADY_EXISTS(5003, "company already exists"),
COMPANY_NOT_VERIFIED(5004, "company not verified"),
// 学生资料 6xxx // 学生资料 6xxx
PROFILE_NOT_FOUND(6001, "profile not found"), PROFILE_NOT_FOUND(6001, "profile not found"),

1
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java

@ -111,6 +111,7 @@ public final class ErrorHttpStatusRegistry {
return new Builder() return new Builder()
.put(HttpStatus.FORBIDDEN, .put(HttpStatus.FORBIDDEN,
ErrorCode.APPLICATION_OWNER_MISMATCH, ErrorCode.APPLICATION_OWNER_MISMATCH,
ErrorCode.COMPANY_NOT_VERIFIED,
ErrorCode.FORBIDDEN, ErrorCode.FORBIDDEN,
ErrorCode.INTERVIEW_ACCESS_DENIED, ErrorCode.INTERVIEW_ACCESS_DENIED,
ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION, ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,

41
mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java

@ -0,0 +1,41 @@
package com.mydreamjob.company;
import com.mydreamjob.auth.User;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import java.util.function.Supplier;
/**
* 企业已认证门禁。角色与企业/资源归属仍由调用 Service 校验;该类只以
* {@code companies.verified} 为准判定企业招聘业务是否可继续。
*/
public final class VerifiedCompanyGuard {
private VerifiedCompanyGuard() {
}
/** 认证前的公司身份检查已通过后,调用本方法执行唯一的认证状态判定。 */
public static Company requireVerified(Company company) {
if (company == null) {
throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
}
if (!Boolean.TRUE.equals(company.getVerified())) {
throw new BusinessException(ErrorCode.COMPANY_NOT_VERIFIED);
}
return company;
}
/** 公司资料缺失、尚无审核申请或被拒绝,都统一按未认证企业拒绝。 */
public static boolean isVerified(Company company) {
return company != null && Boolean.TRUE.equals(company.getVerified());
}
/** 对学生等非企业调用方保持原语义;企业身份则失败关闭。 */
public static void requireVerifiedIfCompany(User actor, Supplier<Company> companySupplier) {
if (actor != null && actor.getRoles() != null
&& actor.getRoles().contains(User.Role.COMPANY)) {
requireVerified(companySupplier.get());
}
}
}

28
mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java

@ -6,6 +6,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.feedback.dto.CandidateFeedbackRequest; import com.mydreamjob.feedback.dto.CandidateFeedbackRequest;
import com.mydreamjob.feedback.dto.CandidateFeedbackResponse; import com.mydreamjob.feedback.dto.CandidateFeedbackResponse;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
@ -47,11 +48,16 @@ public class CandidateFeedbackService {
* 企业归属校验:先按当前登录 userId 解析其拥有的企业, * 企业归属校验:先按当前登录 userId 解析其拥有的企业,
* 再将企业 id 与 job.companyId 比较(userId 与 companyId 属不同命名空间,不可直接比较)。 * 再将企业 id 与 job.companyId 比较(userId 与 companyId 属不同命名空间,不可直接比较)。
*/ */
private void assertCompanyOwner(Long userId, Job job) { private Company assertCompanyOwner(Long userId, Job job) {
Company company = companyRepository.findByOwnerUserId(userId) Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
if (!company.getId().equals(job.getCompanyId())) if (!company.getId().equals(job.getCompanyId()))
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
return company;
}
private void requireOwnedCompanyVerifiedIfPresent(Long userId) {
companyRepository.findByOwnerUserId(userId).ifPresent(VerifiedCompanyGuard::requireVerified);
} }
private void assertStudentOwner(Long studentUserId, Application app) { private void assertStudentOwner(Long studentUserId, Application app) {
@ -63,9 +69,11 @@ public class CandidateFeedbackService {
@Transactional @Transactional
public CandidateFeedbackResponse createFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) { public CandidateFeedbackResponse createFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId); Application app = findApplication(applicationId);
Job job = findJob(app); Job job = findJob(app);
assertCompanyOwner(userId, job); Company company = assertCompanyOwner(userId, job);
VerifiedCompanyGuard.requireVerified(company);
if (feedbackRepository.existsByApplicationId(applicationId)) if (feedbackRepository.existsByApplicationId(applicationId))
throw new BusinessException(ErrorCode.DUPLICATE_FEEDBACK); throw new BusinessException(ErrorCode.DUPLICATE_FEEDBACK);
@ -86,14 +94,18 @@ public class CandidateFeedbackService {
// ── 查询 ────────────────────────────────────────────────── // ── 查询 ──────────────────────────────────────────────────
public CandidateFeedbackResponse getFeedback(Long userId, Long applicationId) { public CandidateFeedbackResponse getFeedback(Long userId, Long applicationId) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId); Application app = findApplication(applicationId);
Job job = findJob(app); Job job = findJob(app);
// 企业(按 userId 解析归属企业后比较企业 id)或学生任一匹配即可查看 // 企业(按 userId 解析归属企业后比较企业 id)或学生任一匹配即可查看
boolean isStudent = app.getStudentUserId().equals(userId); boolean isStudent = app.getStudentUserId().equals(userId);
boolean isCompany = companyRepository.findByOwnerUserId(userId) boolean isCompany = companyRepository.findByOwnerUserId(userId)
.map(c -> c.getId().equals(job.getCompanyId())) .map(c -> {
.orElse(false); if (!c.getId().equals(job.getCompanyId())) return false;
VerifiedCompanyGuard.requireVerified(c);
return true;
}).orElse(false);
if (!isCompany && !isStudent) if (!isCompany && !isStudent)
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
@ -106,9 +118,11 @@ public class CandidateFeedbackService {
@Transactional @Transactional
public CandidateFeedbackResponse updateFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) { public CandidateFeedbackResponse updateFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId); Application app = findApplication(applicationId);
Job job = findJob(app); Job job = findJob(app);
assertCompanyOwner(userId, job); Company company = assertCompanyOwner(userId, job);
VerifiedCompanyGuard.requireVerified(company);
CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND));
@ -126,9 +140,11 @@ public class CandidateFeedbackService {
@Transactional @Transactional
public void deleteFeedback(Long userId, Long applicationId) { public void deleteFeedback(Long userId, Long applicationId) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId); Application app = findApplication(applicationId);
Job job = findJob(app); Job job = findJob(app);
assertCompanyOwner(userId, job); Company company = assertCompanyOwner(userId, job);
VerifiedCompanyGuard.requireVerified(company);
CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND));

27
mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java

@ -10,6 +10,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.interview.dto.InterviewCancelRequest; import com.mydreamjob.interview.dto.InterviewCancelRequest;
import com.mydreamjob.interview.dto.InterviewCreateRequest; import com.mydreamjob.interview.dto.InterviewCreateRequest;
import com.mydreamjob.interview.dto.InterviewRespondRequest; import com.mydreamjob.interview.dto.InterviewRespondRequest;
@ -60,6 +61,7 @@ public class InterviewService {
public Interview createInterview(Long operatorUserId, Long applicationId, InterviewCreateRequest req, public Interview createInterview(Long operatorUserId, Long applicationId, InterviewCreateRequest req,
AuditContext ctx) { AuditContext ctx) {
Company company = requireCompany(operatorUserId); Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
@ -101,6 +103,7 @@ public class InterviewService {
// ── 查询某投递的面试列表 ───────────────────────────────────── // ── 查询某投递的面试列表 ─────────────────────────────────────
public List<Interview> listByApplication(Long applicationId, Long userId) { public List<Interview> listByApplication(Long applicationId, Long userId) {
requireVerifiedIfCompany(userId);
Application app = applicationRepository.findById(applicationId) Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
assertApplicationAccess(app, userId); assertApplicationAccess(app, userId);
@ -119,6 +122,7 @@ public class InterviewService {
boolean hasRange = from != null && to != null; boolean hasRange = from != null && to != null;
if (hasRole(user, User.Role.COMPANY)) { if (hasRole(user, User.Role.COMPANY)) {
VerifiedCompanyGuard.requireVerified(requireCompany(userId));
return listByCompanyUser(userId, status, from, to, pr); return listByCompanyUser(userId, status, from, to, pr);
} }
if (hasRole(user, User.Role.STUDENT)) { if (hasRole(user, User.Role.STUDENT)) {
@ -158,6 +162,7 @@ public class InterviewService {
// ── 查看面试详情 ──────────────────────────────────────────── // ── 查看面试详情 ────────────────────────────────────────────
public Interview getInterview(Long interviewId, Long userId) { public Interview getInterview(Long interviewId, Long userId) {
requireVerifiedIfCompany(userId);
Interview interview = requireInterview(interviewId); Interview interview = requireInterview(interviewId);
assertViewAccess(interview, userId); assertViewAccess(interview, userId);
return interview; return interview;
@ -169,6 +174,7 @@ public class InterviewService {
public Interview updateInterview(Long operatorUserId, Long interviewId, InterviewUpdateRequest req, public Interview updateInterview(Long operatorUserId, Long interviewId, InterviewUpdateRequest req,
AuditContext ctx) { AuditContext ctx) {
Company company = requireCompany(operatorUserId); Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId); Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -253,6 +259,7 @@ public class InterviewService {
public Interview cancelInterview(Long operatorUserId, Long interviewId, InterviewCancelRequest req, public Interview cancelInterview(Long operatorUserId, Long interviewId, InterviewCancelRequest req,
AuditContext ctx) { AuditContext ctx) {
Company company = requireCompany(operatorUserId); Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId); Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -297,6 +304,7 @@ public class InterviewService {
@Transactional @Transactional
public Interview completeInterview(Long operatorUserId, Long interviewId, String notes, AuditContext ctx) { public Interview completeInterview(Long operatorUserId, Long interviewId, String notes, AuditContext ctx) {
Company company = requireCompany(operatorUserId); Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId); Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -411,17 +419,30 @@ public class InterviewService {
private void assertViewAccess(Interview interview, Long userId) { private void assertViewAccess(Interview interview, Long userId) {
User user = loadUser(userId); User user = loadUser(userId);
if (hasRole(user, User.Role.STUDENT) && interview.getStudentUserId().equals(userId)) return; if (hasRole(user, User.Role.STUDENT) && interview.getStudentUserId().equals(userId)) return;
if (hasRole(user, User.Role.COMPANY) && interview.getCompanyUserId().equals(userId)) return; if (hasRole(user, User.Role.COMPANY) && interview.getCompanyUserId().equals(userId)) {
VerifiedCompanyGuard.requireVerified(requireCompany(userId));
return;
}
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
} }
private void requireVerifiedIfCompany(Long userId) {
User user = userRepository.findById(userId).orElse(null);
if (user != null && hasRole(user, User.Role.COMPANY)) {
VerifiedCompanyGuard.requireVerified(requireCompany(userId));
}
}
private void assertApplicationAccess(Application app, Long userId) { private void assertApplicationAccess(Application app, Long userId) {
User user = loadUser(userId); User user = loadUser(userId);
if (hasRole(user, User.Role.STUDENT) && app.getStudentUserId().equals(userId)) return; if (hasRole(user, User.Role.STUDENT) && app.getStudentUserId().equals(userId)) return;
if (hasRole(user, User.Role.COMPANY)) { if (hasRole(user, User.Role.COMPANY)) {
Job job = jobService.getJob(app.getJobId()); Job job = jobService.getJob(app.getJobId());
Company company = companyRepository.findByOwnerUserId(userId).orElse(null); Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
if (company != null && company.getId().equals(job.getCompanyId())) return; if (company != null && company.getId().equals(job.getCompanyId())) {
VerifiedCompanyGuard.requireVerified(company);
return;
}
} }
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
} }
@ -500,4 +521,4 @@ public class InterviewService {
private String getCompanyName(Company company) { private String getCompanyName(Company company) {
return company.getName() != null ? company.getName() : "企业"; return company.getName() != null ? company.getName() : "企业";
} }
} }

18
mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreService.java

@ -11,6 +11,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.interview.Interview; import com.mydreamjob.interview.Interview;
import com.mydreamjob.interview.InterviewRepository; import com.mydreamjob.interview.InterviewRepository;
import com.mydreamjob.interviewscore.dto.InterviewScoreHistoryItem; import com.mydreamjob.interviewscore.dto.InterviewScoreHistoryItem;
@ -133,8 +134,9 @@ public class InterviewScoreService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public InterviewScoreResponse getScore(Long userId, Long interviewId) { public InterviewScoreResponse getScore(Long userId, Long interviewId) {
Interview interview = requireInterview(interviewId);
User user = loadUser(userId); User user = loadUser(userId);
if (hasRole(user, User.Role.COMPANY)) requireVerifiedScoringCompany(userId);
Interview interview = requireInterview(interviewId);
if (hasRole(user, User.Role.COMPANY)) { if (hasRole(user, User.Role.COMPANY)) {
requireScoringCompany(interviewId, userId); requireScoringCompany(interviewId, userId);
@ -209,8 +211,9 @@ public class InterviewScoreService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<InterviewScoreHistoryItem> getHistory(Long userId, Long interviewId) { public List<InterviewScoreHistoryItem> getHistory(Long userId, Long interviewId) {
Interview interview = requireInterview(interviewId);
User user = loadUser(userId); User user = loadUser(userId);
if (hasRole(user, User.Role.COMPANY)) requireVerifiedScoringCompany(userId);
Interview interview = requireInterview(interviewId);
if (hasRole(user, User.Role.COMPANY)) { if (hasRole(user, User.Role.COMPANY)) {
requireScoringCompany(interviewId, userId); requireScoringCompany(interviewId, userId);
@ -238,9 +241,8 @@ public class InterviewScoreService {
if (!hasRole(user, User.Role.COMPANY)) { if (!hasRole(user, User.Role.COMPANY)) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }
Company company = requireVerifiedScoringCompany(userId);
Interview interview = requireInterview(interviewId); Interview interview = requireInterview(interviewId);
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
// 1. 当前企业的 owner 必须是该面试的创建企业 // 1. 当前企业的 owner 必须是该面试的创建企业
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -259,6 +261,12 @@ public class InterviewScoreService {
return company; return company;
} }
private Company requireVerifiedScoringCompany(Long userId) {
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
return VerifiedCompanyGuard.requireVerified(company);
}
private void requireCompleted(Interview interview) { private void requireCompleted(Interview interview) {
if (!"COMPLETED".equals(interview.getStatus())) { if (!"COMPLETED".equals(interview.getStatus())) {
throw new BusinessException(ErrorCode.INTERVIEW_NOT_COMPLETED); throw new BusinessException(ErrorCode.INTERVIEW_NOT_COMPLETED);
@ -397,4 +405,4 @@ public class InterviewScoreService {
String t = value.trim(); String t = value.trim();
return t.isEmpty() ? null : t; return t.isEmpty() ? null : t;
} }
} }

4
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java

@ -85,9 +85,7 @@ public class JobController {
@PathVariable Long jobId, @PathVariable Long jobId,
HttpServletRequest request) { HttpServletRequest request) {
Long companyId = resolveCompanyId(); Long companyId = resolveCompanyId();
// 验证归属后返回 Map<String, Object> view = jobService.toView(jobService.getCompanyJob(companyId, jobId));
jobService.assertOwnerOrThrow(companyId, jobId);
Map<String, Object> view = jobService.toView(jobService.getJob(jobId));
return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request)));
} }

4
mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java

@ -9,10 +9,14 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional;
@Repository @Repository
public interface JobRepository extends JpaRepository<Job, Long>, JpaSpecificationExecutor<Job> { public interface JobRepository extends JpaRepository<Job, Long>, JpaSpecificationExecutor<Job> {
/** 企业资源访问使用归属限定查询,避免取出其他企业的岗位后再区分归属。 */
Optional<Job> findByIdAndCompanyId(Long id, Long companyId);
List<Job> findByCompanyIdAndStatusOrderByCreatedAtDesc(Long companyId, String status); List<Job> findByCompanyIdAndStatusOrderByCreatedAtDesc(Long companyId, String status);
List<Job> findByStatusOrderByCreatedAtDesc(String status); List<Job> findByStatusOrderByCreatedAtDesc(String status);

31
mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java

@ -4,6 +4,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.dto.JobCreateRequest; import com.mydreamjob.job.dto.JobCreateRequest;
import com.mydreamjob.job.dto.JobSearchItem; import com.mydreamjob.job.dto.JobSearchItem;
import com.mydreamjob.job.dto.JobStatusRequest; import com.mydreamjob.job.dto.JobStatusRequest;
@ -75,7 +76,10 @@ public class JobService {
*/ */
@Transactional @Transactional
public Job updateJob(Long companyId, Long jobId, JobUpdateRequest req) { public Job updateJob(Long companyId, Long jobId, JobUpdateRequest req) {
Job job = assertOwnerOrThrow(companyId, jobId); Job job = getCompanyJob(companyId, jobId);
if (!JobStatus.DRAFT.name().equals(job.getStatus())) {
requireVerifiedCompany(companyId);
}
if (req.getTitle() != null) job.setTitle(req.getTitle()); if (req.getTitle() != null) job.setTitle(req.getTitle());
if (req.getDescription() != null) job.setDescription(req.getDescription()); if (req.getDescription() != null) job.setDescription(req.getDescription());
@ -93,10 +97,16 @@ public class JobService {
*/ */
@Transactional @Transactional
public Job transitionStatus(Long companyId, Long jobId, JobStatusRequest req) { public Job transitionStatus(Long companyId, Long jobId, JobStatusRequest req) {
Job job = assertOwnerOrThrow(companyId, jobId);
String current = job.getStatus();
String target = req.getStatus(); String target = req.getStatus();
// 未认证企业的发布请求必须在按 jobId 查询前拒绝,避免通过 400/404 差异探测岗位。
if (JobStatus.PUBLISHED.name().equals(target)) {
requireVerifiedCompany(companyId);
}
Job job = getCompanyJob(companyId, jobId);
String current = job.getStatus();
// 校验状态流转合法性 // 校验状态流转合法性
boolean valid = switch (current) { boolean valid = switch (current) {
case "DRAFT" -> "PUBLISHED".equals(target); case "DRAFT" -> "PUBLISHED".equals(target);
@ -115,6 +125,21 @@ public class JobService {
return jobRepository.save(job); return jobRepository.save(job);
} }
/** 用企业主键解析权威认证状态,供岗位与投递服务在已确认归属后执行统一门禁。 */
public Company requireVerifiedCompany(Long companyId) {
return VerifiedCompanyGuard.requireVerified(
companyRepository.findById(companyId).orElse(null));
}
/**
* 读取当前企业自己的岗位;对外统一将其他企业资源与不存在资源表示为 JOB_NOT_FOUND,
* 不向调用方泄露跨企业资源是否存在。
*/
public Job getCompanyJob(Long companyId, Long jobId) {
return jobRepository.findByIdAndCompanyId(jobId, companyId)
.orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND));
}
// ── 公开接口 ──────────────────────────────────────────────── // ── 公开接口 ────────────────────────────────────────────────
/** /**

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

@ -12,6 +12,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.messaging.dto.CreateConversationRequest; import com.mydreamjob.messaging.dto.CreateConversationRequest;
import com.mydreamjob.messaging.dto.MessagePageResponse; import com.mydreamjob.messaging.dto.MessagePageResponse;
import com.mydreamjob.messaging.dto.MessageResponse; import com.mydreamjob.messaging.dto.MessageResponse;
@ -68,6 +69,7 @@ public class MessagingService {
} }
public List<Map<String, Object>> listConversations(Long userId) { public List<Map<String, Object>> listConversations(Long userId) {
requireVerifiedIfCompany(userId);
List<ConversationMember> memberships = memberRepository.findByUserId(userId); List<ConversationMember> memberships = memberRepository.findByUserId(userId);
if (memberships.isEmpty()) return List.of(); if (memberships.isEmpty()) return List.of();
List<Long> convIds = memberships.stream().map(ConversationMember::getConversationId).toList(); List<Long> convIds = memberships.stream().map(ConversationMember::getConversationId).toList();
@ -86,6 +88,7 @@ public class MessagingService {
} }
public Map<String, Object> getConversation(Long conversationId, Long userId) { public Map<String, Object> getConversation(Long conversationId, Long userId) {
requireVerifiedIfCompany(userId);
Conversation c = requireConversation(conversationId); Conversation c = requireConversation(conversationId);
assertMember(conversationId, userId); assertMember(conversationId, userId);
Map<String, Object> v = toView(c, userId); Map<String, Object> v = toView(c, userId);
@ -95,6 +98,7 @@ public class MessagingService {
@Transactional @Transactional
public Map<String, Object> createOrGetDirectConversation(Long userId, CreateConversationRequest req) { public Map<String, Object> createOrGetDirectConversation(Long userId, CreateConversationRequest req) {
requireVerifiedIfCompany(userId);
Long otherId = req.getParticipantUserId(); Long otherId = req.getParticipantUserId();
if (userId.equals(otherId)) throw new BusinessException(ErrorCode.BAD_REQUEST); if (userId.equals(otherId)) throw new BusinessException(ErrorCode.BAD_REQUEST);
if (!userRepository.existsById(otherId)) throw new BusinessException(ErrorCode.USER_NOT_FOUND); if (!userRepository.existsById(otherId)) throw new BusinessException(ErrorCode.USER_NOT_FOUND);
@ -146,6 +150,12 @@ public class MessagingService {
return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role); return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role);
} }
private void requireVerifiedIfCompany(Long userId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
VerifiedCompanyGuard.requireVerifiedIfCompany(actor,
() -> companyRepository.findByOwnerUserId(userId).orElse(null));
}
// ── V33 屏蔽控制:任一方向存在屏蔽即禁止站内信交互 ── // ── V33 屏蔽控制:任一方向存在屏蔽即禁止站内信交互 ──
private boolean isBlocked(Long aId, Long bId) { private boolean isBlocked(Long aId, Long bId) {
@ -177,6 +187,7 @@ public class MessagingService {
@Transactional @Transactional
public Map<String, Object> sendMessage(Long userId, SendMessageRequest req) { public Map<String, Object> sendMessage(Long userId, SendMessageRequest req) {
requireVerifiedIfCompany(userId);
requireConversation(req.getConversationId()); requireConversation(req.getConversationId());
assertMember(req.getConversationId(), userId); assertMember(req.getConversationId(), userId);
if (req.getContent().length() > MAX_CONTENT_LENGTH) throw new BusinessException(ErrorCode.BAD_REQUEST); if (req.getContent().length() > MAX_CONTENT_LENGTH) throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -204,6 +215,7 @@ public class MessagingService {
} }
public MessagePageResponse listMessages(Long conversationId, Long userId, int page, int size) { public MessagePageResponse listMessages(Long conversationId, Long userId, int page, int size) {
requireVerifiedIfCompany(userId);
Conversation conversation = requireConversation(conversationId); Conversation conversation = requireConversation(conversationId);
assertMember(conversationId, userId); assertMember(conversationId, userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST); if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -230,6 +242,7 @@ public class MessagingService {
@Transactional @Transactional
public void markRead(Long conversationId, Long userId) { public void markRead(Long conversationId, Long userId) {
requireVerifiedIfCompany(userId);
requireConversation(conversationId); requireConversation(conversationId);
assertMember(conversationId, userId); assertMember(conversationId, userId);
List<Message> msgs = messageRepository List<Message> msgs = messageRepository
@ -265,6 +278,7 @@ public class MessagingService {
@Transactional @Transactional
public MessageResponse recallMessage(Long currentUserId, Long conversationId, Long messageId, public MessageResponse recallMessage(Long currentUserId, Long conversationId, Long messageId,
AuditContext auditContext, LocalDateTime capturedNow) { AuditContext auditContext, LocalDateTime capturedNow) {
requireVerifiedIfCompany(currentUserId);
Conversation conversation = requireConversation(conversationId); Conversation conversation = requireConversation(conversationId);
assertMember(conversationId, currentUserId); assertMember(conversationId, currentUserId);
User currentUser = userRepository.findById(currentUserId).orElse(null); User currentUser = userRepository.findById(currentUserId).orElse(null);

16
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java

@ -1,7 +1,11 @@
package com.mydreamjob.notification; package com.mydreamjob.notification;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -22,6 +26,8 @@ public class NotificationService {
private final NotificationRepository notificationRepository; private final NotificationRepository notificationRepository;
private final PushProvider pushProvider; private final PushProvider pushProvider;
private final NotificationPublisher notificationPublisher; private final NotificationPublisher notificationPublisher;
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private static final int MAX_PAGE_SIZE = 50; private static final int MAX_PAGE_SIZE = 50;
@ -74,6 +80,7 @@ public class NotificationService {
/** 分页查询用户通知 */ /** 分页查询用户通知 */
public Page<Notification> list(Long userId, int page, int size) { public Page<Notification> list(Long userId, int page, int size) {
requireVerifiedIfCompany(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST); if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST);
return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size)); return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size));
} }
@ -81,6 +88,7 @@ public class NotificationService {
/** 标记单条已读(幂等:已读也返回成功) */ /** 标记单条已读(幂等:已读也返回成功) */
@Transactional @Transactional
public void markRead(Long userId, Long notificationId) { public void markRead(Long userId, Long notificationId) {
requireVerifiedIfCompany(userId);
Notification n = notificationRepository.findById(notificationId) Notification n = notificationRepository.findById(notificationId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND));
if (!n.getUserId().equals(userId)) throw new BusinessException(ErrorCode.FORBIDDEN); if (!n.getUserId().equals(userId)) throw new BusinessException(ErrorCode.FORBIDDEN);
@ -94,6 +102,7 @@ public class NotificationService {
/** 标记全部已读(幂等) */ /** 标记全部已读(幂等) */
@Transactional @Transactional
public int markAllRead(Long userId) { public int markAllRead(Long userId) {
requireVerifiedIfCompany(userId);
int count = 0; int count = 0;
int pageIdx = 0; int pageIdx = 0;
boolean hasMore = true; boolean hasMore = true;
@ -119,9 +128,16 @@ public class NotificationService {
/** 未读数 */ /** 未读数 */
public long unreadCount(Long userId) { public long unreadCount(Long userId) {
requireVerifiedIfCompany(userId);
return notificationRepository.countUnreadByUserId(userId); return notificationRepository.countUnreadByUserId(userId);
} }
private void requireVerifiedIfCompany(Long userId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
VerifiedCompanyGuard.requireVerifiedIfCompany(actor,
() -> companyRepository.findByOwnerUserId(userId).orElse(null));
}
public Map<String, Object> toView(Notification n) { public Map<String, Object> toView(Notification n) {
Map<String, Object> v = new HashMap<>(); Map<String, Object> v = new HashMap<>();
v.put("id", n.getId()); v.put("id", n.getId());

25
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java

@ -10,6 +10,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import com.mydreamjob.notification.NotificationService; import com.mydreamjob.notification.NotificationService;
@ -153,8 +154,7 @@ public class OfferService {
PageRequest pr = PageRequest.of(page, size); PageRequest pr = PageRequest.of(page, size);
if (hasRole(user, User.Role.COMPANY)) { if (hasRole(user, User.Role.COMPANY)) {
Company company = companyRepository.findByOwnerUserId(userId).orElse(null); Company company = requireCompany(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
return listByCompany(company.getId(), status, applicationId, pr); return listByCompany(company.getId(), status, applicationId, pr);
} }
if (hasRole(user, User.Role.STUDENT)) { if (hasRole(user, User.Role.STUDENT)) {
@ -189,6 +189,7 @@ public class OfferService {
/** 详情:仅目标候选人、所属企业可访问 */ /** 详情:仅目标候选人、所属企业可访问 */
public Offer getOffer(Long offerId, Long userId) { public Offer getOffer(Long offerId, Long userId) {
requireVerifiedIfCompany(userId);
Offer offer = requireOffer(offerId); Offer offer = requireOffer(offerId);
assertViewAccess(offer, userId); assertViewAccess(offer, userId);
return offer; return offer;
@ -258,17 +259,18 @@ public class OfferService {
*/ */
@Transactional @Transactional
public Offer transitionStatus(Long operatorUserId, Long offerId, OfferStatusRequest req, AuditContext ctx) { public Offer transitionStatus(Long operatorUserId, Long offerId, OfferStatusRequest req, AuditContext ctx) {
Offer offer = requireOffer(offerId);
User user = loadUser(operatorUserId); User user = loadUser(operatorUserId);
boolean isCompany = hasRole(user, User.Role.COMPANY); boolean isCompany = hasRole(user, User.Role.COMPANY);
boolean isCandidate = hasRole(user, User.Role.STUDENT); boolean isCandidate = hasRole(user, User.Role.STUDENT);
ActorRole actor = isCompany ? ActorRole.COMPANY : (isCandidate ? ActorRole.CANDIDATE : null); ActorRole actor = isCompany ? ActorRole.COMPANY : (isCandidate ? ActorRole.CANDIDATE : null);
if (actor == null) throw new BusinessException(ErrorCode.FORBIDDEN); if (actor == null) throw new BusinessException(ErrorCode.FORBIDDEN);
Company currentCompany = actor == ActorRole.COMPANY ? requireCompany(operatorUserId) : null;
Offer offer = requireOffer(offerId);
// 访问控制(身份一律来自 JWT) // 访问控制(身份一律来自 JWT)
if (actor == ActorRole.COMPANY) { if (actor == ActorRole.COMPANY) {
Company company = companyRepository.findByOwnerUserId(operatorUserId).orElse(null); if (!currentCompany.getId().equals(offer.getCompanyId())) {
if (company == null || !company.getId().equals(offer.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }
} else { } else {
@ -794,8 +796,12 @@ public class OfferService {
if (!hasRole(user, User.Role.COMPANY)) { if (!hasRole(user, User.Role.COMPANY)) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }
return companyRepository.findByOwnerUserId(userId) return VerifiedCompanyGuard.requireVerified(companyRepository.findByOwnerUserId(userId).orElse(null));
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); }
private void requireVerifiedIfCompany(Long userId) {
User user = userRepository.findById(userId).orElse(null);
if (user != null && hasRole(user, User.Role.COMPANY)) requireCompany(userId);
} }
private boolean hasRole(User user, User.Role role) { private boolean hasRole(User user, User.Role role) {
@ -807,7 +813,10 @@ public class OfferService {
if (hasRole(user, User.Role.STUDENT) && offer.getCandidateId().equals(userId)) return; if (hasRole(user, User.Role.STUDENT) && offer.getCandidateId().equals(userId)) return;
if (hasRole(user, User.Role.COMPANY)) { if (hasRole(user, User.Role.COMPANY)) {
Company company = companyRepository.findByOwnerUserId(userId).orElse(null); Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
if (company != null && company.getId().equals(offer.getCompanyId())) return; if (company != null && company.getId().equals(offer.getCompanyId())) {
VerifiedCompanyGuard.requireVerified(company);
return;
}
} }
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }

8
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java

@ -11,6 +11,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyService; import com.mydreamjob.company.CompanyService;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository; import com.mydreamjob.job.JobRepository;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
@ -206,7 +207,12 @@ public class JobRecommendationService {
private String write(Object o){try{return o==null?null:JSON.writeValueAsString(o);}catch(Exception e){throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}} private String write(Object o){try{return o==null?null:JSON.writeValueAsString(o);}catch(Exception e){throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}}
private void validateTaxonomy(String industry,String family){JobRecommendationTaxonomy i=taxonomyRepository.findById(industry).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));JobRecommendationTaxonomy f=taxonomyRepository.findById(family).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));if(!Boolean.TRUE.equals(i.getEnabled())||!Boolean.TRUE.equals(f.getEnabled())||!"INDUSTRY".equals(i.getTaxonomyType())||!"JOB_FAMILY".equals(f.getTaxonomyType())||!industry.equals(f.getParentCode()))throw new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID);} private void validateTaxonomy(String industry,String family){JobRecommendationTaxonomy i=taxonomyRepository.findById(industry).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));JobRecommendationTaxonomy f=taxonomyRepository.findById(family).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));if(!Boolean.TRUE.equals(i.getEnabled())||!Boolean.TRUE.equals(f.getEnabled())||!"INDUSTRY".equals(i.getTaxonomyType())||!"JOB_FAMILY".equals(f.getTaxonomyType())||!industry.equals(f.getParentCode()))throw new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID);}
private void validateMaps(Map<String,Integer> r,Map<String,Integer>a){if((r==null||r.isEmpty())&&(a==null||a.isEmpty()))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(r!=null&&(!RIASEC.containsAll(r.keySet())||r.values().stream().anyMatch(v->v==null||v<0||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(a!=null&&(!ABILITY.containsAll(a.keySet())||a.values().stream().anyMatch(v->v==null||v<1||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);} private void validateMaps(Map<String,Integer> r,Map<String,Integer>a){if((r==null||r.isEmpty())&&(a==null||a.isEmpty()))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(r!=null&&(!RIASEC.containsAll(r.keySet())||r.values().stream().anyMatch(v->v==null||v<0||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(a!=null&&(!ABILITY.containsAll(a.keySet())||a.values().stream().anyMatch(v->v==null||v<1||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}
private void assertCompanyOwns(Long user,Long job){Company c=companyService.findCompanyByOwnerUserId(user);if(c==null)throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);jobService.assertOwnerOrThrow(c.getId(),job);} private void assertCompanyOwns(Long user, Long job) {
Company company = companyService.findCompanyByOwnerUserId(user);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
VerifiedCompanyGuard.requireVerified(company);
jobService.assertOwnerOrThrow(company.getId(), job);
}
private void validatePage(int p,int s){if(p<0||s<1||s>50)throw new BusinessException(ErrorCode.RECOMMENDATION_INVALID_QUERY);} private void validatePage(int p,int s){if(p<0||s<1||s>50)throw new BusinessException(ErrorCode.RECOMMENDATION_INVALID_QUERY);}
private RecommendationProfileResponse profileView(JobRecommendationProfile p){return RecommendationProfileResponse.builder().jobId(p.getJobId()).industryCode(p.getIndustryCode()).jobFamilyCode(p.getJobFamilyCode()).riasecWeights(mapInteger(p.getRiasecWeightsJson())).abilityExpectations(mapInteger(p.getAbilityExpectationsJson())).enabled(p.getEnabled()).reviewStatus(p.getReviewStatus()).reviewReason(p.getReviewReason()).version(p.getVersion()).updatedAt(p.getUpdatedAt()).build();} private RecommendationProfileResponse profileView(JobRecommendationProfile p){return RecommendationProfileResponse.builder().jobId(p.getJobId()).industryCode(p.getIndustryCode()).jobFamilyCode(p.getJobFamilyCode()).riasecWeights(mapInteger(p.getRiasecWeightsJson())).abilityExpectations(mapInteger(p.getAbilityExpectationsJson())).enabled(p.getEnabled()).reviewStatus(p.getReviewStatus()).reviewReason(p.getReviewReason()).version(p.getVersion()).updatedAt(p.getUpdatedAt()).build();}
private Map<String,Integer> mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference<Map<String,Integer>>(){});}catch(Exception e){return Map.of();}} private Map<String,Integer> mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference<Map<String,Integer>>(){});}catch(Exception e){return Map.of();}}

4
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java

@ -10,6 +10,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository; import com.mydreamjob.job.JobRepository;
import com.mydreamjob.recommendation.JobPreferenceScoreCalculator; import com.mydreamjob.recommendation.JobPreferenceScoreCalculator;
@ -266,8 +267,7 @@ public class TalentDiscoveryService {
if (!hasRole(user, User.Role.COMPANY)) throw new BusinessException(ErrorCode.FORBIDDEN); if (!hasRole(user, User.Role.COMPANY)) throw new BusinessException(ErrorCode.FORBIDDEN);
Company company = companyRepository.findByOwnerUserId(userId) Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
if (!Boolean.TRUE.equals(company.getVerified())) throw new BusinessException(ErrorCode.FORBIDDEN); return VerifiedCompanyGuard.requireVerified(company);
return company;
} }
private User requireUser(Long userId) { private User requireUser(Long userId) {
if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED); if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED);

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

@ -11,6 +11,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository; import com.mydreamjob.job.JobRepository;
import com.mydreamjob.recommendation.JobRecommendationProfile; import com.mydreamjob.recommendation.JobRecommendationProfile;
@ -303,8 +304,7 @@ public class TalentInvitationService {
User user = requireUser(userId); User user = requireUser(userId);
if (!hasRole(user, User.Role.COMPANY)) throw new BusinessException(ErrorCode.FORBIDDEN); if (!hasRole(user, User.Role.COMPANY)) throw new BusinessException(ErrorCode.FORBIDDEN);
Company company = companyRepository.findByOwnerUserId(userId).orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); Company company = companyRepository.findByOwnerUserId(userId).orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
if (!Boolean.TRUE.equals(company.getVerified())) throw new BusinessException(ErrorCode.FORBIDDEN); return VerifiedCompanyGuard.requireVerified(company);
return company;
} }
private Job requireOwnedJob(Company company, Long jobId) { private Job requireOwnedJob(Company company, Long jobId) {

30
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java

@ -33,6 +33,7 @@ class ApplicationServiceTest {
@Mock private ApplicationRepository applicationRepository; @Mock private ApplicationRepository applicationRepository;
@Mock private com.mydreamjob.job.JobService jobService; @Mock private com.mydreamjob.job.JobService jobService;
@Mock private com.mydreamjob.auth.UserRepository userRepository; @Mock private com.mydreamjob.auth.UserRepository userRepository;
@Mock private com.mydreamjob.company.CompanyRepository companyRepository;
@Mock private AuditService auditService; @Mock private AuditService auditService;
@Mock private ApplicationStatusHistoryRepository historyRepository; @Mock private ApplicationStatusHistoryRepository historyRepository;
@Mock private com.mydreamjob.notification.NotificationService notificationService; @Mock private com.mydreamjob.notification.NotificationService notificationService;
@ -61,6 +62,13 @@ class ApplicationServiceTest {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(user)); when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(user));
} }
private void mockStudentRoleWithRoles() {
com.mydreamjob.auth.User user = com.mydreamjob.auth.User.builder().id(STUDENT_ID).phone("TEST-STUDENT")
.role(com.mydreamjob.auth.User.Role.STUDENT).active(true).verified(true).build();
user.setRoles(new HashSet<>(Set.of(com.mydreamjob.auth.User.Role.STUDENT)));
when(userRepository.findByIdWithRoles(STUDENT_ID)).thenReturn(Optional.of(user));
}
// ── 撤回 ───────────────────────────────────────────────── // ── 撤回 ─────────────────────────────────────────────────
@Test void withdraw_submitted_shouldSucceed() { @Test void withdraw_submitted_shouldSucceed() {
@ -106,6 +114,7 @@ class ApplicationServiceTest {
// ── 时间线 ─────────────────────────────────────────────── // ── 时间线 ───────────────────────────────────────────────
@Test void getTimeline_asStudent_shouldSucceed() { @Test void getTimeline_asStudent_shouldSucceed() {
mockStudentRoleWithRoles();
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application));
when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob);
when(historyRepository.findByApplicationIdOrderByCreatedAtAsc(APP_ID)).thenReturn(List.of()); when(historyRepository.findByApplicationIdOrderByCreatedAtAsc(APP_ID)).thenReturn(List.of());
@ -115,6 +124,10 @@ class ApplicationServiceTest {
} }
@Test void getTimeline_wrongUser_shouldReturn403() { @Test void getTimeline_wrongUser_shouldReturn403() {
com.mydreamjob.auth.User other = com.mydreamjob.auth.User.builder().id(999L).phone("13800000999")
.role(com.mydreamjob.auth.User.Role.STUDENT).active(true).verified(true).build();
other.setRoles(new HashSet<>(Set.of(com.mydreamjob.auth.User.Role.STUDENT)));
when(userRepository.findByIdWithRoles(999L)).thenReturn(Optional.of(other));
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application));
when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob);
@ -376,16 +389,19 @@ class ApplicationServiceTest {
// ── getTimeline 已知行为回归 ──────────────────────────────── // ── getTimeline 已知行为回归 ────────────────────────────────
@Test void getTimeline_companyUser_knownBehavior() { @Test void getTimeline_companyOwner_canReadWhenVerified() {
// 公司用户的 userId 与 companyId 不同,当前实现比较 job.companyId == userId Long companyUserId = 300L;
// 这会导致公司用户无法通过权限校验,返回 403 com.mydreamjob.auth.User companyUser = com.mydreamjob.auth.User.builder().id(companyUserId).phone("13800000300")
.role(com.mydreamjob.auth.User.Role.COMPANY).active(true).verified(true).build();
companyUser.setRoles(new HashSet<>(Set.of(com.mydreamjob.auth.User.Role.COMPANY)));
when(userRepository.findByIdWithRoles(companyUserId)).thenReturn(Optional.of(companyUser));
when(companyRepository.findByOwnerUserId(companyUserId)).thenReturn(Optional.of(
com.mydreamjob.company.Company.builder().id(COMPANY_ID).ownerUserId(companyUserId).verified(true).build()));
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application));
when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob);
// publishedJob.companyId = COMPANY_ID (1), userId = COMPANY_ID (1) -> 巧合匹配 when(historyRepository.findByApplicationIdOrderByCreatedAtAsc(APP_ID)).thenReturn(List.of());
// 注意:实际场景中 companyId != userId,此处仅验证当前行为
var result = applicationService.getTimeline(APP_ID, COMPANY_ID); var result = applicationService.getTimeline(APP_ID, companyUserId);
// 因为 COMPANY_ID == publishedJob.companyId,当前代码会通过
assertNotNull(result); assertNotNull(result);
} }

17
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java

@ -22,7 +22,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.*;
/** /**
* S03-B 企业简历授权决策矩阵(设计 §4.3)。 * S03-B 企业简历授权决策矩阵(设计 §4.3)。
@ -64,6 +64,7 @@ class CompanyResumeAccessPolicyTest {
} }
private void givenJobOwnedBy(Long companyId) { private void givenJobOwnedBy(Long companyId) {
givenCompany(COMPANY_ID, true);
when(jobRepository.findById(JOB_ID)).thenReturn(Optional.of( when(jobRepository.findById(JOB_ID)).thenReturn(Optional.of(
Job.builder().id(JOB_ID).companyId(companyId).build())); Job.builder().id(JOB_ID).companyId(companyId).build()));
} }
@ -90,12 +91,14 @@ class CompanyResumeAccessPolicyTest {
@Test @Test
void missingApplicationIsRejected() { void missingApplicationIsRejected() {
givenCompany(COMPANY_ID, true);
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty()); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty());
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
} }
@Test @Test
void studentMismatchIsRejected() { void studentMismatchIsRejected() {
givenCompany(COMPANY_ID, true);
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of( when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(
Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(999L).status("SUBMITTED").build())); Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(999L).status("SUBMITTED").build()));
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
@ -103,11 +106,21 @@ class CompanyResumeAccessPolicyTest {
@Test @Test
void missingJobIsRejected() { void missingJobIsRejected() {
givenCompany(COMPANY_ID, true);
givenApplication("SUBMITTED"); givenApplication("SUBMITTED");
when(jobRepository.findById(JOB_ID)).thenReturn(Optional.empty()); when(jobRepository.findById(JOB_ID)).thenReturn(Optional.empty());
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
} }
@Test
void appliedCompaniesRejectsUnverifiedCompanyBeforeReadingApplication() {
givenCompany(COMPANY_ID, false);
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
verify(applicationRepository, never()).findById(APP_ID);
}
// ── PRIVATE / 无记录 ─────────────────────────────────────── // ── PRIVATE / 无记录 ───────────────────────────────────────
@Test @Test
@ -300,7 +313,7 @@ class CompanyResumeAccessPolicyTest {
@Test @Test
void authorizeForDownloadReturnsStudentIdWhenAllowed() { void authorizeForDownloadReturnsStudentIdWhenAllowed() {
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of( when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of(
Company.builder().id(COMPANY_ID).verified(false).build())); Company.builder().id(COMPANY_ID).verified(true).build()));
givenApplication("SUBMITTED"); givenApplication("SUBMITTED");
givenJobOwnedBy(COMPANY_ID); givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.APPLIED_COMPANIES); givenVisibility(ResumeVisibility.APPLIED_COMPANIES);

18
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java

@ -193,6 +193,12 @@ class CompanyResumeDownloadIntegrationTest {
applicationRepository.saveAndFlush(app); applicationRepository.saveAndFlush(app);
} }
private void certifyCompanyForResumePolicyTest() {
Company company = companyRepository.findById(companyId).orElseThrow();
company.setVerified(true);
companyRepository.saveAndFlush(company);
}
private String url() { private String url() {
return "/company/candidates/" + applicationId + "/resume"; return "/company/candidates/" + applicationId + "/resume";
} }
@ -205,6 +211,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test @Test
void privateIsDeniedForEveryStatusAndNeverReadsFile() { void privateIsDeniedForEveryStatusAndNeverReadsFile() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.PRIVATE); setVisibility(ResumeVisibility.PRIVATE);
for (String status : ALL_STATUSES) { for (String status : ALL_STATUSES) {
setStatus(status); setStatus(status);
@ -217,6 +224,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test @Test
void missingSettingsIsDeniedAsPrivate() { void missingSettingsIsDeniedAsPrivate() {
certifyCompanyForResumePolicyTest();
setVisibility(null); setVisibility(null);
for (String status : ALL_STATUSES) { for (String status : ALL_STATUSES) {
setStatus(status); setStatus(status);
@ -228,6 +236,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test @Test
void appliedCompaniesAllowsOnlyEffectiveStatusesForOwningCompany() throws Exception { void appliedCompaniesAllowsOnlyEffectiveStatusesForOwningCompany() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES); setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String status : ALL_STATUSES) { for (String status : ALL_STATUSES) {
setStatus(status); setStatus(status);
@ -272,6 +281,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test @Test
void appliedCompaniesUnknownStatusReturns403WithoutReadingStorage() throws Exception { void appliedCompaniesUnknownStatusReturns403WithoutReadingStorage() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES); setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String unknown : new String[]{"PENDING", "DRAFT", "ARCHIVED", "submitted"}) { for (String unknown : new String[]{"PENDING", "DRAFT", "ARCHIVED", "submitted"}) {
@ -290,6 +300,7 @@ class CompanyResumeDownloadIntegrationTest {
void appliedCompaniesBlankStatusReturns403WithoutReadingStorage() throws Exception { void appliedCompaniesBlankStatusReturns403WithoutReadingStorage() throws Exception {
// 说明:applications.status 在 V2 中是 NOT NULL,因此 DB 层无法保存 NULL; // 说明:applications.status 在 V2 中是 NOT NULL,因此 DB 层无法保存 NULL;
// NULL 情形由 CompanyResumeAccessPolicyTest 在内存中覆盖,这里用可达的空白值验证。 // NULL 情形由 CompanyResumeAccessPolicyTest 在内存中覆盖,这里用可达的空白值验证。
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES); setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String blank : new String[]{"", " "}) { for (String blank : new String[]{"", " "}) {
@ -308,6 +319,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test @Test
void otherCompanyIsAlwaysDenied() { void otherCompanyIsAlwaysDenied() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES); setVisibility(ResumeVisibility.APPLIED_COMPANIES);
READ_CALLS.set(0); READ_CALLS.set(0);
assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY"))) assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY")))
@ -339,6 +351,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test @Test
void nonexistentApplicationIsNotFound() { void nonexistentApplicationIsNotFound() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES); setVisibility(ResumeVisibility.APPLIED_COMPANIES);
READ_CALLS.set(0); READ_CALLS.set(0);
String token = companyToken(); String token = companyToken();
@ -355,6 +368,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test @Test
void missingResumeIsProfileNotFound() { void missingResumeIsProfileNotFound() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES); setVisibility(ResumeVisibility.APPLIED_COMPANIES);
StudentProfile profile = profileRepository.findByUserId(studentUserId).orElseThrow(); StudentProfile profile = profileRepository.findByUserId(studentUserId).orElseThrow();
profile.setResumeUrl(null); profile.setResumeUrl(null);
@ -367,6 +381,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test @Test
void responseDoesNotLeakPathOrIdentity() throws Exception { void responseDoesNotLeakPathOrIdentity() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES); setVisibility(ResumeVisibility.APPLIED_COMPANIES);
String body = mockMvc.perform(get(url()).header("Authorization", companyToken())) String body = mockMvc.perform(get(url()).header("Authorization", companyToken()))
@ -381,6 +396,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test @Test
void contentDispositionUsesSanitizedOriginalName() throws Exception { void contentDispositionUsesSanitizedOriginalName() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES); setVisibility(ResumeVisibility.APPLIED_COMPANIES);
mockMvc.perform(get(url()).header("Authorization", companyToken())) mockMvc.perform(get(url()).header("Authorization", companyToken()))
@ -396,6 +412,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test @Test
void companyCandidateEndpointsStillExcludeResumeUrl() throws Exception { void companyCandidateEndpointsStillExcludeResumeUrl() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES); setVisibility(ResumeVisibility.APPLIED_COMPANIES);
String token = companyToken(); String token = companyToken();
@ -529,6 +546,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test @Test
void companyDownloadHasNoSideEffectsOnBusinessTables() { void companyDownloadHasNoSideEffectsOnBusinessTables() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES); setVisibility(ResumeVisibility.APPLIED_COMPANIES);
// 先取 token:测试夹具在签发时会写入 auth_tokens/users,必须发生在基线快照之前, // 先取 token:测试夹具在签发时会写入 auth_tokens/users,必须发生在基线快照之前,

13
mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java

@ -340,13 +340,13 @@ class AuthenticationBoundaryIntegrationTest {
void otherCompanyCannotModifyOwnedJob() throws Exception { void otherCompanyCannotModifyOwnedJob() throws Exception {
List<String> before = snapshotJobs(); List<String> before = snapshotJobs();
// 既有语义:归属校验失败为 JOB_OWNER_MISMATCH(7002) → HTTP 400(本轮不改变错误码) // 其他企业资源与不存在资源统一返回 JOB_NOT_FOUND,避免泄露跨企业资源是否存在。
mockMvc.perform(put("/jobs/" + jobId) mockMvc.perform(put("/jobs/" + jobId)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"他企业篡改\"}") .content("{\"title\":\"他企业篡改\"}")
.header("Authorization", token(otherCompanyUserId))) .header("Authorization", token(otherCompanyUserId)))
.andExpect(status().isBadRequest()) .andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.JOB_OWNER_MISMATCH.getCode())); .andExpect(jsonPath("$.code").value(ErrorCode.JOB_NOT_FOUND.getCode()));
assertThat(snapshotJobs()).isEqualTo(before); assertThat(snapshotJobs()).isEqualTo(before);
} }
@ -359,8 +359,8 @@ class AuthenticationBoundaryIntegrationTest {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PUBLISHED\"}") .content("{\"status\":\"PUBLISHED\"}")
.header("Authorization", token(otherCompanyUserId))) .header("Authorization", token(otherCompanyUserId)))
.andExpect(status().isBadRequest()) .andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.JOB_OWNER_MISMATCH.getCode())); .andExpect(jsonPath("$.code").value(ErrorCode.JOB_NOT_FOUND.getCode()));
assertThat(snapshotJobs()).isEqualTo(before); assertThat(snapshotJobs()).isEqualTo(before);
} }
@ -422,10 +422,11 @@ class AuthenticationBoundaryIntegrationTest {
return companyRepository.save(Company.builder() return companyRepository.save(Company.builder()
.ownerUserId(ownerUserId) .ownerUserId(ownerUserId)
.name(name) .name(name)
.verified(true)
.build()).getId(); .build()).getId();
} }
private Long companyOf(Long ownerUserId) { private Long companyOf(Long ownerUserId) {
return companyRepository.findByOwnerUserId(ownerUserId).orElseThrow().getId(); return companyRepository.findByOwnerUserId(ownerUserId).orElseThrow().getId();
} }
} }

2
mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java

@ -97,7 +97,7 @@ class BidirectionalMatchPersistenceIntegrationTest {
otherStudentUserId = createUser(User.Role.STUDENT); otherStudentUserId = createUser(User.Role.STUDENT);
companyUserId = createUser(User.Role.COMPANY); companyUserId = createUser(User.Role.COMPANY);
Company company = companyRepository.save(Company.builder() Company company = companyRepository.save(Company.builder()
.ownerUserId(companyUserId).name("匹配测试企业").build()); .ownerUserId(companyUserId).name("匹配测试企业").verified(true).build());
companyId = company.getId(); companyId = company.getId();
Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端") Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端")
.description("desc").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); .description("desc").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build());

8
mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchServiceTest.java

@ -93,7 +93,7 @@ class BidirectionalMatchServiceTest {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app));
when(jobService.getJob(JOB_ID)).thenReturn(job); when(jobService.getJob(JOB_ID)).thenReturn(job);
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build()));
when(matchRecordWriter.applyScore(eq(app), eq(job), eq(COMPANY_USER_ID), eq(80), when(matchRecordWriter.applyScore(eq(app), eq(job), eq(COMPANY_USER_ID), eq(80),
eq(MatchScoreField.HR_SUITABILITY), any())) eq(MatchScoreField.HR_SUITABILITY), any()))
.thenReturn(ApplicationMatchRecord.builder().applicationId(APPLICATION_ID) .thenReturn(ApplicationMatchRecord.builder().applicationId(APPLICATION_ID)
@ -136,7 +136,7 @@ class BidirectionalMatchServiceTest {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app));
when(jobService.getJob(JOB_ID)).thenReturn(job); when(jobService.getJob(JOB_ID)).thenReturn(job);
when(companyRepository.findByOwnerUserId(OTHER_COMPANY_USER_ID)) when(companyRepository.findByOwnerUserId(OTHER_COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(OTHER_COMPANY_ID).ownerUserId(OTHER_COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(OTHER_COMPANY_ID).ownerUserId(OTHER_COMPANY_USER_ID).verified(true).build()));
BusinessException ex = assertThrows(BusinessException.class, BusinessException ex = assertThrows(BusinessException.class,
() -> matchService.submitHrSuitabilityScore(OTHER_COMPANY_USER_ID, APPLICATION_ID, 80, null)); () -> matchService.submitHrSuitabilityScore(OTHER_COMPANY_USER_ID, APPLICATION_ID, 80, null));
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt());
@ -191,7 +191,7 @@ class BidirectionalMatchServiceTest {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app));
when(jobService.getJob(JOB_ID)).thenReturn(job); when(jobService.getJob(JOB_ID)).thenReturn(job);
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build()));
when(matchRecordWriter.applyScore(eq(app), eq(job), eq(COMPANY_USER_ID), eq(80), when(matchRecordWriter.applyScore(eq(app), eq(job), eq(COMPANY_USER_ID), eq(80),
eq(MatchScoreField.HR_SUITABILITY), any())) eq(MatchScoreField.HR_SUITABILITY), any()))
.thenThrow(new OptimisticLockingFailureException("conflict")) .thenThrow(new OptimisticLockingFailureException("conflict"))
@ -210,7 +210,7 @@ class BidirectionalMatchServiceTest {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app)); when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app));
when(jobService.getJob(JOB_ID)).thenReturn(job); when(jobService.getJob(JOB_ID)).thenReturn(job);
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build()));
when(matchRecordWriter.applyScore(eq(app), eq(job), eq(COMPANY_USER_ID), eq(80), when(matchRecordWriter.applyScore(eq(app), eq(job), eq(COMPANY_USER_ID), eq(80),
eq(MatchScoreField.HR_SUITABILITY), any())) eq(MatchScoreField.HR_SUITABILITY), any()))
.thenThrow(new OptimisticLockingFailureException("conflict")); .thenThrow(new OptimisticLockingFailureException("conflict"));

10
mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java

@ -67,7 +67,7 @@ class MatchConversationServiceTest {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app()));
when(jobService.getJob(JOB_ID)).thenReturn(job()); when(jobService.getJob(JOB_ID)).thenReturn(job());
when(companyRepository.findById(COMPANY_ID)) when(companyRepository.findById(COMPANY_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build()));
ApplicationMatchRecord notMatched = matchedRecord(); ApplicationMatchRecord notMatched = matchedRecord();
notMatched.setStatus(ApplicationMatchRecord.STATUS_PENDING_HR); notMatched.setStatus(ApplicationMatchRecord.STATUS_PENDING_HR);
when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(notMatched)); when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(notMatched));
@ -85,7 +85,7 @@ class MatchConversationServiceTest {
when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord()));
when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty());
when(companyRepository.findById(COMPANY_ID)) when(companyRepository.findById(COMPANY_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build()));
when(memberRepository.findByUserId(STUDENT_ID)).thenReturn(List.of()); when(memberRepository.findByUserId(STUDENT_ID)).thenReturn(List.of());
when(conversationRepository.save(any())).thenAnswer(inv -> { when(conversationRepository.save(any())).thenAnswer(inv -> {
Conversation c = inv.getArgument(0); Conversation c = inv.getArgument(0);
@ -110,7 +110,7 @@ class MatchConversationServiceTest {
when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord()));
when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty()); when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty());
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build()));
when(memberRepository.findByUserId(COMPANY_USER_ID)).thenReturn(List.of()); when(memberRepository.findByUserId(COMPANY_USER_ID)).thenReturn(List.of());
when(conversationRepository.save(any())).thenAnswer(inv -> { when(conversationRepository.save(any())).thenAnswer(inv -> {
Conversation c = inv.getArgument(0); Conversation c = inv.getArgument(0);
@ -130,7 +130,7 @@ class MatchConversationServiceTest {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app())); when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app()));
when(jobService.getJob(JOB_ID)).thenReturn(job()); when(jobService.getJob(JOB_ID)).thenReturn(job());
when(companyRepository.findById(COMPANY_ID)) when(companyRepository.findById(COMPANY_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build()));
when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord())); when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord()));
when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)) when(conversationLinkRepository.findByApplicationId(APPLICATION_ID))
.thenReturn(Optional.of(ApplicationMatchConversation.builder() .thenReturn(Optional.of(ApplicationMatchConversation.builder()
@ -155,7 +155,7 @@ class MatchConversationServiceTest {
.thenReturn(Optional.of(ApplicationMatchConversation.builder() .thenReturn(Optional.of(ApplicationMatchConversation.builder()
.applicationId(APPLICATION_ID).conversationId(CONV_ID).build())); .applicationId(APPLICATION_ID).conversationId(CONV_ID).build()));
when(companyRepository.findById(COMPANY_ID)) when(companyRepository.findById(COMPANY_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(true).build()));
when(memberRepository.findByUserId(STUDENT_ID)).thenReturn(List.of()); when(memberRepository.findByUserId(STUDENT_ID)).thenReturn(List.of());
when(conversationRepository.save(any())).thenAnswer(inv -> { when(conversationRepository.save(any())).thenAnswer(inv -> {
Conversation c = inv.getArgument(0); Conversation c = inv.getArgument(0);

4
mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java

@ -58,7 +58,7 @@ class MatchRuleServiceTest {
void getCompanyRule_noRule_shouldReturnDefault60() { void getCompanyRule_noRule_shouldReturnDefault60() {
when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(userWithRole(COMPANY_USER_ID, User.Role.COMPANY))); when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(userWithRole(COMPANY_USER_ID, User.Role.COMPANY)));
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).verified(true).build()));
when(companyRuleRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); when(companyRuleRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty());
var view = matchRuleService.getCompanyRule(COMPANY_USER_ID); var view = matchRuleService.getCompanyRule(COMPANY_USER_ID);
@ -71,7 +71,7 @@ class MatchRuleServiceTest {
void updateCompanyRule_shouldUpsert() { void updateCompanyRule_shouldUpsert() {
when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(userWithRole(COMPANY_USER_ID, User.Role.COMPANY))); when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(userWithRole(COMPANY_USER_ID, User.Role.COMPANY)));
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(COMPANY_ID).build())); .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).verified(true).build()));
when(companyRuleRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); when(companyRuleRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty());
when(companyRuleRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(companyRuleRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

2
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java

@ -182,7 +182,7 @@ class CandidateScoringClockConsistencyTest {
AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE); AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE);
service.setClock(advancing); service.setClock(advancing);
when(companyRepository.findByOwnerUserId(200L)).thenReturn(Optional.of( when(companyRepository.findByOwnerUserId(200L)).thenReturn(Optional.of(
com.mydreamjob.company.Company.builder().id(9L).ownerUserId(200L).build())); com.mydreamjob.company.Company.builder().id(9L).ownerUserId(200L).verified(true).build()));
when(applicationRepository.findById(5L)).thenReturn(Optional.of( when(applicationRepository.findById(5L)).thenReturn(Optional.of(
com.mydreamjob.application.Application.builder().id(5L).jobId(50L).studentUserId(7L).build())); com.mydreamjob.application.Application.builder().id(5L).jobId(50L).studentUserId(7L).build()));
when(jobService.getJob(50L)).thenReturn(com.mydreamjob.job.Job.builder().id(50L).companyId(9L).build()); when(jobService.getJob(50L)).thenReturn(com.mydreamjob.job.Job.builder().id(50L).companyId(9L).build());

8
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java

@ -162,7 +162,7 @@ class CandidateScoringEngineServiceTest {
@Test void getCompanyScoring_abilityOnlyAggregate_noFourDimOrSourceLeak() throws Exception { @Test void getCompanyScoring_abilityOnlyAggregate_noFourDimOrSourceLeak() throws Exception {
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(COMPANY_USER_ID).verified(true).build()));
when(applicationRepository.findById(APP_ID)) when(applicationRepository.findById(APP_ID))
.thenReturn(Optional.of(Application.builder().id(APP_ID).jobId(50L).studentUserId(STUDENT_ID).build())); .thenReturn(Optional.of(Application.builder().id(APP_ID).jobId(50L).studentUserId(STUDENT_ID).build()));
when(jobService.getJob(50L)).thenReturn(Job.builder().id(50L).companyId(1L).build()); when(jobService.getJob(50L)).thenReturn(Job.builder().id(50L).companyId(1L).build());
@ -266,7 +266,7 @@ class CandidateScoringEngineServiceTest {
@Test void getCompanyScoring_appNotFound_should404() { @Test void getCompanyScoring_appNotFound_should404() {
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(COMPANY_USER_ID).verified(true).build()));
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty()); when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty());
BusinessException ex = assertThrows(BusinessException.class, BusinessException ex = assertThrows(BusinessException.class,
() -> service.getCompanyScoring(COMPANY_USER_ID, APP_ID)); () -> service.getCompanyScoring(COMPANY_USER_ID, APP_ID));
@ -275,7 +275,7 @@ class CandidateScoringEngineServiceTest {
@Test void getCompanyScoring_otherCompany_should403() { @Test void getCompanyScoring_otherCompany_should403() {
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(2L).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(2L).ownerUserId(COMPANY_USER_ID).verified(true).build()));
when(applicationRepository.findById(APP_ID)) when(applicationRepository.findById(APP_ID))
.thenReturn(Optional.of(Application.builder().id(APP_ID).jobId(50L).studentUserId(STUDENT_ID).build())); .thenReturn(Optional.of(Application.builder().id(APP_ID).jobId(50L).studentUserId(STUDENT_ID).build()));
when(jobService.getJob(50L)).thenReturn(Job.builder().id(50L).companyId(1L).build()); when(jobService.getJob(50L)).thenReturn(Job.builder().id(50L).companyId(1L).build());
@ -286,7 +286,7 @@ class CandidateScoringEngineServiceTest {
@Test void getCompanyScoring_success_whitelistOnly() throws Exception { @Test void getCompanyScoring_success_whitelistOnly() throws Exception {
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)) when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(COMPANY_USER_ID).build())); .thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(COMPANY_USER_ID).verified(true).build()));
when(applicationRepository.findById(APP_ID)) when(applicationRepository.findById(APP_ID))
.thenReturn(Optional.of(Application.builder().id(APP_ID).jobId(50L).studentUserId(STUDENT_ID).build())); .thenReturn(Optional.of(Application.builder().id(APP_ID).jobId(50L).studentUserId(STUDENT_ID).build()));
when(jobService.getJob(50L)).thenReturn(Job.builder().id(50L).companyId(1L).build()); when(jobService.getJob(50L)).thenReturn(Job.builder().id(50L).companyId(1L).build());

2
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java

@ -19,7 +19,7 @@ class GlobalExceptionHandlerStatusContractTest {
private static final Set<String> FORBIDDEN = Set.of( private static final Set<String> FORBIDDEN = Set.of(
"FORBIDDEN", "INTERVIEW_ACCESS_DENIED", "APPLICATION_OWNER_MISMATCH", "FORBIDDEN", "INTERVIEW_ACCESS_DENIED", "APPLICATION_OWNER_MISMATCH",
"MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER"); "MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER", "COMPANY_NOT_VERIFIED");
private static final Set<String> NOT_FOUND = Set.of( private static final Set<String> NOT_FOUND = Set.of(
"COMPANY_NOT_FOUND", "PROFILE_NOT_FOUND", "JOB_NOT_FOUND", "APPLICATION_NOT_FOUND", "COMPANY_NOT_FOUND", "PROFILE_NOT_FOUND", "JOB_NOT_FOUND", "APPLICATION_NOT_FOUND",

13
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java

@ -32,12 +32,13 @@ class ErrorHttpStatusRegistryTest {
HttpStatus.SERVICE_UNAVAILABLE); HttpStatus.SERVICE_UNAVAILABLE);
@Test @Test
@DisplayName("密码登录:显式映射包含新增认证状态") @DisplayName("Phase B:显式映射包含企业未认证拒绝状态")
void explicitMappingCountMatchesBaseline() { void explicitMappingCountMatchesBaseline() {
assertThat(registry.explicitMappingCount()).isEqualTo(119); assertThat(registry.explicitMappingCount()).isEqualTo(120);
// 119 个常量只覆盖 118 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 // 120 个常量只覆盖 119 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
// DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。 // DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(118); assertThat(registry.explicitNumericCodeCount()).isEqualTo(119);
assertThat(registry.resolve(ErrorCode.COMPANY_NOT_VERIFIED)).isEqualTo(HttpStatus.FORBIDDEN);
} }
@Test @Test
@ -122,12 +123,12 @@ class ErrorHttpStatusRegistryTest {
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN)) assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN))
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThat(view).hasSize(119); assertThat(view).hasSize(120);
Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView(); Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView();
assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK))
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThat(numeric).hasSize(118); assertThat(numeric).hasSize(119);
} }
@Test @Test

6
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java

@ -90,6 +90,7 @@ class GlobalExceptionHandlerMappingParityTest {
"COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE"); "COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE");
private static final Set<String> PHASE_A_SERVICE_UNAVAILABLE = Set.of( private static final Set<String> PHASE_A_SERVICE_UNAVAILABLE = Set.of(
"COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE"); "COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE");
private static final Set<String> PHASE_B_FORBIDDEN = Set.of("COMPANY_NOT_VERIFIED");
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler = private final GlobalExceptionHandler handler =
@ -105,6 +106,7 @@ class GlobalExceptionHandlerMappingParityTest {
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS; if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS;
if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED; if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED;
if (PHASE_A_SERVICE_UNAVAILABLE.contains(name)) return HttpStatus.SERVICE_UNAVAILABLE; if (PHASE_A_SERVICE_UNAVAILABLE.contains(name)) return HttpStatus.SERVICE_UNAVAILABLE;
if (PHASE_B_FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN;
if (PASSWORD_BAD_REQUEST.contains(name)) return HttpStatus.BAD_REQUEST; if (PASSWORD_BAD_REQUEST.contains(name)) return HttpStatus.BAD_REQUEST;
return HttpStatus.BAD_REQUEST; return HttpStatus.BAD_REQUEST;
} }
@ -112,8 +114,8 @@ class GlobalExceptionHandlerMappingParityTest {
@Test @Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等") @DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() { void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(119); assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(120);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(118); assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(119);
for (ErrorCode errorCode : ErrorCode.values()) { for (ErrorCode errorCode : ErrorCode.values()) {
HttpStatus expected = baselineStatus(errorCode); HttpStatus expected = baselineStatus(errorCode);

32
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java

@ -40,6 +40,8 @@ class R01BIndependentHttpMappingParityTest {
"COMPANY_REGISTRATION_CONFLICT", HttpStatus.CONFLICT, "COMPANY_REGISTRATION_CONFLICT", HttpStatus.CONFLICT,
"COMPANY_ONBOARDING_INCONSISTENT_STATE", HttpStatus.CONFLICT, "COMPANY_ONBOARDING_INCONSISTENT_STATE", HttpStatus.CONFLICT,
"COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE); "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE);
private static final Map<String, HttpStatus> PHASE_B_ADDITIONS =
Map.of("COMPANY_NOT_VERIFIED", HttpStatus.FORBIDDEN);
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler = private final GlobalExceptionHandler handler =
@ -76,24 +78,26 @@ class R01BIndependentHttpMappingParityTest {
}; };
} }
// ── 166 项逐项对账 ──────────────────────────────────────── // ── 历史基线 + Phase A/B 追加码逐项对账 ─────────────────────
@Test @Test
@DisplayName("独立复验:166 个枚举常量按名称逐项与 HEAD 基线对齐(不按数字去重)") @DisplayName("独立复验:枚举常量按名称逐项与 HEAD 基线或阶段追加码对齐(不按数字去重)")
void everyEnumConstantMatchesHeadBaselineByName() { void everyEnumConstantMatchesHeadBaselineByName() {
Map<String, int[]> base = baseline(); Map<String, int[]> base = baseline();
// HEAD 基线有 169 条;Phase A 追加 3 个错误码,不修改任何基线行。 // HEAD 基线有 169 条;后续阶段新增码单独列出,不修改任何历史基线行。
assertThat(base).hasSize(169); assertThat(base).hasSize(169);
assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK");
assertThat(ErrorCode.values()).hasSize(173); assertThat(ErrorCode.values()).hasSize(174);
int checked = 0; int checked = 0;
for (ErrorCode code : ErrorCode.values()) { for (ErrorCode code : ErrorCode.values()) {
int[] expected = base.get(code.name()); int[] expected = base.get(code.name());
if (expected == null) { if (expected == null) {
assertThat(PHASE_A_ADDITIONS).containsKey(code.name()); HttpStatus addedStatus = PHASE_A_ADDITIONS.get(code.name());
assertThat(registry.resolve(code)).isEqualTo(PHASE_A_ADDITIONS.get(code.name())); if (addedStatus == null) addedStatus = PHASE_B_ADDITIONS.get(code.name());
assertThat(addedStatus).as("新增错误码必须逐项明确登记: %s", code.name()).isNotNull();
assertThat(registry.resolve(code)).isEqualTo(addedStatus);
checked++; checked++;
continue; continue;
} }
@ -104,11 +108,11 @@ class R01BIndependentHttpMappingParityTest {
.isEqualTo(expected[1]); .isEqualTo(expected[1]);
checked++; checked++;
} }
assertThat(checked).isEqualTo(173); assertThat(checked).isEqualTo(174);
} }
@Test @Test
@DisplayName("密码登录:116 个显式映射 + 53 个默认 400") @DisplayName("显式映射总数等于历史映射与 Phase A/B 明确追加映射之和")
void explicitAndDefaultCountsMatchBaseline() { void explicitAndDefaultCountsMatchBaseline() {
Map<String, int[]> base = baseline(); Map<String, int[]> base = baseline();
long explicitInBaseline = base.values().stream().filter(v -> v[1] != 400).count(); long explicitInBaseline = base.values().stream().filter(v -> v[1] != 400).count();
@ -116,10 +120,10 @@ class R01BIndependentHttpMappingParityTest {
assertThat(explicitInBaseline).isEqualTo(115); assertThat(explicitInBaseline).isEqualTo(115);
assertThat(base.size() - explicitInBaseline).isEqualTo(54); assertThat(base.size() - explicitInBaseline).isEqualTo(54);
assertThat(registry.explicitMappingCount()).isEqualTo(119); assertThat(registry.explicitMappingCount()).isEqualTo(120);
assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54); assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54);
// 118 个常量仅覆盖 117 个唯一数字码(9103 共用) // 120 个显式常量覆盖 119 个唯一数字码(9103 共用)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(118); assertThat(registry.explicitNumericCodeCount()).isEqualTo(119);
} }
@Test @Test
@ -231,8 +235,10 @@ class R01BIndependentHttpMappingParityTest {
// 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明 // 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明
Map<String, int[]> base = baseline(); Map<String, int[]> base = baseline();
for (ErrorCode c : ErrorCode.values()) { for (ErrorCode c : ErrorCode.values()) {
if (PHASE_A_ADDITIONS.containsKey(c.name())) { if (PHASE_A_ADDITIONS.containsKey(c.name()) || PHASE_B_ADDITIONS.containsKey(c.name())) {
assertThat(registry.resolve(c)).isEqualTo(PHASE_A_ADDITIONS.get(c.name())); HttpStatus addedStatus = PHASE_A_ADDITIONS.get(c.name());
if (addedStatus == null) addedStatus = PHASE_B_ADDITIONS.get(c.name());
assertThat(registry.resolve(c)).isEqualTo(addedStatus);
} else { } else {
assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]); assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]);
} }

24
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java

@ -923,12 +923,16 @@ class R01EIndependentCrossDatabaseVerificationTest {
+ java.util.Arrays.toString(onMy) + "\n--- h2 sql ---\n" + String.join("\n", h2Sql) + java.util.Arrays.toString(onMy) + "\n--- h2 sql ---\n" + String.join("\n", h2Sql)
+ "\n--- mysql sql ---\n" + String.join("\n", mySql) + "\n"); + "\n--- mysql sql ---\n" + String.join("\n", mySql) + "\n");
assertThat(onH2).as("E2 H2:SQL 数必须精确为 3/3/3").containsExactly(3, 3, 3); assertThat(onH2).as("E2 H2:含角色加载的固定 SQL 数必须为 4/4/4").containsExactly(4, 4, 4);
assertThat(onMy).as("E2 MySQL:SQL 数必须精确为 3/3/3").containsExactly(3, 3, 3); assertThat(onMy).as("E2 MySQL:含角色加载的固定 SQL 数必须为 4/4/4").containsExactly(4, 4, 4);
// 3 条语句必须是「成员关系 + 会话 + 分组未读」,且未读查询只出现 1 次 // 4 条语句必须是「角色加载 + 成员关系 + 会话 + 分组未读」,且未读查询只出现 1 次
assertThat(h2Sql).hasSize(3); assertThat(h2Sql).hasSize(4);
assertThat(mySql).hasSize(3); assertThat(mySql).hasSize(4);
assertThat(h2Sql.stream().anyMatch(s -> s.contains("user_roles")))
.as("H2:服务层企业门禁必须读取角色集合").isTrue();
assertThat(mySql.stream().anyMatch(s -> s.contains("user_roles")))
.as("MySQL:服务层企业门禁必须读取角色集合").isTrue();
assertThat(h2Sql.stream().filter(s -> s.contains("ROW_NUMBER")).count()) assertThat(h2Sql.stream().filter(s -> s.contains("ROW_NUMBER")).count())
.as("H2:分组未读查询只执行一次").isEqualTo(1); .as("H2:分组未读查询只执行一次").isEqualTo(1);
assertThat(mySql.stream().filter(s -> s.contains("ROW_NUMBER")).count()) assertThat(mySql.stream().filter(s -> s.contains("ROW_NUMBER")).count())
@ -958,10 +962,10 @@ class R01EIndependentCrossDatabaseVerificationTest {
assertThat(MySql.messaging.listConversations(nobodyMy)).isEmpty(); assertThat(MySql.messaging.listConversations(nobodyMy)).isEmpty();
List<String> mySql = MySql.counting.sqls(); List<String> mySql = MySql.counting.sqls();
assertThat(h2Sql).as("H2:空会话页只发出一条成员关系查询").hasSize(1); assertThat(h2Sql).as("H2:空会话页只发角色与成员关系两条读取").hasSize(2);
assertThat(mySql).as("MySQL:空会话页只发出一条成员关系查询").hasSize(1); assertThat(mySql).as("MySQL:空会话页只发角色与成员关系两条读取").hasSize(2);
assertThat(h2Sql.get(0)).as("不得执行未读查询").doesNotContain("ROW_NUMBER"); assertThat(h2Sql.toString()).as("不得执行未读查询").doesNotContain("ROW_NUMBER");
assertThat(mySql.get(0)).as("不得执行未读查询").doesNotContain("ROW_NUMBER"); assertThat(mySql.toString()).as("不得执行未读查询").doesNotContain("ROW_NUMBER");
assertThat(h2Counting.matching("row_number")).isZero(); assertThat(h2Counting.matching("row_number")).isZero();
assertThat(MySql.counting.matching("row_number")).isZero(); assertThat(MySql.counting.matching("row_number")).isZero();
} }

4
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java

@ -135,8 +135,8 @@ class R01EQueryBudgetAcceptanceTest {
countingDataSource.reset(); countingDataSource.reset();
assertThat(messagingService.listConversations(me)).isEmpty(); assertThat(messagingService.listConversations(me)).isEmpty();
assertThat(countingDataSource.statements()) assertThat(countingDataSource.statements())
.as("空会话不得执行无意义的 IN () 查询") .as("空会话允许角色门禁与成员关系两条固定读取,不得执行会话/未读 IN 查询")
.isLessThanOrEqualTo(1); .isEqualTo(2);
} }
@Test @Test

2
mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java

@ -95,7 +95,7 @@ class CandidateFeedbackRefreshRegistrationTest {
companyUserId = companyUser.getId(); companyUserId = companyUser.getId();
Company company = companyRepository.save(Company.builder() Company company = companyRepository.save(Company.builder()
.ownerUserId(companyUserId).name("F2公司").build()); .ownerUserId(companyUserId).name("F2公司").verified(true).build());
Job job = jobRepository.save(Job.builder() Job job = jobRepository.save(Job.builder()
.companyId(company.getId()).title("F2岗位").description("d").city("上海") .companyId(company.getId()).title("F2岗位").description("d").city("上海")
.employmentType("FULL_TIME").status("PUBLISHED").build()); .employmentType("FULL_TIME").status("PUBLISHED").build());

2
mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java

@ -52,7 +52,7 @@ class CandidateFeedbackServiceTest {
private void mockCompanyOwnership(Long userId, Long companyId) { private void mockCompanyOwnership(Long userId, Long companyId) {
when(companyRepository.findByOwnerUserId(userId)) when(companyRepository.findByOwnerUserId(userId))
.thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).build())); .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).verified(true).build()));
} }
private CandidateFeedbackRequest buildReq() { private CandidateFeedbackRequest buildReq() {

27
mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java

@ -88,17 +88,17 @@ class InterviewServiceTest {
private void mockCompany(Long userId, Long companyId) { private void mockCompany(Long userId, Long companyId) {
when(companyRepository.findByOwnerUserId(userId)) when(companyRepository.findByOwnerUserId(userId))
.thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).name("TestCorp").build())); .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).name("TestCorp").verified(true).build()));
} }
private void mockCompanyById(Long companyId, Long ownerUserId) { private void mockCompanyById(Long companyId, Long ownerUserId) {
when(companyRepository.findById(companyId)) when(companyRepository.findById(companyId))
.thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(ownerUserId).name("TestCorp").build())); .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(ownerUserId).name("TestCorp").verified(true).build()));
} }
private void mockCompanyByOwner(Long ownerUserId, Long companyId) { private void mockCompanyByOwner(Long ownerUserId, Long companyId) {
when(companyRepository.findByOwnerUserId(ownerUserId)) when(companyRepository.findByOwnerUserId(ownerUserId))
.thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(ownerUserId).name("TestCorp").build())); .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(ownerUserId).name("TestCorp").verified(true).build()));
} }
private Interview buildInterview(String status) { private Interview buildInterview(String status) {
@ -123,6 +123,22 @@ class InterviewServiceTest {
// ── 创建面试 ────────────────────────────────────────────── // ── 创建面试 ──────────────────────────────────────────────
@Test
void unverifiedCompanyCannotScheduleInterviewBeforeReadsOrSideEffects() {
User companyUser = User.builder().id(COMPANY_USER_ID).role(User.Role.COMPANY)
.roles(Set.of(User.Role.COMPANY)).build();
when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(companyUser));
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of(
Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).name("TestCorp")
.verified(false).build()));
assertThrows(BusinessException.class, () -> interviewService.createInterview(
COMPANY_USER_ID, APPLICATION_ID, buildCreateReq(), ctx));
verifyNoInteractions(applicationRepository, interviewRepository, historyRepository,
notificationService, auditService);
}
@Test @Test
void createInterview_shouldSucceed() { void createInterview_shouldSucceed() {
mockCompanyUser(COMPANY_USER_ID); mockCompanyUser(COMPANY_USER_ID);
@ -259,6 +275,7 @@ class InterviewServiceTest {
@Test @Test
void getInterview_asOtherCompany_shouldReturn403() { void getInterview_asOtherCompany_shouldReturn403() {
mockCompanyUser(OTHER_COMPANY_USER_ID); mockCompanyUser(OTHER_COMPANY_USER_ID);
mockCompany(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID);
when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED")));
BusinessException ex = assertThrows(BusinessException.class, BusinessException ex = assertThrows(BusinessException.class,
() -> interviewService.getInterview(INTERVIEW_ID, OTHER_COMPANY_USER_ID)); () -> interviewService.getInterview(INTERVIEW_ID, OTHER_COMPANY_USER_ID));
@ -268,6 +285,7 @@ class InterviewServiceTest {
@Test @Test
void getInterview_asOwnCompany_shouldSucceed() { void getInterview_asOwnCompany_shouldSucceed() {
mockCompanyUser(COMPANY_USER_ID); mockCompanyUser(COMPANY_USER_ID);
mockCompany(COMPANY_USER_ID, COMPANY_ID);
when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED"))); when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED")));
assertDoesNotThrow(() -> interviewService.getInterview(INTERVIEW_ID, COMPANY_USER_ID)); assertDoesNotThrow(() -> interviewService.getInterview(INTERVIEW_ID, COMPANY_USER_ID));
} }
@ -522,6 +540,7 @@ class InterviewServiceTest {
@Test @Test
void listMyInterviews_asCompany_shouldReturnOwn() { void listMyInterviews_asCompany_shouldReturnOwn() {
mockCompanyUser(COMPANY_USER_ID); mockCompanyUser(COMPANY_USER_ID);
mockCompany(COMPANY_USER_ID, COMPANY_ID);
when(interviewRepository.findByCompanyUserIdOrderByStartAtDesc(eq(COMPANY_USER_ID), any())) when(interviewRepository.findByCompanyUserIdOrderByStartAtDesc(eq(COMPANY_USER_ID), any()))
.thenReturn(new PageImpl<>(List.of(buildInterview("SCHEDULED")))); .thenReturn(new PageImpl<>(List.of(buildInterview("SCHEDULED"))));
var page = interviewService.listMyInterviews(COMPANY_USER_ID, null, null, null, 0, 20); var page = interviewService.listMyInterviews(COMPANY_USER_ID, null, null, null, 0, 20);
@ -534,4 +553,4 @@ class InterviewServiceTest {
() -> interviewService.listMyInterviews(STUDENT_ID, null, null, null, -1, 20)); () -> interviewService.listMyInterviews(STUDENT_ID, null, null, null, -1, 20));
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt());
} }
} }

6
mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java

@ -84,7 +84,7 @@ class InterviewScoreControllerTest {
studentUserId = createUser(User.Role.STUDENT); studentUserId = createUser(User.Role.STUDENT);
companyUserId = createUser(User.Role.COMPANY); companyUserId = createUser(User.Role.COMPANY);
Company company = companyRepository.save( Company company = companyRepository.save(
Company.builder().ownerUserId(companyUserId).name("评分企业").build()); Company.builder().ownerUserId(companyUserId).name("评分企业").verified(true).build());
companyId = company.getId(); companyId = company.getId();
Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java") Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java")
.description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); .description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build());
@ -95,7 +95,7 @@ class InterviewScoreControllerTest {
otherCompanyUserId = createUser(User.Role.COMPANY); otherCompanyUserId = createUser(User.Role.COMPANY);
otherCompanyId = companyRepository.save(Company.builder() otherCompanyId = companyRepository.save(Company.builder()
.ownerUserId(otherCompanyUserId).name("其他企业").build()).getId(); .ownerUserId(otherCompanyUserId).name("其他企业").verified(true).build()).getId();
} }
@AfterEach @AfterEach
@ -225,4 +225,4 @@ class InterviewScoreControllerTest {
.andExpect(status().isConflict()) .andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(9502)); .andExpect(jsonPath("$.code").value(9502));
} }
} }

4
mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScorePersistenceIntegrationTest.java

@ -90,7 +90,7 @@ class InterviewScorePersistenceIntegrationTest {
studentUserId = createUser(User.Role.STUDENT); studentUserId = createUser(User.Role.STUDENT);
companyUserId = createUser(User.Role.COMPANY); companyUserId = createUser(User.Role.COMPANY);
Company company = companyRepository.save( Company company = companyRepository.save(
Company.builder().ownerUserId(companyUserId).name("评分企业").build()); Company.builder().ownerUserId(companyUserId).name("评分企业").verified(true).build());
companyId = company.getId(); companyId = company.getId();
Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java") Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java")
.description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); .description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build());
@ -269,4 +269,4 @@ class InterviewScorePersistenceIntegrationTest {
private void await(CountDownLatch latch) { private void await(CountDownLatch latch) {
try { latch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } try { latch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
} }
} }

4
mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreServiceTest.java

@ -89,7 +89,7 @@ class InterviewScoreServiceTest {
private void mockCompany(Long userId, Long companyId) { private void mockCompany(Long userId, Long companyId) {
when(companyRepository.findByOwnerUserId(userId)) when(companyRepository.findByOwnerUserId(userId))
.thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).name("Corp").build())); .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).name("Corp").verified(true).build()));
} }
private Interview buildInterview(String status) { private Interview buildInterview(String status) {
@ -374,4 +374,4 @@ class InterviewScoreServiceTest {
// DTO 结构性脱敏:无 actorUserId 字段 // DTO 结构性脱敏:无 actorUserId 字段
assertEquals("COMPANY", items.get(0).getActorRole()); assertEquals("COMPANY", items.get(0).getActorRole());
} }
} }

47
mydreamjob-server/src/test/java/com/mydreamjob/job/JobServiceTest.java

@ -2,6 +2,8 @@ package com.mydreamjob.job;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.dto.JobCreateRequest; import com.mydreamjob.job.dto.JobCreateRequest;
import com.mydreamjob.job.dto.JobStatusRequest; import com.mydreamjob.job.dto.JobStatusRequest;
import com.mydreamjob.job.dto.JobUpdateRequest; import com.mydreamjob.job.dto.JobUpdateRequest;
@ -25,6 +27,9 @@ class JobServiceTest {
@Mock @Mock
private JobRepository jobRepository; private JobRepository jobRepository;
@Mock
private CompanyRepository companyRepository;
@InjectMocks @InjectMocks
private JobService jobService; private JobService jobService;
@ -74,7 +79,7 @@ class JobServiceTest {
req.setTitle("高级 Java 后端"); req.setTitle("高级 Java 后端");
req.setCity("上海"); req.setCity("上海");
when(jobRepository.findById(10L)).thenReturn(Optional.of(draftJob)); when(jobRepository.findByIdAndCompanyId(10L, COMPANY_ID)).thenReturn(Optional.of(draftJob));
when(jobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(jobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
Job updated = jobService.updateJob(COMPANY_ID, 10L, req); Job updated = jobService.updateJob(COMPANY_ID, 10L, req);
@ -93,11 +98,9 @@ class JobServiceTest {
JobUpdateRequest req = new JobUpdateRequest(); JobUpdateRequest req = new JobUpdateRequest();
req.setTitle("恶意修改"); req.setTitle("恶意修改");
when(jobRepository.findById(10L)).thenReturn(Optional.of(draftJob));
BusinessException ex = assertThrows(BusinessException.class, BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.updateJob(OTHER_COMPANY_ID, 10L, req)); () -> jobService.updateJob(OTHER_COMPANY_ID, 10L, req));
assertEquals(ErrorCode.JOB_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt());
verify(jobRepository, never()).save(any()); verify(jobRepository, never()).save(any());
} }
@ -108,7 +111,8 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest(); JobStatusRequest req = new JobStatusRequest();
req.setStatus("PUBLISHED"); req.setStatus("PUBLISHED");
when(jobRepository.findById(10L)).thenReturn(Optional.of(draftJob)); when(jobRepository.findByIdAndCompanyId(10L, COMPANY_ID)).thenReturn(Optional.of(draftJob));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(verifiedCompany()));
when(jobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(jobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
Job result = jobService.transitionStatus(COMPANY_ID, 10L, req); Job result = jobService.transitionStatus(COMPANY_ID, 10L, req);
@ -117,6 +121,21 @@ class JobServiceTest {
assertNotNull(result.getPublishedAt()); assertNotNull(result.getPublishedAt());
} }
@Test
void transitionStatus_unverifiedCompanyCannotPublish() {
JobStatusRequest req = new JobStatusRequest();
req.setStatus("PUBLISHED");
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(
Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_ID).verified(false).build()));
BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.transitionStatus(COMPANY_ID, 10L, req));
assertEquals(ErrorCode.COMPANY_NOT_VERIFIED.getCode(), ex.getErrorCodeInt());
verify(jobRepository, never()).findById(anyLong());
verify(jobRepository, never()).findByIdAndCompanyId(anyLong(), anyLong());
verify(jobRepository, never()).save(any());
}
@Test @Test
void transitionStatus_publishedToClosed_shouldSucceed() { void transitionStatus_publishedToClosed_shouldSucceed() {
Job publishedJob = Job.builder() Job publishedJob = Job.builder()
@ -126,7 +145,7 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest(); JobStatusRequest req = new JobStatusRequest();
req.setStatus("CLOSED"); req.setStatus("CLOSED");
when(jobRepository.findById(10L)).thenReturn(Optional.of(publishedJob)); when(jobRepository.findByIdAndCompanyId(10L, COMPANY_ID)).thenReturn(Optional.of(publishedJob));
when(jobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(jobRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
Job result = jobService.transitionStatus(COMPANY_ID, 10L, req); Job result = jobService.transitionStatus(COMPANY_ID, 10L, req);
@ -145,7 +164,8 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest(); JobStatusRequest req = new JobStatusRequest();
req.setStatus("PUBLISHED"); req.setStatus("PUBLISHED");
when(jobRepository.findById(10L)).thenReturn(Optional.of(closedJob)); when(jobRepository.findByIdAndCompanyId(10L, COMPANY_ID)).thenReturn(Optional.of(closedJob));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(verifiedCompany()));
BusinessException ex = assertThrows(BusinessException.class, BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.transitionStatus(COMPANY_ID, 10L, req)); () -> jobService.transitionStatus(COMPANY_ID, 10L, req));
@ -162,7 +182,7 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest(); JobStatusRequest req = new JobStatusRequest();
req.setStatus("DRAFT"); req.setStatus("DRAFT");
when(jobRepository.findById(10L)).thenReturn(Optional.of(publishedJob)); when(jobRepository.findByIdAndCompanyId(10L, COMPANY_ID)).thenReturn(Optional.of(publishedJob));
BusinessException ex = assertThrows(BusinessException.class, BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.transitionStatus(COMPANY_ID, 10L, req)); () -> jobService.transitionStatus(COMPANY_ID, 10L, req));
@ -175,7 +195,7 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest(); JobStatusRequest req = new JobStatusRequest();
req.setStatus("CLOSED"); req.setStatus("CLOSED");
when(jobRepository.findById(10L)).thenReturn(Optional.of(draftJob)); when(jobRepository.findByIdAndCompanyId(10L, COMPANY_ID)).thenReturn(Optional.of(draftJob));
BusinessException ex = assertThrows(BusinessException.class, BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.transitionStatus(COMPANY_ID, 10L, req)); () -> jobService.transitionStatus(COMPANY_ID, 10L, req));
@ -188,16 +208,21 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest(); JobStatusRequest req = new JobStatusRequest();
req.setStatus("PUBLISHED"); req.setStatus("PUBLISHED");
when(jobRepository.findById(10L)).thenReturn(Optional.of(draftJob)); when(companyRepository.findById(OTHER_COMPANY_ID)).thenReturn(Optional.of(
Company.builder().id(OTHER_COMPANY_ID).ownerUserId(OTHER_COMPANY_ID).verified(true).build()));
BusinessException ex = assertThrows(BusinessException.class, BusinessException ex = assertThrows(BusinessException.class,
() -> jobService.transitionStatus(OTHER_COMPANY_ID, 10L, req)); () -> jobService.transitionStatus(OTHER_COMPANY_ID, 10L, req));
assertEquals(ErrorCode.JOB_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt());
verify(jobRepository, never()).save(any()); verify(jobRepository, never()).save(any());
} }
// ── 辅助 ──────────────────────────────────────────────── // ── 辅助 ────────────────────────────────────────────────
private Company verifiedCompany() {
return Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_ID).verified(true).build();
}
@Test @Test
void getJob_notFound_shouldThrow() { void getJob_notFound_shouldThrow() {
when(jobRepository.findById(999L)).thenReturn(Optional.empty()); when(jobRepository.findById(999L)).thenReturn(Optional.empty());

14
mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java

@ -5,6 +5,8 @@ import com.mydreamjob.audit.AuditLogRepository;
import com.mydreamjob.auth.JwtTokenProvider; import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -40,6 +42,7 @@ class MessageRecallHttpIntegrationTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider; @Autowired private JwtTokenProvider jwtTokenProvider;
@Autowired private UserRepository userRepository; @Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private ConversationRepository conversationRepository; @Autowired private ConversationRepository conversationRepository;
@Autowired private ConversationMemberRepository memberRepository; @Autowired private ConversationMemberRepository memberRepository;
@Autowired private MessageRepository messageRepository; @Autowired private MessageRepository messageRepository;
@ -57,6 +60,7 @@ class MessageRecallHttpIntegrationTest {
private final List<Long> messageIds = new ArrayList<>(); private final List<Long> messageIds = new ArrayList<>();
private final List<Long> conversationIds = new ArrayList<>(); private final List<Long> conversationIds = new ArrayList<>();
private final List<Long> userIds = new ArrayList<>(); private final List<Long> userIds = new ArrayList<>();
private final List<Long> companyIds = new ArrayList<>();
@org.junit.jupiter.api.BeforeEach @org.junit.jupiter.api.BeforeEach
void fixApplicationClock() { void fixApplicationClock() {
@ -79,6 +83,9 @@ class MessageRecallHttpIntegrationTest {
conversationIds.toArray()); conversationIds.toArray());
jdbcTemplate.update("DELETE FROM conversations WHERE id IN (" + marks + ")", conversationIds.toArray()); jdbcTemplate.update("DELETE FROM conversations WHERE id IN (" + marks + ")", conversationIds.toArray());
} }
if (!companyIds.isEmpty()) {
companyRepository.deleteAllById(companyIds);
}
if (!userIds.isEmpty()) { if (!userIds.isEmpty()) {
String marks = String.join(",", userIds.stream().map(id -> "?").toList()); String marks = String.join(",", userIds.stream().map(id -> "?").toList());
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (" + marks + ")", userIds.toArray()); jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (" + marks + ")", userIds.toArray());
@ -87,6 +94,7 @@ class MessageRecallHttpIntegrationTest {
messageIds.clear(); messageIds.clear();
conversationIds.clear(); conversationIds.clear();
userIds.clear(); userIds.clear();
companyIds.clear();
} }
private Long user(String label, User.Role role) { private Long user(String label, User.Role role) {
@ -95,6 +103,12 @@ class MessageRecallHttpIntegrationTest {
.nickname(label).role(role).roles(Set.of(role)) .nickname(label).role(role).roles(Set.of(role))
.active(true).verified(true).build()); .active(true).verified(true).build());
userIds.add(saved.getId()); userIds.add(saved.getId());
if (role == User.Role.COMPANY) {
Company company = companyRepository.saveAndFlush(Company.builder()
.ownerUserId(saved.getId()).name(label + " enterprise")
.verified(true).build());
companyIds.add(company.getId());
}
return saved.getId(); return saved.getId();
} }

4
mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java

@ -112,7 +112,7 @@ class MessagingServiceTest {
when(userRepository.findById(USER_A)).thenReturn(Optional.of(user(USER_A, User.Role.COMPANY))); when(userRepository.findById(USER_A)).thenReturn(Optional.of(user(USER_A, User.Role.COMPANY)));
when(userRepository.findById(USER_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT))); when(userRepository.findById(USER_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT)));
when(companyRepository.findByOwnerUserId(USER_A)) when(companyRepository.findByOwnerUserId(USER_A))
.thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(USER_A).build())); .thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(USER_A).verified(true).build()));
when(applicationMatchRecordRepository.existsMatchedApplication(1L, USER_B)).thenReturn(false); when(applicationMatchRecordRepository.existsMatchedApplication(1L, USER_B)).thenReturn(false);
when(memberRepository.findByUserId(USER_A)).thenReturn(List.of()); when(memberRepository.findByUserId(USER_A)).thenReturn(List.of());
when(userRepository.existsById(USER_B)).thenReturn(true); when(userRepository.existsById(USER_B)).thenReturn(true);
@ -129,7 +129,7 @@ class MessagingServiceTest {
when(userRepository.findById(USER_A)).thenReturn(Optional.of(user(USER_A, User.Role.COMPANY))); when(userRepository.findById(USER_A)).thenReturn(Optional.of(user(USER_A, User.Role.COMPANY)));
when(userRepository.findById(USER_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT))); when(userRepository.findById(USER_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT)));
when(companyRepository.findByOwnerUserId(USER_A)) when(companyRepository.findByOwnerUserId(USER_A))
.thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(USER_A).build())); .thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(USER_A).verified(true).build()));
when(applicationMatchRecordRepository.existsMatchedApplication(1L, USER_B)).thenReturn(true); when(applicationMatchRecordRepository.existsMatchedApplication(1L, USER_B)).thenReturn(true);
stubNewConversationCreation(); stubNewConversationCreation();
when(userRepository.existsById(USER_B)).thenReturn(true); when(userRepository.existsById(USER_B)).thenReturn(true);

4
mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java

@ -2,6 +2,8 @@ package com.mydreamjob.notification;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@ -23,6 +25,8 @@ class NotificationServiceTest {
@Mock private NotificationRepository notificationRepository; @Mock private NotificationRepository notificationRepository;
@Mock private PushProvider pushProvider; @Mock private PushProvider pushProvider;
@Mock private UserRepository userRepository;
@Mock private CompanyRepository companyRepository;
@InjectMocks private NotificationService notificationService; @InjectMocks private NotificationService notificationService;
private static final Long USER_A = 100L; private static final Long USER_A = 100L;

19
mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java

@ -87,12 +87,12 @@ class OfferServiceTest {
private void mockCompany(Long userId, Long companyId) { private void mockCompany(Long userId, Long companyId) {
when(companyRepository.findByOwnerUserId(userId)) when(companyRepository.findByOwnerUserId(userId))
.thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).build())); .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(userId).verified(true).build()));
} }
private void mockCompanyById(Long companyId, Long ownerUserId) { private void mockCompanyById(Long companyId, Long ownerUserId) {
when(companyRepository.findById(companyId)) when(companyRepository.findById(companyId))
.thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(ownerUserId).build())); .thenReturn(Optional.of(Company.builder().id(companyId).ownerUserId(ownerUserId).verified(true).build()));
} }
private Offer buildOffer(String status) { private Offer buildOffer(String status) {
@ -116,6 +116,21 @@ class OfferServiceTest {
// ── 创建 ────────────────────────────────────────────────── // ── 创建 ──────────────────────────────────────────────────
@Test
void unverifiedCompanyCannotCreateOfferBeforeCandidateReadsOrSideEffects() {
User companyUser = User.builder().id(COMPANY_USER_ID).role(User.Role.COMPANY)
.roles(Set.of(User.Role.COMPANY)).build();
when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(companyUser));
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of(
Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_USER_ID).verified(false).build()));
assertThrows(BusinessException.class,
() -> offerService.createOffer(COMPANY_USER_ID, buildCreateReq(), ctx));
verifyNoInteractions(applicationRepository, offerRepository, historyRepository, reminderRepository,
counterOfferRepository, notificationService, auditService, scoringRefreshRequestService);
}
@Test @Test
void createOffer_shouldSucceed() { void createOffer_shouldSucceed() {
mockCompanyUser(COMPANY_USER_ID); mockCompanyUser(COMPANY_USER_ID);

156
mydreamjob-server/src/test/java/com/mydreamjob/phaseb/PhaseBVerifiedCompanyGateContractTest.java

@ -0,0 +1,156 @@
package com.mydreamjob.phaseb;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Phase B 路由库存与统一业务服务门禁的结构性契约。 */
class PhaseBVerifiedCompanyGateContractTest {
private static final Path MAIN = Path.of("src/main/java/com/mydreamjob");
private static final Map<String, List<String>> ROUTE_INVENTORY = Map.ofEntries(
Map.entry("application/ApplicationController.java", List.of(
"@GetMapping(\"/applications/{id}/timeline\")",
"@GetMapping(\"/company/applications\")",
"@PatchMapping(\"/applications/{id}/status\")",
"@GetMapping(\"/company/candidates\")",
"@GetMapping(\"/company/candidates/{applicationId}\")",
"@GetMapping(\"/company/candidates/{applicationId}/resume\")")),
Map.entry("job/JobController.java", List.of(
"@PostMapping", "@PutMapping(\"/{jobId}\")", "@PatchMapping(\"/{jobId}/status\")")),
Map.entry("candidatescore/CandidateScoringController.java", List.of(
"@GetMapping(\"/company/candidates/{applicationId}/scoring\")")),
Map.entry("feedback/CandidateFeedbackController.java", List.of(
"@PostMapping(\"/applications/{applicationId}/feedback\")",
"@GetMapping(\"/applications/{applicationId}/feedback\")",
"@PutMapping(\"/applications/{applicationId}/feedback\")",
"@DeleteMapping(\"/applications/{applicationId}/feedback\")")),
Map.entry("talentdiscovery/TalentDiscoveryController.java", List.of(
"@GetMapping(\"/companies/me/jobs/{jobId}/talent-recommendations\")")),
Map.entry("talentinvitation/TalentInvitationController.java", List.of(
"@PostMapping(\"/companies/me/jobs/{jobId}/talent-invitations\")",
"@GetMapping(\"/companies/me/talent-invitations\")",
"@GetMapping(\"/companies/me/talent-invitations/{invitationId}\")")),
Map.entry("interview/InterviewController.java", List.of(
"@PostMapping(\"/applications/{applicationId}/interviews\")",
"@GetMapping(\"/applications/{applicationId}/interviews\")",
"@GetMapping(\"/interviews/me\")",
"@GetMapping(\"/interviews/{interviewId}\")",
"@PutMapping(\"/interviews/{interviewId}\")",
"@PostMapping(\"/interviews/{interviewId}/cancel\")",
"@PostMapping(\"/interviews/{interviewId}/complete\")",
"@GetMapping(\"/interviews/{interviewId}/timeline\")")),
Map.entry("interviewscore/InterviewScoreController.java", List.of(
"@PutMapping(\"/interviews/{interviewId}/score\")",
"@GetMapping(\"/interviews/{interviewId}/score\")",
"@PostMapping(\"/interviews/{interviewId}/score/submit\")",
"@GetMapping(\"/interviews/{interviewId}/score/history\")")),
Map.entry("candidatescore/dashboard/CompanyScoringDashboardController.java", List.of(
"@GetMapping(\"/company/scoring/dashboard/overview\")",
"@GetMapping(\"/company/scoring/dashboard/job-rankings\")",
"@GetMapping(\"/company/scoring/dashboard/job-rankings/{jobId}\")",
"@GetMapping(\"/company/scoring/dashboard/candidate-rankings\")",
"@GetMapping(\"/company/scoring/dashboard/candidate-rankings/{applicationId}\")",
"@GetMapping(\"/company/scoring/dashboard/trend\")")),
Map.entry("offer/OfferController.java", List.of(
"@PostMapping", "@GetMapping", "@PutMapping(\"/{id:\\\\d+}\")",
"@PatchMapping(\"/{id:\\\\d+}/status\")", "@PostMapping(\"/{id:\\\\d+}/remind\")",
"@GetMapping(\"/{id:\\\\d+}/timeline\")",
"@GetMapping(\"/{id:\\\\d+}/counter-offers\")",
"@PostMapping(\"/{id:\\\\d+}/counter-offers\")",
"@PatchMapping(\"/{id:\\\\d+}/counter-offers/{coId:\\\\d+}/accept\")",
"@PatchMapping(\"/{id:\\\\d+}/counter-offers/{coId:\\\\d+}/reject\")",
"@GetMapping(\"/compare\")", "@GetMapping(\"/statistics\")")),
Map.entry("recommendation/JobRecommendationController.java", List.of(
"@GetMapping(\"/companies/me/jobs/{jobId}/recommendation-profile\")",
"@PutMapping(\"/companies/me/jobs/{jobId}/recommendation-profile\")")),
Map.entry("bidirectionalmatch/ApplicationMatchController.java", List.of(
"@PutMapping(\"/applications/{id}/hr-suitability-score\")",
"@GetMapping(\"/applications/{id}/match\")")),
Map.entry("bidirectionalmatch/MatchConversationController.java", List.of(
"@PostMapping(\"/applications/{id}/conversation\")")),
Map.entry("messaging/MessagingController.java", List.of(
"@GetMapping", "@GetMapping(\"/{id}\")", "@PostMapping", "@PostMapping(\"/messages\")",
"@GetMapping(\"/{id}/messages\")", "@PostMapping(\"/{id}/read\")",
"@PostMapping(\"/{conversationId}/messages/{messageId}/recall\")")),
Map.entry("notification/NotificationController.java", List.of(
"@GetMapping", "@PostMapping(\"/{id}/read\")", "@PostMapping(\"/read-all\")",
"@GetMapping(\"/unread-count\")")),
Map.entry("company/CompanyController.java", List.of(
"@GetMapping(\"/me/onboarding\")", "@PostMapping(\"/me\")",
"@GetMapping(\"/me\")", "@PutMapping(\"/me\")")),
Map.entry("verification/CompanyVerificationController.java", List.of(
"@PostMapping(\"/companies/me/verification\")",
"@GetMapping(\"/companies/me/verification\")")),
Map.entry("bidirectionalmatch/MatchRuleController.java", List.of(
"@GetMapping(\"/companies/me/match-rule\")", "@PutMapping(\"/companies/me/match-rule\")"))
);
private static final List<String> SENSITIVE_SERVICES = List.of(
"job/JobService.java",
"application/ApplicationService.java",
"application/CompanyResumeAccessPolicy.java",
"candidatescore/CandidateScoringEngineService.java",
"feedback/CandidateFeedbackService.java",
"talentdiscovery/TalentDiscoveryService.java",
"talentinvitation/TalentInvitationService.java",
"interview/InterviewService.java",
"interviewscore/InterviewScoreService.java",
"offer/OfferService.java",
"recommendation/JobRecommendationService.java",
"bidirectionalmatch/BidirectionalMatchService.java",
"bidirectionalmatch/MatchConversationService.java",
"bidirectionalmatch/MatchRuleService.java",
"messaging/MessagingService.java",
"notification/NotificationService.java",
"candidatescore/dashboard/CompanyScoringDashboardService.java"
);
@Test
void routeInventoryExistsAndEachSensitiveBusinessServiceUsesTheUnifiedGate() throws IOException {
for (Map.Entry<String, List<String>> entry : ROUTE_INVENTORY.entrySet()) {
String source = read(entry.getKey());
for (String route : entry.getValue()) {
assertTrue(source.contains(route), "路由库存缺失:" + entry.getKey() + " " + route);
}
}
for (String service : SENSITIVE_SERVICES) {
String source = read(service);
boolean usesUnifiedGate = source.contains("VerifiedCompanyGuard.")
|| (service.equals("application/ApplicationService.java")
&& source.contains("requireVerifiedCompany("));
assertTrue(usesUnifiedGate,
"服务未调用统一企业认证门禁:" + service);
}
String gate = read("company/VerifiedCompanyGuard.java");
assertTrue(gate.contains("company.getVerified()"), "门禁必须读取 companies.verified");
assertTrue(!gate.contains("user.getVerified()") && !gate.contains("actor.getVerified()"),
"门禁不得以 users.is_verified 替代企业认证状态");
}
@Test
void onboardingAndOwnDraftJobRoutesRemainAvailable() throws IOException {
String company = read("company/CompanyController.java");
assertTrue(company.contains("/me/onboarding"));
assertTrue(company.contains("/me"));
String verification = read("verification/CompanyVerificationController.java");
assertTrue(verification.contains("/companies/me/verification"));
String jobs = read("job/JobController.java");
assertTrue(jobs.contains("@PostMapping"));
assertTrue(jobs.contains("@PutMapping(\"/{jobId}\")"));
assertTrue(jobs.contains("@GetMapping(\"/{jobId}\")"));
}
private String read(String relative) throws IOException {
return Files.readString(MAIN.resolve(relative));
}
}

336
mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGateMySqlBlackboxTest.java

@ -0,0 +1,336 @@
package com.mydreamjob.phaseb;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
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.privacy.ResumeVisibility;
import com.mydreamjob.privacy.StudentPrivacySettings;
import com.mydreamjob.privacy.StudentPrivacySettingsRepository;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import com.mydreamjob.profile.storage.LocalResumeStorageService;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.verification.CompanyVerification;
import com.mydreamjob.verification.CompanyVerificationRepository;
import org.junit.jupiter.api.AfterEach;
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.context.TestConfiguration;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.annotation.DirtiesContext;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/** Phase B 黑盒:真实 MySQL 8、真实 HTTP socket 与正式 Security/Controller/Service 链路。 */
@Testcontainers(disabledWithoutDocker = false)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"spring.rabbitmq.listener.simple.auto-startup=false",
"spring.rabbitmq.listener.direct.auto-startup=false"
})
@ActiveProfiles("mysql-it")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class VerifiedCompanyGateMySqlBlackboxTest {
private static final AtomicInteger RESUME_READS = new AtomicInteger();
@TestConfiguration
static class CountingResumeStorageConfiguration {
@Bean
@Primary
ResumeStorageService countingResumeStorageService(LocalResumeStorageService delegate) {
return new ResumeStorageService() {
@Override
public String store(Long userId, org.springframework.web.multipart.MultipartFile file) {
return delegate.store(userId, file);
}
@Override
public byte[] read(String storagePath) {
RESUME_READS.incrementAndGet();
return delegate.read(storagePath);
}
@Override
public void delete(String storagePath) {
delegate.delete(storagePath);
}
};
}
}
private static final AtomicLong SEQUENCE = new AtomicLong();
private static final List<String> SNAPSHOT_TABLES = List.of(
"users", "user_roles", "companies", "company_verifications", "jobs", "applications",
"student_profiles", "student_privacy_settings", "resume_versions", "notifications",
"audit_logs", "offers", "interviews", "messages", "auth_tokens");
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("phase_b_gate_blackbox")
.withUsername("phase_b_gate_test")
.withPassword("phase-b-gate-test-only");
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
registry.add("spring.rabbitmq.listener.simple.auto-startup", () -> "false");
registry.add("spring.rabbitmq.listener.direct.auto-startup", () -> "false");
}
@Autowired private TestRestTemplate http;
@LocalServerPort private int port;
@Autowired private ObjectMapper objectMapper;
@Autowired private AuthSessionService authSessionService;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private CompanyVerificationRepository verificationRepository;
@Autowired private JobRepository jobRepository;
@Autowired private ApplicationRepository applicationRepository;
@Autowired private StudentProfileRepository profileRepository;
@Autowired private StudentPrivacySettingsRepository privacyRepository;
@Autowired private ResumeStorageService resumeStorageService;
@Autowired private JdbcTemplate jdbc;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private final List<Long> userIds = new ArrayList<>();
private final List<Long> companyIds = new ArrayList<>();
private final List<Long> jobIds = new ArrayList<>();
private final List<Long> applicationIds = new ArrayList<>();
private final List<String> storagePaths = new ArrayList<>();
@Test
@SuppressWarnings("unchecked")
void unverifiedPendingAndRejectedCompaniesAreDeniedOverHttpWithoutDatabaseSideEffects() throws Exception {
ValueOperations<String, Object> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(redisTemplate.hasKey(anyString())).thenReturn(false);
for (String state : new String[]{"UNVERIFIED", "PENDING", "REJECTED"}) {
CompanyFixture fixture = company(state, false);
Job draft = job(fixture.companyId(), "DRAFT");
List<Map<String, Object>> beforeRequest = snapshot();
String body = "{\"status\":\"PUBLISHED\"}";
ResponseEntity<String> response = exchange("/api/v1/jobs/" + draft.getId() + "/status",
HttpMethod.PATCH, fixture.token(), body);
assertGateDenied(response, fixture.phone());
assertThat(jdbc.queryForObject("SELECT status FROM jobs WHERE id = ?", String.class, draft.getId()))
.as("拒绝后岗位状态保持不变:" + state).isEqualTo("DRAFT");
assertThat(snapshot()).as("拒绝发布不应产生任何数据库写入:" + state).isEqualTo(beforeRequest);
}
CompanyFixture pending = company("PENDING", false);
Long studentId = user("STUDENT", "黑盒候选人");
Job published = job(pending.companyId(), "PUBLISHED");
Long applicationId = applicationRepository.saveAndFlush(Application.builder()
.jobId(published.getId()).studentUserId(studentId).status("SUBMITTED").build()).getId();
applicationIds.add(applicationId);
MockMultipartFile resume = new MockMultipartFile("file", "phase-b-resume.pdf", "application/pdf",
"%PDF-1.4 phase-b-blackbox".getBytes(StandardCharsets.UTF_8));
String storagePath = resumeStorageService.store(studentId, resume);
storagePaths.add(storagePath);
profileRepository.saveAndFlush(StudentProfile.builder()
.userId(studentId).resumeUrl(storagePath).resumeOriginalName("private-resume.pdf")
.resumeContentType("application/pdf").resumeSize(resume.getSize()).build());
privacyRepository.saveAndFlush(StudentPrivacySettings.builder()
.studentUserId(studentId).resumeVisibility(ResumeVisibility.APPLIED_COMPANIES)
.contactVisible(false).version(0L).build());
// 若认证门禁被移除,APPLIED_COMPANIES 下的有效投递和有效文件会使下载返回 200。
List<Map<String, Object>> before = snapshot();
ResponseEntity<String> list = exchange("/api/v1/company/candidates", HttpMethod.GET,
pending.token(), null);
assertGateDenied(list, pending.phone());
ResponseEntity<String> detail = exchange("/api/v1/company/candidates/" + applicationId,
HttpMethod.GET, pending.token(), null);
assertGateDenied(detail, pending.phone());
RESUME_READS.set(0);
ResponseEntity<byte[]> download = http.exchange(url("/api/v1/company/candidates/" + applicationId + "/resume"),
HttpMethod.GET, authorized(pending.token(), null), byte[].class);
assertThat(download.getStatusCode().value()).isEqualTo(403);
assertThat(download.getHeaders().getContentType()).isNotEqualTo(MediaType.APPLICATION_PDF);
assertThat(RESUME_READS.get()).as("认证拒绝必须早于真实文件读取").isZero();
assertThat(snapshot()).as("认证拒绝路径数据库逐表快照必须零变化").isEqualTo(before);
}
@Test
void verifiedCompanyCanPublishOwnJobButCannotProbeAnotherCompaniesJob() throws Exception {
CompanyFixture companyA = company("VERIFIED_A", true);
CompanyFixture companyB = company("VERIFIED_B", true);
Job ownDraft = job(companyA.companyId(), "DRAFT");
Job foreignDraft = job(companyB.companyId(), "DRAFT");
ResponseEntity<String> publish = exchange("/api/v1/jobs/" + ownDraft.getId() + "/status",
HttpMethod.PATCH, companyA.token(), "{\"status\":\"PUBLISHED\"}");
assertThat(publish.getStatusCode().value()).isEqualTo(200);
assertThat(jdbc.queryForObject("SELECT status FROM jobs WHERE id = ?", String.class, ownDraft.getId()))
.isEqualTo("PUBLISHED");
List<Map<String, Object>> before = snapshot();
Long missingId = foreignDraft.getId() + 900_000L;
for (String method : new String[]{"GET", "PUT"}) {
ResponseEntity<String> foreign = exchange("/api/v1/jobs/" + foreignDraft.getId(),
HttpMethod.valueOf(method), companyA.token(), method.equals("PUT")
? "{\"title\":\"attempted cross-tenant update\"}" : null);
ResponseEntity<String> missing = exchange("/api/v1/jobs/" + missingId,
HttpMethod.valueOf(method), companyA.token(), method.equals("PUT")
? "{\"title\":\"attempted missing update\"}" : null);
assertThat(foreign.getStatusCode().value()).isEqualTo(404);
assertThat(missing.getStatusCode().value()).isEqualTo(404);
assertThat(code(foreign)).isEqualTo(ErrorCode.JOB_NOT_FOUND.getCode());
assertThat(code(missing)).isEqualTo(ErrorCode.JOB_NOT_FOUND.getCode());
}
ResponseEntity<String> foreignPublish = exchange("/api/v1/jobs/" + foreignDraft.getId() + "/status",
HttpMethod.PATCH, companyA.token(), "{\"status\":\"PUBLISHED\"}");
assertThat(foreignPublish.getStatusCode().value()).isEqualTo(404);
assertThat(code(foreignPublish)).isEqualTo(ErrorCode.JOB_NOT_FOUND.getCode());
assertThat(snapshot()).as("跨企业拒绝路径数据库逐表快照必须零变化").isEqualTo(before);
assertThat(jdbc.queryForObject("SELECT status FROM jobs WHERE id = ?", String.class, foreignDraft.getId()))
.isEqualTo("DRAFT");
}
private CompanyFixture company(String verificationState, boolean verified) {
Long userId = user("COMPANY", "Phase B " + verificationState);
Company company = companyRepository.saveAndFlush(Company.builder()
.ownerUserId(userId).name("Phase B " + verificationState).verified(verified).build());
companyIds.add(company.getId());
if (!"UNVERIFIED".equals(verificationState)) {
verificationRepository.saveAndFlush(CompanyVerification.builder()
.companyId(company.getId()).submittedBy(userId)
.status(verified ? "APPROVED" : verificationState).build());
}
User persisted = userRepository.findById(userId).orElseThrow();
String token = authSessionService.createSession(persisted, Instant.now(), null).accessToken();
return new CompanyFixture(userId, company.getId(), persisted.getPhone(), "Bearer " + token);
}
private Long user(String roleName, String nickname) {
User.Role role = User.Role.valueOf(roleName);
String phone = "199" + String.format("%08d", SEQUENCE.incrementAndGet());
User saved = userRepository.saveAndFlush(User.builder().phone(phone).nickname(nickname)
.role(role).roles(Set.of(role)).active(true)
// users.is_verified is deliberately true; it is not the company gate authority.
.verified(true).build());
userIds.add(saved.getId());
return saved.getId();
}
private Job job(Long companyId, String status) {
Job saved = jobRepository.saveAndFlush(Job.builder().companyId(companyId)
.title("Phase B blackbox job").description("fixture").city("Shanghai")
.employmentType("FULL_TIME").status(status).build());
jobIds.add(saved.getId());
return saved;
}
private ResponseEntity<String> exchange(String path, HttpMethod method, String token, String body) {
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, token);
if (body != null) headers.setContentType(MediaType.APPLICATION_JSON);
return http.exchange(url(path), method, new HttpEntity<>(body, headers), String.class);
}
private String url(String path) {
return "http://127.0.0.1:" + port + path;
}
private HttpEntity<String> authorized(String token, String body) {
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, token);
return new HttpEntity<>(body, headers);
}
private void assertGateDenied(ResponseEntity<String> response, String phone) throws Exception {
assertThat(response.getStatusCode().value()).isEqualTo(403);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("code").asInt()).isEqualTo(ErrorCode.COMPANY_NOT_VERIFIED.getCode());
assertThat(body.path("message").asText()).isEqualTo(ErrorCode.COMPANY_NOT_VERIFIED.getMessage());
String serialized = response.getBody() == null ? "" : response.getBody();
assertThat(serialized).doesNotContain(phone, "resumeUrl", "storagePath", "SQLException", "SQLState", "at com.");
}
private int code(ResponseEntity<String> response) throws Exception {
JsonNode body = objectMapper.readTree(response.getBody());
return body.path("code").asInt();
}
private List<Map<String, Object>> snapshot() {
List<Map<String, Object>> rows = new ArrayList<>();
for (String table : SNAPSHOT_TABLES) {
rows.add(Map.of("table", table, "rows", jdbc.queryForList("SELECT * FROM " + table + " ORDER BY 1")));
}
return rows;
}
@AfterEach
void cleanupExactFixtures() {
storagePaths.forEach(resumeStorageService::delete);
for (Long id : applicationIds) {
jdbc.update("DELETE FROM student_privacy_settings WHERE student_user_id IN " +
"(SELECT student_user_id FROM applications WHERE id = ?)", id);
jdbc.update("DELETE FROM applications WHERE id = ?", id);
}
for (Long jobId : jobIds) jdbc.update("DELETE FROM jobs WHERE id = ?", jobId);
if (!companyIds.isEmpty()) {
for (Long companyId : companyIds) {
jdbc.update("DELETE FROM company_verifications WHERE company_id = ?", companyId);
jdbc.update("DELETE FROM companies WHERE id = ?", companyId);
}
}
for (Long userId : userIds) {
jdbc.update("DELETE FROM student_profiles WHERE user_id = ?", userId);
jdbc.update("DELETE FROM student_privacy_settings WHERE student_user_id = ?", userId);
jdbc.update("DELETE FROM auth_tokens WHERE user_id = ?", userId);
jdbc.update("DELETE FROM user_roles WHERE user_id = ?", userId);
jdbc.update("DELETE FROM users WHERE id = ?", userId);
}
userIds.clear(); companyIds.clear(); jobIds.clear(); applicationIds.clear(); storagePaths.clear();
}
private record CompanyFixture(Long userId, Long companyId, String phone, String token) {}
}

193
mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGatePersistenceIntegrationTest.java

@ -0,0 +1,193 @@
package com.mydreamjob.phaseb;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
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.job.JobService;
import com.mydreamjob.job.dto.JobStatusRequest;
import com.mydreamjob.verification.CompanyVerification;
import com.mydreamjob.verification.CompanyVerificationRepository;
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 org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** Real JPA rows prove verification requests do not substitute for companies.verified. */
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@Transactional
class VerifiedCompanyGatePersistenceIntegrationTest {
private static final AtomicInteger PHONE_SEQUENCE = new AtomicInteger();
@Autowired private UserRepository userRepository;
@Autowired private AuthSessionService authSessionService;
@Autowired private ObjectMapper objectMapper;
@Autowired private CompanyRepository companyRepository;
@Autowired private CompanyVerificationRepository verificationRepository;
@Autowired private JobRepository jobRepository;
@Autowired private JobService jobService;
@Autowired private MockMvc mockMvc;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@Test
void unverifiedPendingAndRejectedApplicationsDoNotEnableJobPublication() {
for (String applicationStatus : new String[]{null, "PENDING", "REJECTED"}) {
String stateLabel = applicationStatus == null ? "UNVERIFIED" : applicationStatus;
Long ownerUserId = createCompanyOwner();
Company company = companyRepository.saveAndFlush(Company.builder()
.ownerUserId(ownerUserId).name("Phase B fixture " + stateLabel).verified(false).build());
if (applicationStatus != null) {
verificationRepository.saveAndFlush(CompanyVerification.builder()
.companyId(company.getId()).submittedBy(ownerUserId).status(applicationStatus).build());
}
Job draft = jobRepository.saveAndFlush(Job.builder()
.companyId(company.getId()).title("Draft " + stateLabel)
.description("Phase B persistence fixture")
.city("Shanghai").employmentType("FULL_TIME").status("DRAFT").build());
try {
mockMvc.perform(patch("/jobs/{jobId}/status", draft.getId())
.header("Authorization", bearerFor(ownerUserId))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PUBLISHED\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_VERIFIED.getCode()))
.andExpect(jsonPath("$.message").value(ErrorCode.COMPANY_NOT_VERIFIED.getMessage()));
} catch (Exception exception) {
throw new AssertionError("unverified company publish request failed unexpectedly", exception);
}
assertEquals("DRAFT", jobRepository.findById(draft.getId()).orElseThrow().getStatus());
}
}
@Test
void verifiedCompanyCanPublishWithPersistedApprovedRecord() {
Long ownerUserId = createCompanyOwner();
Company company = companyRepository.saveAndFlush(Company.builder()
.ownerUserId(ownerUserId).name("Phase B verified fixture").verified(true).build());
verificationRepository.saveAndFlush(CompanyVerification.builder()
.companyId(company.getId()).submittedBy(ownerUserId).status("APPROVED").build());
Job draft = jobRepository.saveAndFlush(Job.builder()
.companyId(company.getId()).title("Verified draft")
.description("Phase B persistence fixture")
.city("Shanghai").employmentType("FULL_TIME").status("DRAFT").build());
Job published = jobService.transitionStatus(company.getId(), draft.getId(), publishRequest());
assertEquals("PUBLISHED", published.getStatus());
}
@Test
void unverifiedCompanyCannotProbeJobIdsWhenPublishing() throws Exception {
Long ownerUserId = createCompanyOwner();
companyRepository.saveAndFlush(Company.builder()
.ownerUserId(ownerUserId).name("Phase B owner fixture").verified(false).build());
Long otherOwnerUserId = createCompanyOwner();
Company otherCompany = companyRepository.saveAndFlush(Company.builder()
.ownerUserId(otherOwnerUserId).name("Phase B other fixture").verified(true).build());
Job foreignDraft = jobRepository.saveAndFlush(Job.builder()
.companyId(otherCompany.getId()).title("Foreign draft")
.description("Phase B persistence fixture")
.city("Shanghai").employmentType("FULL_TIME").status("DRAFT").build());
String authorization = bearerFor(ownerUserId);
for (Long jobId : new Long[]{foreignDraft.getId(), foreignDraft.getId() + 100_000L}) {
MvcResult result = mockMvc.perform(patch("/jobs/{jobId}/status", jobId)
.header("Authorization", authorization)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PUBLISHED\"}"))
.andReturn();
assertEquals(403, result.getResponse().getStatus());
assertEquals(ErrorCode.COMPANY_NOT_VERIFIED.getCode(),
objectMapper.readTree(result.getResponse().getContentAsString()).path("code").asInt());
}
assertEquals("DRAFT", jobRepository.findById(foreignDraft.getId()).orElseThrow().getStatus());
}
@Test
void companyJobReadAndUpdateDoNotDistinguishForeignFromMissingJobs() throws Exception {
Long ownerUserId = createCompanyOwner();
companyRepository.saveAndFlush(Company.builder()
.ownerUserId(ownerUserId).name("Phase B owner fixture").verified(false).build());
Long otherOwnerUserId = createCompanyOwner();
Company otherCompany = companyRepository.saveAndFlush(Company.builder()
.ownerUserId(otherOwnerUserId).name("Phase B other fixture").verified(true).build());
Job foreignDraft = jobRepository.saveAndFlush(Job.builder()
.companyId(otherCompany.getId()).title("Foreign draft")
.description("Phase B persistence fixture")
.city("Shanghai").employmentType("FULL_TIME").status("DRAFT").build());
String authorization = bearerFor(ownerUserId);
Long missingJobId = foreignDraft.getId() + 100_000L;
for (String method : new String[]{"GET", "PUT"}) {
for (Long jobId : new Long[]{foreignDraft.getId(), missingJobId}) {
var request = method.equals("GET")
? org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.get("/jobs/{jobId}", jobId)
: org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.put("/jobs/{jobId}", jobId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Updated title\"}");
MvcResult result = mockMvc.perform(request.header("Authorization", authorization)).andReturn();
assertEquals(404, result.getResponse().getStatus());
assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(),
objectMapper.readTree(result.getResponse().getContentAsString()).path("code").asInt());
}
}
assertEquals("Foreign draft", jobRepository.findById(foreignDraft.getId()).orElseThrow().getTitle());
}
private Long createCompanyOwner() {
int suffix = PHONE_SEQUENCE.incrementAndGet();
User user = User.builder()
.phone("00000000" + String.format("%03d", suffix))
.nickname("Phase B fixture")
.role(User.Role.COMPANY)
.roles(Set.of(User.Role.COMPANY))
.active(true)
// Deliberately true: users.is_verified is not enterprise verification authority.
.verified(true)
.build();
return userRepository.saveAndFlush(user).getId();
}
private JobStatusRequest publishRequest() {
JobStatusRequest request = new JobStatusRequest();
request.setStatus("PUBLISHED");
return request;
}
private String bearerFor(Long userId) {
String token = authSessionService.createSession(
userRepository.findById(userId).orElseThrow(), Instant.now(), null).accessToken();
return "Bearer " + token;
}
}

49
mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGuardTest.java

@ -0,0 +1,49 @@
package com.mydreamjob.phaseb;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class VerifiedCompanyGuardTest {
private final ErrorHttpStatusRegistry statuses = new ErrorHttpStatusRegistry();
@Test
void verifiedCompanyPassesWithoutMutatingTheCompany() {
Company company = Company.builder().id(41L).ownerUserId(72L).verified(true).build();
assertThat(VerifiedCompanyGuard.requireVerified(company)).isSameAs(company);
assertThat(company.getVerified()).isTrue();
}
@Test
void unverifiedCompanyGetsOneStableForbiddenBusinessError() {
for (Company company : new Company[]{
Company.builder().verified(false).build(),
Company.builder().verified(null).build()}) {
assertThatThrownBy(() -> VerifiedCompanyGuard.requireVerified(company))
.isInstanceOf(BusinessException.class)
.satisfies(error -> {
BusinessException business = (BusinessException) error;
assertThat(business.getErrorCodeInt()).isEqualTo(ErrorCode.COMPANY_NOT_VERIFIED.getCode());
assertThat(business.getErrorMessage()).isEqualTo(ErrorCode.COMPANY_NOT_VERIFIED.getMessage());
assertThat(statuses.resolve(business.getErrorCodeInt())).isEqualTo(HttpStatus.FORBIDDEN);
});
}
}
@Test
void missingCompanyDoesNotBecomeVerified() {
assertThatThrownBy(() -> VerifiedCompanyGuard.requireVerified(null))
.isInstanceOf(BusinessException.class)
.satisfies(error -> assertThat(((BusinessException) error).getErrorCodeInt())
.isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode()));
}
}

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

@ -84,6 +84,22 @@ class TalentInvitationServiceTest {
return request; return request;
} }
@Test
void unverifiedCompanyCannotResolveCandidateOrCreateInvitation() {
when(userRepository.findById(1L)).thenReturn(Optional.of(companyUser));
when(companyRepository.findByOwnerUserId(1L)).thenReturn(Optional.of(
Company.builder().id(10L).ownerUserId(1L).verified(false).build()));
assertThatThrownBy(() -> service.create(1L, 20L,
request("candidate-key", "欢迎了解岗位", "3c989a31-6774-4520-9700-e58416cc1890"), null))
.isInstanceOf(BusinessException.class)
.extracting("errorCode").isEqualTo(ErrorCode.COMPANY_NOT_VERIFIED.getCode());
verifyNoInteractions(jobRepository, profileRepository, candidateResolver, moderationService,
invitationRepository, historyRepository, conversationService, expiryService,
blockRelationRepository, auditService, securityAuditService);
}
@Test @Test
void safeMessage_createsPendingInvitationAndHistory() { void safeMessage_createsPendingInvitationAndHistory() {
commonSetup(); commonSetup();

Loading…
Cancel
Save