Browse Source

feat: gate recruiting operations on company verification

integration/lxq-n03-n05-20261008
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.auth.User;
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.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.profile.StudentProfile;
@ -44,6 +47,7 @@ public class ApplicationService {
private final ApplicationRepository applicationRepository;
private final JobService jobService;
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final AuditService auditService;
private final ApplicationStatusHistoryRepository historyRepository;
private final NotificationService notificationService;
@ -132,12 +136,18 @@ public class ApplicationService {
/** 状态时间线 */
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)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
// 仅学生本人或岗位所属企业可查看
Job job = jobService.getJob(app.getJobId());
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);
return historyRepository.findByApplicationIdOrderByCreatedAtAsc(applicationId)
@ -172,6 +182,7 @@ public class ApplicationService {
// ── 企业端 ────────────────────────────────────────────────
public List<Application> listCompanyApplications(Long companyId, Long jobId) {
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId))
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) {
if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST);
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
@ -197,6 +209,7 @@ public class ApplicationService {
@Transactional
public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req,
com.mydreamjob.audit.AuditContext ctx) {
jobService.requireVerifiedCompany(companyId);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
@ -265,6 +278,7 @@ public class ApplicationService {
public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status,
String keyword, String sort, String order,
int page, int size) {
jobService.requireVerifiedCompany(companyId);
// 参数校验
if (page < 0 || size < 1 || size > 100)
throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -349,6 +363,7 @@ public class ApplicationService {
*/
@Transactional(readOnly = true)
public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) {
jobService.requireVerifiedCompany(companyId);
// 单次 JOIN 查询:applications + jobs + student_profiles + users
// 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.privacy.ResumeVisibility;
@ -69,10 +70,11 @@ public class CompanyResumeAccessPolicy {
if (company == null) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
VerifiedCompanyGuard.requireVerified(company);
Application application = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!isVisibleToCompanyInternal(application, company.getId())) {
if (!isVisibleToCompanyInternal(application, company)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return new Decision(application.getStudentUserId());
@ -88,17 +90,22 @@ public class CompanyResumeAccessPolicy {
if (studentUserId == null || companyId == null || applicationId == null) {
return false;
}
Company company = companyRepository.findById(companyId).orElse(null);
if (!VerifiedCompanyGuard.isVerified(company)) {
return false;
}
Application application = applicationRepository.findById(applicationId).orElse(null);
if (application == null || !studentUserId.equals(application.getStudentUserId())) {
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);
if (job == null || !companyId.equals(job.getCompanyId())) {
// 岗位不存在或不属于当前企业:一律拒绝(含跨企业投递)。
@ -113,8 +120,7 @@ public class CompanyResumeAccessPolicy {
return application.getStatus() != null
&& APPLIED_COMPANY_VISIBLE_STATUSES.contains(application.getStatus());
case VERIFIED_COMPANIES:
Company company = companyRepository.findById(companyId).orElse(null);
return company != null && Boolean.TRUE.equals(company.getVerified());
return VerifiedCompanyGuard.isVerified(company);
case PRIVATE:
default:
// 无设置记录、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.Lock;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@ -14,6 +15,11 @@ import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
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)
@Query("select u from User u where u.phone = :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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import lombok.RequiredArgsConstructor;
@ -78,10 +79,11 @@ public class BidirectionalMatchService {
Long userId, Long applicationId, int score, AuditContext ctx) {
User user = loadUser(userId);
assertRole(user, User.Role.COMPANY);
Company company = VerifiedCompanyGuard.requireVerified(
companyRepository.findByOwnerUserId(userId).orElse(null));
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Long companyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
Long companyId = company.getId();
Job job = jobService.getJob(app.getJobId());
if (companyId == null || !companyId.equals(job.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
@ -97,6 +99,10 @@ public class BidirectionalMatchService {
// ── 匹配状态查询(双方可读) ──────────────────────────────
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)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
@ -139,9 +145,11 @@ public class BidirectionalMatchService {
private void assertParticipant(Long userId, Application app, Job job) {
if (app.getStudentUserId().equals(userId)) return;
Long companyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
if (companyId != null && companyId.equals(job.getCompanyId())) return;
Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
if (company != null && company.getId().equals(job.getCompanyId())) {
VerifiedCompanyGuard.requireVerified(company);
return;
}
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.messaging.Conversation;
@ -43,6 +44,8 @@ public class MatchConversationService {
@Transactional
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)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
@ -56,8 +59,7 @@ public class MatchConversationService {
.map(Company::getOwnerUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
} else {
Long actorCompanyId = companyRepository.findByOwnerUserId(userId)
.map(Company::getId).orElse(null);
Long actorCompanyId = ownedCompany == null ? null : ownedCompany.getId();
if (actorCompanyId == null || !actorCompanyId.equals(companyId)) {
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.security.UserRoleGuard;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@ -33,6 +34,7 @@ public class MatchRuleService {
@Transactional(readOnly = true)
public Map<String, Object> getCompanyRule(Long userId) {
Company company = requireCompany(userId);
VerifiedCompanyGuard.requireVerified(company);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId()).orElse(null);
int required = rule != null ? rule.getRequiredCandidateScore()
: CompanyMatchRule.DEFAULT_REQUIRED_CANDIDATE_SCORE;
@ -42,6 +44,7 @@ public class MatchRuleService {
@Transactional
public Map<String, Object> updateCompanyRule(Long userId, int requiredCandidateScore) {
Company company = requireCompany(userId);
VerifiedCompanyGuard.requireVerified(company);
CompanyMatchRule rule = companyRuleRepository.findByCompanyId(company.getId())
.orElseGet(() -> CompanyMatchRule.builder().companyId(company.getId()).build());
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import lombok.RequiredArgsConstructor;
@ -107,6 +108,7 @@ public class CandidateScoringEngineService {
public CompanyCandidateScoringResponse getCompanyScoring(Long companyUserId, Long applicationId) {
Company company = companyRepository.findByOwnerUserId(companyUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
VerifiedCompanyGuard.requireVerified(company);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.profile.StudentProfile;
@ -450,8 +451,9 @@ public class CompanyScoringDashboardService {
// ── 辅助 ────────────────────────────────────────────────────
private Company requireCompany(Long userId) {
return companyRepository.findByOwnerUserId(userId)
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
return VerifiedCompanyGuard.requireVerified(company);
}
private void validatePage(int page, int size) {
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_OWNER_MISMATCH(5002, "company owner mismatch"),
COMPANY_ALREADY_EXISTS(5003, "company already exists"),
COMPANY_NOT_VERIFIED(5004, "company not verified"),
// 学生资料 6xxx
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()
.put(HttpStatus.FORBIDDEN,
ErrorCode.APPLICATION_OWNER_MISMATCH,
ErrorCode.COMPANY_NOT_VERIFIED,
ErrorCode.FORBIDDEN,
ErrorCode.INTERVIEW_ACCESS_DENIED,
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.feedback.dto.CandidateFeedbackRequest;
import com.mydreamjob.feedback.dto.CandidateFeedbackResponse;
import com.mydreamjob.job.Job;
@ -47,11 +48,16 @@ public class CandidateFeedbackService {
* 企业归属校验:先按当前登录 userId 解析其拥有的企业,
* 再将企业 id 与 job.companyId 比较(userId 与 companyId 属不同命名空间,不可直接比较)。
*/
private void assertCompanyOwner(Long userId, Job job) {
private Company assertCompanyOwner(Long userId, Job job) {
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
if (!company.getId().equals(job.getCompanyId()))
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) {
@ -63,9 +69,11 @@ public class CandidateFeedbackService {
@Transactional
public CandidateFeedbackResponse createFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId);
Job job = findJob(app);
assertCompanyOwner(userId, job);
Company company = assertCompanyOwner(userId, job);
VerifiedCompanyGuard.requireVerified(company);
if (feedbackRepository.existsByApplicationId(applicationId))
throw new BusinessException(ErrorCode.DUPLICATE_FEEDBACK);
@ -86,14 +94,18 @@ public class CandidateFeedbackService {
// ── 查询 ──────────────────────────────────────────────────
public CandidateFeedbackResponse getFeedback(Long userId, Long applicationId) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId);
Job job = findJob(app);
// 企业(按 userId 解析归属企业后比较企业 id)或学生任一匹配即可查看
boolean isStudent = app.getStudentUserId().equals(userId);
boolean isCompany = companyRepository.findByOwnerUserId(userId)
.map(c -> c.getId().equals(job.getCompanyId()))
.orElse(false);
.map(c -> {
if (!c.getId().equals(job.getCompanyId())) return false;
VerifiedCompanyGuard.requireVerified(c);
return true;
}).orElse(false);
if (!isCompany && !isStudent)
throw new BusinessException(ErrorCode.FORBIDDEN);
@ -106,9 +118,11 @@ public class CandidateFeedbackService {
@Transactional
public CandidateFeedbackResponse updateFeedback(Long userId, Long applicationId, CandidateFeedbackRequest req) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId);
Job job = findJob(app);
assertCompanyOwner(userId, job);
Company company = assertCompanyOwner(userId, job);
VerifiedCompanyGuard.requireVerified(company);
CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND));
@ -126,9 +140,11 @@ public class CandidateFeedbackService {
@Transactional
public void deleteFeedback(Long userId, Long applicationId) {
requireOwnedCompanyVerifiedIfPresent(userId);
Application app = findApplication(applicationId);
Job job = findJob(app);
assertCompanyOwner(userId, job);
Company company = assertCompanyOwner(userId, job);
VerifiedCompanyGuard.requireVerified(company);
CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId)
.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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.interview.dto.InterviewCancelRequest;
import com.mydreamjob.interview.dto.InterviewCreateRequest;
import com.mydreamjob.interview.dto.InterviewRespondRequest;
@ -60,6 +61,7 @@ public class InterviewService {
public Interview createInterview(Long operatorUserId, Long applicationId, InterviewCreateRequest req,
AuditContext ctx) {
Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
@ -101,6 +103,7 @@ public class InterviewService {
// ── 查询某投递的面试列表 ─────────────────────────────────────
public List<Interview> listByApplication(Long applicationId, Long userId) {
requireVerifiedIfCompany(userId);
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
assertApplicationAccess(app, userId);
@ -119,6 +122,7 @@ public class InterviewService {
boolean hasRange = from != null && to != null;
if (hasRole(user, User.Role.COMPANY)) {
VerifiedCompanyGuard.requireVerified(requireCompany(userId));
return listByCompanyUser(userId, status, from, to, pr);
}
if (hasRole(user, User.Role.STUDENT)) {
@ -158,6 +162,7 @@ public class InterviewService {
// ── 查看面试详情 ────────────────────────────────────────────
public Interview getInterview(Long interviewId, Long userId) {
requireVerifiedIfCompany(userId);
Interview interview = requireInterview(interviewId);
assertViewAccess(interview, userId);
return interview;
@ -169,6 +174,7 @@ public class InterviewService {
public Interview updateInterview(Long operatorUserId, Long interviewId, InterviewUpdateRequest req,
AuditContext ctx) {
Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -253,6 +259,7 @@ public class InterviewService {
public Interview cancelInterview(Long operatorUserId, Long interviewId, InterviewCancelRequest req,
AuditContext ctx) {
Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -297,6 +304,7 @@ public class InterviewService {
@Transactional
public Interview completeInterview(Long operatorUserId, Long interviewId, String notes, AuditContext ctx) {
Company company = requireCompany(operatorUserId);
VerifiedCompanyGuard.requireVerified(company);
Interview interview = requireInterview(interviewId);
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -411,17 +419,30 @@ public class InterviewService {
private void assertViewAccess(Interview interview, Long userId) {
User user = loadUser(userId);
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);
}
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) {
User user = loadUser(userId);
if (hasRole(user, User.Role.STUDENT) && app.getStudentUserId().equals(userId)) return;
if (hasRole(user, User.Role.COMPANY)) {
Job job = jobService.getJob(app.getJobId());
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);
}
@ -500,4 +521,4 @@ public class InterviewService {
private String getCompanyName(Company company) {
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.interview.Interview;
import com.mydreamjob.interview.InterviewRepository;
import com.mydreamjob.interviewscore.dto.InterviewScoreHistoryItem;
@ -133,8 +134,9 @@ public class InterviewScoreService {
@Transactional(readOnly = true)
public InterviewScoreResponse getScore(Long userId, Long interviewId) {
Interview interview = requireInterview(interviewId);
User user = loadUser(userId);
if (hasRole(user, User.Role.COMPANY)) requireVerifiedScoringCompany(userId);
Interview interview = requireInterview(interviewId);
if (hasRole(user, User.Role.COMPANY)) {
requireScoringCompany(interviewId, userId);
@ -209,8 +211,9 @@ public class InterviewScoreService {
@Transactional(readOnly = true)
public List<InterviewScoreHistoryItem> getHistory(Long userId, Long interviewId) {
Interview interview = requireInterview(interviewId);
User user = loadUser(userId);
if (hasRole(user, User.Role.COMPANY)) requireVerifiedScoringCompany(userId);
Interview interview = requireInterview(interviewId);
if (hasRole(user, User.Role.COMPANY)) {
requireScoringCompany(interviewId, userId);
@ -238,9 +241,8 @@ public class InterviewScoreService {
if (!hasRole(user, User.Role.COMPANY)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
Company company = requireVerifiedScoringCompany(userId);
Interview interview = requireInterview(interviewId);
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
// 1. 当前企业的 owner 必须是该面试的创建企业
if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) {
throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED);
@ -259,6 +261,12 @@ public class InterviewScoreService {
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) {
if (!"COMPLETED".equals(interview.getStatus())) {
throw new BusinessException(ErrorCode.INTERVIEW_NOT_COMPLETED);
@ -397,4 +405,4 @@ public class InterviewScoreService {
String t = value.trim();
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,
HttpServletRequest request) {
Long companyId = resolveCompanyId();
// 验证归属后返回
jobService.assertOwnerOrThrow(companyId, jobId);
Map<String, Object> view = jobService.toView(jobService.getJob(jobId));
Map<String, Object> view = jobService.toView(jobService.getCompanyJob(companyId, jobId));
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 java.util.List;
import java.util.Optional;
@Repository
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> 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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.dto.JobCreateRequest;
import com.mydreamjob.job.dto.JobSearchItem;
import com.mydreamjob.job.dto.JobStatusRequest;
@ -75,7 +76,10 @@ public class JobService {
*/
@Transactional
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.getDescription() != null) job.setDescription(req.getDescription());
@ -93,10 +97,16 @@ public class JobService {
*/
@Transactional
public Job transitionStatus(Long companyId, Long jobId, JobStatusRequest req) {
Job job = assertOwnerOrThrow(companyId, jobId);
String current = job.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) {
case "DRAFT" -> "PUBLISHED".equals(target);
@ -115,6 +125,21 @@ public class JobService {
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.messaging.dto.CreateConversationRequest;
import com.mydreamjob.messaging.dto.MessagePageResponse;
import com.mydreamjob.messaging.dto.MessageResponse;
@ -68,6 +69,7 @@ public class MessagingService {
}
public List<Map<String, Object>> listConversations(Long userId) {
requireVerifiedIfCompany(userId);
List<ConversationMember> memberships = memberRepository.findByUserId(userId);
if (memberships.isEmpty()) return List.of();
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) {
requireVerifiedIfCompany(userId);
Conversation c = requireConversation(conversationId);
assertMember(conversationId, userId);
Map<String, Object> v = toView(c, userId);
@ -95,6 +98,7 @@ public class MessagingService {
@Transactional
public Map<String, Object> createOrGetDirectConversation(Long userId, CreateConversationRequest req) {
requireVerifiedIfCompany(userId);
Long otherId = req.getParticipantUserId();
if (userId.equals(otherId)) throw new BusinessException(ErrorCode.BAD_REQUEST);
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);
}
private void requireVerifiedIfCompany(Long userId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
VerifiedCompanyGuard.requireVerifiedIfCompany(actor,
() -> companyRepository.findByOwnerUserId(userId).orElse(null));
}
// ── V33 屏蔽控制:任一方向存在屏蔽即禁止站内信交互 ──
private boolean isBlocked(Long aId, Long bId) {
@ -177,6 +187,7 @@ public class MessagingService {
@Transactional
public Map<String, Object> sendMessage(Long userId, SendMessageRequest req) {
requireVerifiedIfCompany(userId);
requireConversation(req.getConversationId());
assertMember(req.getConversationId(), userId);
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) {
requireVerifiedIfCompany(userId);
Conversation conversation = requireConversation(conversationId);
assertMember(conversationId, userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -230,6 +242,7 @@ public class MessagingService {
@Transactional
public void markRead(Long conversationId, Long userId) {
requireVerifiedIfCompany(userId);
requireConversation(conversationId);
assertMember(conversationId, userId);
List<Message> msgs = messageRepository
@ -265,6 +278,7 @@ public class MessagingService {
@Transactional
public MessageResponse recallMessage(Long currentUserId, Long conversationId, Long messageId,
AuditContext auditContext, LocalDateTime capturedNow) {
requireVerifiedIfCompany(currentUserId);
Conversation conversation = requireConversation(conversationId);
assertMember(conversationId, currentUserId);
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;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
@ -22,6 +26,8 @@ public class NotificationService {
private final NotificationRepository notificationRepository;
private final PushProvider pushProvider;
private final NotificationPublisher notificationPublisher;
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
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) {
requireVerifiedIfCompany(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST);
return notificationRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size));
}
@ -81,6 +88,7 @@ public class NotificationService {
/** 标记单条已读(幂等:已读也返回成功) */
@Transactional
public void markRead(Long userId, Long notificationId) {
requireVerifiedIfCompany(userId);
Notification n = notificationRepository.findById(notificationId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND));
if (!n.getUserId().equals(userId)) throw new BusinessException(ErrorCode.FORBIDDEN);
@ -94,6 +102,7 @@ public class NotificationService {
/** 标记全部已读(幂等) */
@Transactional
public int markAllRead(Long userId) {
requireVerifiedIfCompany(userId);
int count = 0;
int pageIdx = 0;
boolean hasMore = true;
@ -119,9 +128,16 @@ public class NotificationService {
/** 未读数 */
public long unreadCount(Long userId) {
requireVerifiedIfCompany(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) {
Map<String, Object> v = new HashMap<>();
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.notification.NotificationService;
@ -153,8 +154,7 @@ public class OfferService {
PageRequest pr = PageRequest.of(page, size);
if (hasRole(user, User.Role.COMPANY)) {
Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
Company company = requireCompany(userId);
return listByCompany(company.getId(), status, applicationId, pr);
}
if (hasRole(user, User.Role.STUDENT)) {
@ -189,6 +189,7 @@ public class OfferService {
/** 详情:仅目标候选人、所属企业可访问 */
public Offer getOffer(Long offerId, Long userId) {
requireVerifiedIfCompany(userId);
Offer offer = requireOffer(offerId);
assertViewAccess(offer, userId);
return offer;
@ -258,17 +259,18 @@ public class OfferService {
*/
@Transactional
public Offer transitionStatus(Long operatorUserId, Long offerId, OfferStatusRequest req, AuditContext ctx) {
Offer offer = requireOffer(offerId);
User user = loadUser(operatorUserId);
boolean isCompany = hasRole(user, User.Role.COMPANY);
boolean isCandidate = hasRole(user, User.Role.STUDENT);
ActorRole actor = isCompany ? ActorRole.COMPANY : (isCandidate ? ActorRole.CANDIDATE : null);
if (actor == null) throw new BusinessException(ErrorCode.FORBIDDEN);
Company currentCompany = actor == ActorRole.COMPANY ? requireCompany(operatorUserId) : null;
Offer offer = requireOffer(offerId);
// 访问控制(身份一律来自 JWT)
if (actor == ActorRole.COMPANY) {
Company company = companyRepository.findByOwnerUserId(operatorUserId).orElse(null);
if (company == null || !company.getId().equals(offer.getCompanyId())) {
if (!currentCompany.getId().equals(offer.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
} else {
@ -794,8 +796,12 @@ public class OfferService {
if (!hasRole(user, User.Role.COMPANY)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
return VerifiedCompanyGuard.requireVerified(companyRepository.findByOwnerUserId(userId).orElse(null));
}
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) {
@ -807,7 +813,10 @@ public class OfferService {
if (hasRole(user, User.Role.STUDENT) && offer.getCandidateId().equals(userId)) return;
if (hasRole(user, User.Role.COMPANY)) {
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);
}

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.company.Company;
import com.mydreamjob.company.CompanyService;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
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 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 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 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();}}

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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.recommendation.JobPreferenceScoreCalculator;
@ -266,8 +267,7 @@ public class TalentDiscoveryService {
if (!hasRole(user, User.Role.COMPANY)) throw new BusinessException(ErrorCode.FORBIDDEN);
Company company = companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
if (!Boolean.TRUE.equals(company.getVerified())) throw new BusinessException(ErrorCode.FORBIDDEN);
return company;
return VerifiedCompanyGuard.requireVerified(company);
}
private User requireUser(Long userId) {
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.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.recommendation.JobRecommendationProfile;
@ -303,8 +304,7 @@ public class TalentInvitationService {
User user = requireUser(userId);
if (!hasRole(user, User.Role.COMPANY)) throw new BusinessException(ErrorCode.FORBIDDEN);
Company company = companyRepository.findByOwnerUserId(userId).orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
if (!Boolean.TRUE.equals(company.getVerified())) throw new BusinessException(ErrorCode.FORBIDDEN);
return company;
return VerifiedCompanyGuard.requireVerified(company);
}
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 com.mydreamjob.job.JobService jobService;
@Mock private com.mydreamjob.auth.UserRepository userRepository;
@Mock private com.mydreamjob.company.CompanyRepository companyRepository;
@Mock private AuditService auditService;
@Mock private ApplicationStatusHistoryRepository historyRepository;
@Mock private com.mydreamjob.notification.NotificationService notificationService;
@ -61,6 +62,13 @@ class ApplicationServiceTest {
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() {
@ -106,6 +114,7 @@ class ApplicationServiceTest {
// ── 时间线 ───────────────────────────────────────────────
@Test void getTimeline_asStudent_shouldSucceed() {
mockStudentRoleWithRoles();
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application));
when(jobService.getJob(JOB_ID)).thenReturn(publishedJob);
when(historyRepository.findByApplicationIdOrderByCreatedAtAsc(APP_ID)).thenReturn(List.of());
@ -115,6 +124,10 @@ class ApplicationServiceTest {
}
@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(jobService.getJob(JOB_ID)).thenReturn(publishedJob);
@ -376,16 +389,19 @@ class ApplicationServiceTest {
// ── getTimeline 已知行为回归 ────────────────────────────────
@Test void getTimeline_companyUser_knownBehavior() {
// 公司用户的 userId 与 companyId 不同,当前实现比较 job.companyId == userId
// 这会导致公司用户无法通过权限校验,返回 403
@Test void getTimeline_companyOwner_canReadWhenVerified() {
Long companyUserId = 300L;
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(jobService.getJob(JOB_ID)).thenReturn(publishedJob);
// publishedJob.companyId = COMPANY_ID (1), userId = COMPANY_ID (1) -> 巧合匹配
// 注意:实际场景中 companyId != userId,此处仅验证当前行为
when(historyRepository.findByApplicationIdOrderByCreatedAtAsc(APP_ID)).thenReturn(List.of());
var result = applicationService.getTimeline(APP_ID, COMPANY_ID);
// 因为 COMPANY_ID == publishedJob.companyId,当前代码会通过
var result = applicationService.getTimeline(APP_ID, companyUserId);
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.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
/**
* S03-B 企业简历授权决策矩阵(设计 §4.3)。
@ -64,6 +64,7 @@ class CompanyResumeAccessPolicyTest {
}
private void givenJobOwnedBy(Long companyId) {
givenCompany(COMPANY_ID, true);
when(jobRepository.findById(JOB_ID)).thenReturn(Optional.of(
Job.builder().id(JOB_ID).companyId(companyId).build()));
}
@ -90,12 +91,14 @@ class CompanyResumeAccessPolicyTest {
@Test
void missingApplicationIsRejected() {
givenCompany(COMPANY_ID, true);
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty());
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
}
@Test
void studentMismatchIsRejected() {
givenCompany(COMPANY_ID, true);
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(
Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(999L).status("SUBMITTED").build()));
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID));
@ -103,11 +106,21 @@ class CompanyResumeAccessPolicyTest {
@Test
void missingJobIsRejected() {
givenCompany(COMPANY_ID, true);
givenApplication("SUBMITTED");
when(jobRepository.findById(JOB_ID)).thenReturn(Optional.empty());
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 / 无记录 ───────────────────────────────────────
@Test
@ -300,7 +313,7 @@ class CompanyResumeAccessPolicyTest {
@Test
void authorizeForDownloadReturnsStudentIdWhenAllowed() {
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");
givenJobOwnedBy(COMPANY_ID);
givenVisibility(ResumeVisibility.APPLIED_COMPANIES);

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

@ -193,6 +193,12 @@ class CompanyResumeDownloadIntegrationTest {
applicationRepository.saveAndFlush(app);
}
private void certifyCompanyForResumePolicyTest() {
Company company = companyRepository.findById(companyId).orElseThrow();
company.setVerified(true);
companyRepository.saveAndFlush(company);
}
private String url() {
return "/company/candidates/" + applicationId + "/resume";
}
@ -205,6 +211,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test
void privateIsDeniedForEveryStatusAndNeverReadsFile() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.PRIVATE);
for (String status : ALL_STATUSES) {
setStatus(status);
@ -217,6 +224,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test
void missingSettingsIsDeniedAsPrivate() {
certifyCompanyForResumePolicyTest();
setVisibility(null);
for (String status : ALL_STATUSES) {
setStatus(status);
@ -228,6 +236,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test
void appliedCompaniesAllowsOnlyEffectiveStatusesForOwningCompany() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String status : ALL_STATUSES) {
setStatus(status);
@ -272,6 +281,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test
void appliedCompaniesUnknownStatusReturns403WithoutReadingStorage() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String unknown : new String[]{"PENDING", "DRAFT", "ARCHIVED", "submitted"}) {
@ -290,6 +300,7 @@ class CompanyResumeDownloadIntegrationTest {
void appliedCompaniesBlankStatusReturns403WithoutReadingStorage() throws Exception {
// 说明:applications.status 在 V2 中是 NOT NULL,因此 DB 层无法保存 NULL;
// NULL 情形由 CompanyResumeAccessPolicyTest 在内存中覆盖,这里用可达的空白值验证。
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
for (String blank : new String[]{"", " "}) {
@ -308,6 +319,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test
void otherCompanyIsAlwaysDenied() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
READ_CALLS.set(0);
assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY")))
@ -339,6 +351,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test
void nonexistentApplicationIsNotFound() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
READ_CALLS.set(0);
String token = companyToken();
@ -355,6 +368,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test
void missingResumeIsProfileNotFound() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
StudentProfile profile = profileRepository.findByUserId(studentUserId).orElseThrow();
profile.setResumeUrl(null);
@ -367,6 +381,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test
void responseDoesNotLeakPathOrIdentity() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
String body = mockMvc.perform(get(url()).header("Authorization", companyToken()))
@ -381,6 +396,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test
void contentDispositionUsesSanitizedOriginalName() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
mockMvc.perform(get(url()).header("Authorization", companyToken()))
@ -396,6 +412,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test
void companyCandidateEndpointsStillExcludeResumeUrl() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
String token = companyToken();
@ -529,6 +546,7 @@ class CompanyResumeDownloadIntegrationTest {
@Test
void companyDownloadHasNoSideEffectsOnBusinessTables() {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
// 先取 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 {
List<String> before = snapshotJobs();
// 既有语义:归属校验失败为 JOB_OWNER_MISMATCH(7002) → HTTP 400(本轮不改变错误码)
// 其他企业资源与不存在资源统一返回 JOB_NOT_FOUND,避免泄露跨企业资源是否存在。
mockMvc.perform(put("/jobs/" + jobId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"他企业篡改\"}")
.header("Authorization", token(otherCompanyUserId)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(ErrorCode.JOB_OWNER_MISMATCH.getCode()));
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.JOB_NOT_FOUND.getCode()));
assertThat(snapshotJobs()).isEqualTo(before);
}
@ -359,8 +359,8 @@ class AuthenticationBoundaryIntegrationTest {
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PUBLISHED\"}")
.header("Authorization", token(otherCompanyUserId)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(ErrorCode.JOB_OWNER_MISMATCH.getCode()));
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.JOB_NOT_FOUND.getCode()));
assertThat(snapshotJobs()).isEqualTo(before);
}
@ -422,10 +422,11 @@ class AuthenticationBoundaryIntegrationTest {
return companyRepository.save(Company.builder()
.ownerUserId(ownerUserId)
.name(name)
.verified(true)
.build()).getId();
}
private Long companyOf(Long ownerUserId) {
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);
companyUserId = createUser(User.Role.COMPANY);
Company company = companyRepository.save(Company.builder()
.ownerUserId(companyUserId).name("匹配测试企业").build());
.ownerUserId(companyUserId).name("匹配测试企业").verified(true).build());
companyId = company.getId();
Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端")
.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(jobService.getJob(JOB_ID)).thenReturn(job);
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),
eq(MatchScoreField.HR_SUITABILITY), any()))
.thenReturn(ApplicationMatchRecord.builder().applicationId(APPLICATION_ID)
@ -136,7 +136,7 @@ class BidirectionalMatchServiceTest {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app));
when(jobService.getJob(JOB_ID)).thenReturn(job);
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,
() -> matchService.submitHrSuitabilityScore(OTHER_COMPANY_USER_ID, APPLICATION_ID, 80, null));
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt());
@ -191,7 +191,7 @@ class BidirectionalMatchServiceTest {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app));
when(jobService.getJob(JOB_ID)).thenReturn(job);
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),
eq(MatchScoreField.HR_SUITABILITY), any()))
.thenThrow(new OptimisticLockingFailureException("conflict"))
@ -210,7 +210,7 @@ class BidirectionalMatchServiceTest {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app));
when(jobService.getJob(JOB_ID)).thenReturn(job);
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),
eq(MatchScoreField.HR_SUITABILITY), any()))
.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(jobService.getJob(JOB_ID)).thenReturn(job());
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();
notMatched.setStatus(ApplicationMatchRecord.STATUS_PENDING_HR);
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(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty());
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(conversationRepository.save(any())).thenAnswer(inv -> {
Conversation c = inv.getArgument(0);
@ -110,7 +110,7 @@ class MatchConversationServiceTest {
when(recordRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.of(matchedRecord()));
when(conversationLinkRepository.findByApplicationId(APPLICATION_ID)).thenReturn(Optional.empty());
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(conversationRepository.save(any())).thenAnswer(inv -> {
Conversation c = inv.getArgument(0);
@ -130,7 +130,7 @@ class MatchConversationServiceTest {
when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of(app()));
when(jobService.getJob(JOB_ID)).thenReturn(job());
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(conversationLinkRepository.findByApplicationId(APPLICATION_ID))
.thenReturn(Optional.of(ApplicationMatchConversation.builder()
@ -155,7 +155,7 @@ class MatchConversationServiceTest {
.thenReturn(Optional.of(ApplicationMatchConversation.builder()
.applicationId(APPLICATION_ID).conversationId(CONV_ID).build()));
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(conversationRepository.save(any())).thenAnswer(inv -> {
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() {
when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(userWithRole(COMPANY_USER_ID, User.Role.COMPANY)));
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());
var view = matchRuleService.getCompanyRule(COMPANY_USER_ID);
@ -71,7 +71,7 @@ class MatchRuleServiceTest {
void updateCompanyRule_shouldUpsert() {
when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(userWithRole(COMPANY_USER_ID, User.Role.COMPANY)));
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.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);
service.setClock(advancing);
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(
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());

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

@ -162,7 +162,7 @@ class CandidateScoringEngineServiceTest {
@Test void getCompanyScoring_abilityOnlyAggregate_noFourDimOrSourceLeak() throws Exception {
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.of(Application.builder().id(APP_ID).jobId(50L).studentUserId(STUDENT_ID).build()));
when(jobService.getJob(50L)).thenReturn(Job.builder().id(50L).companyId(1L).build());
@ -266,7 +266,7 @@ class CandidateScoringEngineServiceTest {
@Test void getCompanyScoring_appNotFound_should404() {
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());
BusinessException ex = assertThrows(BusinessException.class,
() -> service.getCompanyScoring(COMPANY_USER_ID, APP_ID));
@ -275,7 +275,7 @@ class CandidateScoringEngineServiceTest {
@Test void getCompanyScoring_otherCompany_should403() {
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))
.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());
@ -286,7 +286,7 @@ class CandidateScoringEngineServiceTest {
@Test void getCompanyScoring_success_whitelistOnly() throws Exception {
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.of(Application.builder().id(APP_ID).jobId(50L).studentUserId(STUDENT_ID).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(
"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(
"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);
@Test
@DisplayName("密码登录:显式映射包含新增认证状态")
@DisplayName("Phase B:显式映射包含企业未认证拒绝状态")
void explicitMappingCountMatchesBaseline() {
assertThat(registry.explicitMappingCount()).isEqualTo(119);
// 119 个常量只覆盖 118 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
assertThat(registry.explicitMappingCount()).isEqualTo(120);
// 120 个常量只覆盖 119 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
// DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(118);
assertThat(registry.explicitNumericCodeCount()).isEqualTo(119);
assertThat(registry.resolve(ErrorCode.COMPANY_NOT_VERIFIED)).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
@ -122,12 +123,12 @@ class ErrorHttpStatusRegistryTest {
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN))
.isInstanceOf(UnsupportedOperationException.class);
assertThat(view).hasSize(119);
assertThat(view).hasSize(120);
Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView();
assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK))
.isInstanceOf(UnsupportedOperationException.class);
assertThat(numeric).hasSize(118);
assertThat(numeric).hasSize(119);
}
@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");
private static final Set<String> PHASE_A_SERVICE_UNAVAILABLE = Set.of(
"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 GlobalExceptionHandler handler =
@ -105,6 +106,7 @@ class GlobalExceptionHandlerMappingParityTest {
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS;
if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED;
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;
return HttpStatus.BAD_REQUEST;
}
@ -112,8 +114,8 @@ class GlobalExceptionHandlerMappingParityTest {
@Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(119);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(118);
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(120);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(119);
for (ErrorCode errorCode : ErrorCode.values()) {
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_ONBOARDING_INCONSISTENT_STATE", HttpStatus.CONFLICT,
"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 GlobalExceptionHandler handler =
@ -76,24 +78,26 @@ class R01BIndependentHttpMappingParityTest {
};
}
// ── 166 项逐项对账 ────────────────────────────────────────
// ── 历史基线 + Phase A/B 追加码逐项对账 ─────────────────────
@Test
@DisplayName("独立复验:166 个枚举常量按名称逐项与 HEAD 基线对齐(不按数字去重)")
@DisplayName("独立复验:枚举常量按名称逐项与 HEAD 基线或阶段追加码对齐(不按数字去重)")
void everyEnumConstantMatchesHeadBaselineByName() {
Map<String, int[]> base = baseline();
// HEAD 基线有 169 条;Phase A 追加 3 个错误码,不修改任何基线行。
// HEAD 基线有 169 条;后续阶段新增码单独列出,不修改任何历史基线行。
assertThat(base).hasSize(169);
assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK");
assertThat(ErrorCode.values()).hasSize(173);
assertThat(ErrorCode.values()).hasSize(174);
int checked = 0;
for (ErrorCode code : ErrorCode.values()) {
int[] expected = base.get(code.name());
if (expected == null) {
assertThat(PHASE_A_ADDITIONS).containsKey(code.name());
assertThat(registry.resolve(code)).isEqualTo(PHASE_A_ADDITIONS.get(code.name()));
HttpStatus addedStatus = 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++;
continue;
}
@ -104,11 +108,11 @@ class R01BIndependentHttpMappingParityTest {
.isEqualTo(expected[1]);
checked++;
}
assertThat(checked).isEqualTo(173);
assertThat(checked).isEqualTo(174);
}
@Test
@DisplayName("密码登录:116 个显式映射 + 53 个默认 400")
@DisplayName("显式映射总数等于历史映射与 Phase A/B 明确追加映射之和")
void explicitAndDefaultCountsMatchBaseline() {
Map<String, int[]> base = baseline();
long explicitInBaseline = base.values().stream().filter(v -> v[1] != 400).count();
@ -116,10 +120,10 @@ class R01BIndependentHttpMappingParityTest {
assertThat(explicitInBaseline).isEqualTo(115);
assertThat(base.size() - explicitInBaseline).isEqualTo(54);
assertThat(registry.explicitMappingCount()).isEqualTo(119);
assertThat(registry.explicitMappingCount()).isEqualTo(120);
assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54);
// 118 个常量仅覆盖 117 个唯一数字码(9103 共用)
assertThat(registry.explicitNumericCodeCount()).isEqualTo(118);
// 120 个显式常量覆盖 119 个唯一数字码(9103 共用)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(119);
}
@Test
@ -231,8 +235,10 @@ class R01BIndependentHttpMappingParityTest {
// 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明
Map<String, int[]> base = baseline();
for (ErrorCode c : ErrorCode.values()) {
if (PHASE_A_ADDITIONS.containsKey(c.name())) {
assertThat(registry.resolve(c)).isEqualTo(PHASE_A_ADDITIONS.get(c.name()));
if (PHASE_A_ADDITIONS.containsKey(c.name()) || PHASE_B_ADDITIONS.containsKey(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 {
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)
+ "\n--- mysql sql ---\n" + String.join("\n", mySql) + "\n");
assertThat(onH2).as("E2 H2:SQL 数必须精确为 3/3/3").containsExactly(3, 3, 3);
assertThat(onMy).as("E2 MySQL:SQL 数必须精确为 3/3/3").containsExactly(3, 3, 3);
// 3 条语句必须是「成员关系 + 会话 + 分组未读」,且未读查询只出现 1 次
assertThat(h2Sql).hasSize(3);
assertThat(mySql).hasSize(3);
assertThat(onH2).as("E2 H2:含角色加载的固定 SQL 数必须为 4/4/4").containsExactly(4, 4, 4);
assertThat(onMy).as("E2 MySQL:含角色加载的固定 SQL 数必须为 4/4/4").containsExactly(4, 4, 4);
// 4 条语句必须是「角色加载 + 成员关系 + 会话 + 分组未读」,且未读查询只出现 1 次
assertThat(h2Sql).hasSize(4);
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())
.as("H2:分组未读查询只执行一次").isEqualTo(1);
assertThat(mySql.stream().filter(s -> s.contains("ROW_NUMBER")).count())
@ -958,10 +962,10 @@ class R01EIndependentCrossDatabaseVerificationTest {
assertThat(MySql.messaging.listConversations(nobodyMy)).isEmpty();
List<String> mySql = MySql.counting.sqls();
assertThat(h2Sql).as("H2:空会话页只发出一条成员关系查询").hasSize(1);
assertThat(mySql).as("MySQL:空会话页只发出一条成员关系查询").hasSize(1);
assertThat(h2Sql.get(0)).as("不得执行未读查询").doesNotContain("ROW_NUMBER");
assertThat(mySql.get(0)).as("不得执行未读查询").doesNotContain("ROW_NUMBER");
assertThat(h2Sql).as("H2:空会话页只发角色与成员关系两条读取").hasSize(2);
assertThat(mySql).as("MySQL:空会话页只发角色与成员关系两条读取").hasSize(2);
assertThat(h2Sql.toString()).as("不得执行未读查询").doesNotContain("ROW_NUMBER");
assertThat(mySql.toString()).as("不得执行未读查询").doesNotContain("ROW_NUMBER");
assertThat(h2Counting.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();
assertThat(messagingService.listConversations(me)).isEmpty();
assertThat(countingDataSource.statements())
.as("空会话不得执行无意义的 IN () 查询")
.isLessThanOrEqualTo(1);
.as("空会话允许角色门禁与成员关系两条固定读取,不得执行会话/未读 IN 查询")
.isEqualTo(2);
}
@Test

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

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

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

@ -88,17 +88,17 @@ class InterviewServiceTest {
private void mockCompany(Long userId, Long companyId) {
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) {
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) {
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) {
@ -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
void createInterview_shouldSucceed() {
mockCompanyUser(COMPANY_USER_ID);
@ -259,6 +275,7 @@ class InterviewServiceTest {
@Test
void getInterview_asOtherCompany_shouldReturn403() {
mockCompanyUser(OTHER_COMPANY_USER_ID);
mockCompany(OTHER_COMPANY_USER_ID, OTHER_COMPANY_ID);
when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED")));
BusinessException ex = assertThrows(BusinessException.class,
() -> interviewService.getInterview(INTERVIEW_ID, OTHER_COMPANY_USER_ID));
@ -268,6 +285,7 @@ class InterviewServiceTest {
@Test
void getInterview_asOwnCompany_shouldSucceed() {
mockCompanyUser(COMPANY_USER_ID);
mockCompany(COMPANY_USER_ID, COMPANY_ID);
when(interviewRepository.findById(INTERVIEW_ID)).thenReturn(Optional.of(buildInterview("SCHEDULED")));
assertDoesNotThrow(() -> interviewService.getInterview(INTERVIEW_ID, COMPANY_USER_ID));
}
@ -522,6 +540,7 @@ class InterviewServiceTest {
@Test
void listMyInterviews_asCompany_shouldReturnOwn() {
mockCompanyUser(COMPANY_USER_ID);
mockCompany(COMPANY_USER_ID, COMPANY_ID);
when(interviewRepository.findByCompanyUserIdOrderByStartAtDesc(eq(COMPANY_USER_ID), any()))
.thenReturn(new PageImpl<>(List.of(buildInterview("SCHEDULED"))));
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));
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);
companyUserId = createUser(User.Role.COMPANY);
Company company = companyRepository.save(
Company.builder().ownerUserId(companyUserId).name("评分企业").build());
Company.builder().ownerUserId(companyUserId).name("评分企业").verified(true).build());
companyId = company.getId();
Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java")
.description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build());
@ -95,7 +95,7 @@ class InterviewScoreControllerTest {
otherCompanyUserId = createUser(User.Role.COMPANY);
otherCompanyId = companyRepository.save(Company.builder()
.ownerUserId(otherCompanyUserId).name("其他企业").build()).getId();
.ownerUserId(otherCompanyUserId).name("其他企业").verified(true).build()).getId();
}
@AfterEach
@ -225,4 +225,4 @@ class InterviewScoreControllerTest {
.andExpect(status().isConflict())
.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);
companyUserId = createUser(User.Role.COMPANY);
Company company = companyRepository.save(
Company.builder().ownerUserId(companyUserId).name("评分企业").build());
Company.builder().ownerUserId(companyUserId).name("评分企业").verified(true).build());
companyId = company.getId();
Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java")
.description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build());
@ -269,4 +269,4 @@ class InterviewScorePersistenceIntegrationTest {
private void await(CountDownLatch latch) {
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) {
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) {
@ -374,4 +374,4 @@ class InterviewScoreServiceTest {
// DTO 结构性脱敏:无 actorUserId 字段
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.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.dto.JobCreateRequest;
import com.mydreamjob.job.dto.JobStatusRequest;
import com.mydreamjob.job.dto.JobUpdateRequest;
@ -25,6 +27,9 @@ class JobServiceTest {
@Mock
private JobRepository jobRepository;
@Mock
private CompanyRepository companyRepository;
@InjectMocks
private JobService jobService;
@ -74,7 +79,7 @@ class JobServiceTest {
req.setTitle("高级 Java 后端");
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));
Job updated = jobService.updateJob(COMPANY_ID, 10L, req);
@ -93,11 +98,9 @@ class JobServiceTest {
JobUpdateRequest req = new JobUpdateRequest();
req.setTitle("恶意修改");
when(jobRepository.findById(10L)).thenReturn(Optional.of(draftJob));
BusinessException ex = assertThrows(BusinessException.class,
() -> 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());
}
@ -108,7 +111,8 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest();
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));
Job result = jobService.transitionStatus(COMPANY_ID, 10L, req);
@ -117,6 +121,21 @@ class JobServiceTest {
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
void transitionStatus_publishedToClosed_shouldSucceed() {
Job publishedJob = Job.builder()
@ -126,7 +145,7 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest();
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));
Job result = jobService.transitionStatus(COMPANY_ID, 10L, req);
@ -145,7 +164,8 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest();
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,
() -> jobService.transitionStatus(COMPANY_ID, 10L, req));
@ -162,7 +182,7 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest();
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,
() -> jobService.transitionStatus(COMPANY_ID, 10L, req));
@ -175,7 +195,7 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest();
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,
() -> jobService.transitionStatus(COMPANY_ID, 10L, req));
@ -188,16 +208,21 @@ class JobServiceTest {
JobStatusRequest req = new JobStatusRequest();
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,
() -> 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());
}
// ── 辅助 ────────────────────────────────────────────────
private Company verifiedCompany() {
return Company.builder().id(COMPANY_ID).ownerUserId(COMPANY_ID).verified(true).build();
}
@Test
void getJob_notFound_shouldThrow() {
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.User;
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.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -40,6 +42,7 @@ class MessageRecallHttpIntegrationTest {
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private ConversationRepository conversationRepository;
@Autowired private ConversationMemberRepository memberRepository;
@Autowired private MessageRepository messageRepository;
@ -57,6 +60,7 @@ class MessageRecallHttpIntegrationTest {
private final List<Long> messageIds = new ArrayList<>();
private final List<Long> conversationIds = new ArrayList<>();
private final List<Long> userIds = new ArrayList<>();
private final List<Long> companyIds = new ArrayList<>();
@org.junit.jupiter.api.BeforeEach
void fixApplicationClock() {
@ -79,6 +83,9 @@ class MessageRecallHttpIntegrationTest {
conversationIds.toArray());
jdbcTemplate.update("DELETE FROM conversations WHERE id IN (" + marks + ")", conversationIds.toArray());
}
if (!companyIds.isEmpty()) {
companyRepository.deleteAllById(companyIds);
}
if (!userIds.isEmpty()) {
String marks = String.join(",", userIds.stream().map(id -> "?").toList());
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (" + marks + ")", userIds.toArray());
@ -87,6 +94,7 @@ class MessageRecallHttpIntegrationTest {
messageIds.clear();
conversationIds.clear();
userIds.clear();
companyIds.clear();
}
private Long user(String label, User.Role role) {
@ -95,6 +103,12 @@ class MessageRecallHttpIntegrationTest {
.nickname(label).role(role).roles(Set.of(role))
.active(true).verified(true).build());
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();
}

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_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT)));
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(memberRepository.findByUserId(USER_A)).thenReturn(List.of());
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_B)).thenReturn(Optional.of(user(USER_B, User.Role.STUDENT)));
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);
stubNewConversationCreation();
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.ErrorCode;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.CompanyRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -23,6 +25,8 @@ class NotificationServiceTest {
@Mock private NotificationRepository notificationRepository;
@Mock private PushProvider pushProvider;
@Mock private UserRepository userRepository;
@Mock private CompanyRepository companyRepository;
@InjectMocks private NotificationService notificationService;
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) {
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) {
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) {
@ -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
void createOffer_shouldSucceed() {
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;
}
@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
void safeMessage_createsPendingInvitationAndHistory() {
commonSetup();

Loading…
Cancel
Save