From 9c14abd49890d13f5d2cb172de3b6a65bc749209 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Tue, 29 Sep 2026 10:07:04 +0800 Subject: [PATCH] feat: gate recruiting operations on company verification --- ...verification-gate-implementation-report.md | 163 +++++++++ ...-gate-independent-verification-20260923.md | 136 +++++++ .../application/ApplicationService.java | 17 +- .../CompanyResumeAccessPolicy.java | 16 +- .../com/mydreamjob/auth/UserRepository.java | 6 + .../BidirectionalMatchService.java | 18 +- .../MatchConversationService.java | 6 +- .../bidirectionalmatch/MatchRuleService.java | 3 + .../CandidateScoringEngineService.java | 2 + .../CompanyScoringDashboardService.java | 4 +- .../common/exception/ErrorCode.java | 1 + .../exception/ErrorHttpStatusRegistry.java | 1 + .../company/VerifiedCompanyGuard.java | 41 +++ .../feedback/CandidateFeedbackService.java | 28 +- .../interview/InterviewService.java | 27 +- .../interviewscore/InterviewScoreService.java | 18 +- .../com/mydreamjob/job/JobController.java | 4 +- .../com/mydreamjob/job/JobRepository.java | 4 + .../java/com/mydreamjob/job/JobService.java | 31 +- .../messaging/MessagingService.java | 14 + .../notification/NotificationService.java | 16 + .../com/mydreamjob/offer/OfferService.java | 25 +- .../JobRecommendationService.java | 8 +- .../TalentDiscoveryService.java | 4 +- .../TalentInvitationService.java | 4 +- .../application/ApplicationServiceTest.java | 30 +- .../CompanyResumeAccessPolicyTest.java | 17 +- .../CompanyResumeDownloadIntegrationTest.java | 18 + ...AuthenticationBoundaryIntegrationTest.java | 13 +- ...tionalMatchPersistenceIntegrationTest.java | 2 +- .../BidirectionalMatchServiceTest.java | 8 +- .../MatchConversationServiceTest.java | 10 +- .../MatchRuleServiceTest.java | 4 +- .../CandidateScoringClockConsistencyTest.java | 2 +- .../CandidateScoringEngineServiceTest.java | 8 +- ...balExceptionHandlerStatusContractTest.java | 2 +- .../r01b/ErrorHttpStatusRegistryTest.java | 13 +- ...obalExceptionHandlerMappingParityTest.java | 6 +- .../R01BIndependentHttpMappingParityTest.java | 32 +- ...ependentCrossDatabaseVerificationTest.java | 24 +- .../r01e/R01EQueryBudgetAcceptanceTest.java | 4 +- ...didateFeedbackRefreshRegistrationTest.java | 2 +- .../CandidateFeedbackServiceTest.java | 2 +- .../interview/InterviewServiceTest.java | 27 +- .../InterviewScoreControllerTest.java | 6 +- ...erviewScorePersistenceIntegrationTest.java | 4 +- .../InterviewScoreServiceTest.java | 4 +- .../com/mydreamjob/job/JobServiceTest.java | 47 ++- .../MessageRecallHttpIntegrationTest.java | 14 + .../messaging/MessagingServiceTest.java | 4 +- .../notification/NotificationServiceTest.java | 4 + .../mydreamjob/offer/OfferServiceTest.java | 19 +- ...PhaseBVerifiedCompanyGateContractTest.java | 156 ++++++++ .../VerifiedCompanyGateMySqlBlackboxTest.java | 336 ++++++++++++++++++ ...CompanyGatePersistenceIntegrationTest.java | 193 ++++++++++ .../phaseb/VerifiedCompanyGuardTest.java | 49 +++ .../TalentInvitationServiceTest.java | 16 + 57 files changed, 1532 insertions(+), 141 deletions(-) create mode 100644 docs/phase-b-company-verification-gate-implementation-report.md create mode 100644 docs/phase-b-company-verification-gate-independent-verification-20260923.md create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/phaseb/PhaseBVerifiedCompanyGateContractTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGateMySqlBlackboxTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGatePersistenceIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGuardTest.java diff --git a/docs/phase-b-company-verification-gate-implementation-report.md b/docs/phase-b-company-verification-gate-implementation-report.md new file mode 100644 index 0000000..282a26a --- /dev/null +++ b/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 后续仍需独立复验结论确认后才能进入相应检查点。 diff --git a/docs/phase-b-company-verification-gate-independent-verification-20260923.md b/docs/phase-b-company-verification-gate-independent-verification-20260923.md new file mode 100644 index 0000000..0f69cd7 --- /dev/null +++ b/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 全链路三中间件或公网验收。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java index 60cf990..9ee862b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java +++ b/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> 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 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 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 query = em.createQuery( diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java index 0f858ac..8d24d44 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java +++ b/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 处理。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java index 5525ae4..038d7e6 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java +++ b/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 { Optional 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 findByIdWithRoles(@Param("id") Long id); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select u from User u where u.phone = :phone") Optional findByPhoneForUpdate(@Param("phone") String phone); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java index 08ab758..ffe3321 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchService.java +++ b/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 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); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java index 4d68121..1bbe0c5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java +++ b/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 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); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java index dedeb07..02b8749 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleService.java +++ b/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 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 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); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java index fd5776d..9526512 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java +++ b/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()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java index bc2c28e..0546de5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardService.java +++ b/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) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index d7c4f2f..9c6482f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -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"), diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java index 0833cf7..a2323aa 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java +++ b/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, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java new file mode 100644 index 0000000..71e8740 --- /dev/null +++ b/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 companySupplier) { + if (actor != null && actor.getRoles() != null + && actor.getRoles().contains(User.Role.COMPANY)) { + requireVerified(companySupplier.get()); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java index 09c5429..14308a4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java +++ b/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)); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java index e8e2ce4..690551d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java +++ b/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 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() : "企业"; } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreService.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreService.java index 0531f72..08dfef4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreService.java +++ b/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 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; } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java index 940a6d8..268140a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java +++ b/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 view = jobService.toView(jobService.getJob(jobId)); + Map view = jobService.toView(jobService.getCompanyJob(companyId, jobId)); return ResponseEntity.ok(ApiResponse.ok(view, requestIdAccessor.get(request))); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java index 483eb84..f2d51d5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java +++ b/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, JpaSpecificationExecutor { + /** 企业资源访问使用归属限定查询,避免取出其他企业的岗位后再区分归属。 */ + Optional findByIdAndCompanyId(Long id, Long companyId); + List findByCompanyIdAndStatusOrderByCreatedAtDesc(Long companyId, String status); List findByStatusOrderByCreatedAtDesc(String status); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java index b36913e..6abe1a9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java +++ b/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)); + } + // ── 公开接口 ──────────────────────────────────────────────── /** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java index b443d95..e632e96 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java +++ b/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> listConversations(Long userId) { + requireVerifiedIfCompany(userId); List memberships = memberRepository.findByUserId(userId); if (memberships.isEmpty()) return List.of(); List convIds = memberships.stream().map(ConversationMember::getConversationId).toList(); @@ -86,6 +88,7 @@ public class MessagingService { } public Map getConversation(Long conversationId, Long userId) { + requireVerifiedIfCompany(userId); Conversation c = requireConversation(conversationId); assertMember(conversationId, userId); Map v = toView(c, userId); @@ -95,6 +98,7 @@ public class MessagingService { @Transactional public Map 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 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 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); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java index 0d4f049..cbe54d6 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationService.java +++ b/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 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 toView(Notification n) { Map v = new HashMap<>(); v.put("id", n.getId()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java index e0f52d8..2596f6b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java +++ b/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); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java index 13c4e98..252b6c3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java +++ b/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 r,Mapa){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 mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference>(){});}catch(Exception e){return Map.of();}} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java index 67f5f1a..3c28458 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java +++ b/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); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java index 152df9a..01f727a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationService.java +++ b/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) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java index 0af13ac..a8d013a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java +++ b/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); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java index 6fdf1dc..4bc80c9 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java +++ b/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); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index aa70296..8873a91 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/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,必须发生在基线快照之前, diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java index 347a276..083c4ba 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java @@ -340,13 +340,13 @@ class AuthenticationBoundaryIntegrationTest { void otherCompanyCannotModifyOwnedJob() throws Exception { List 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(); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java index 491ae08..e7fa52b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java +++ b/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()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchServiceTest.java index c86f16e..c2e3c45 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/BidirectionalMatchServiceTest.java +++ b/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")); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java index a9c62a2..1c76050 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchConversationServiceTest.java +++ b/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); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java index 20105c3..ebfca46 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/MatchRuleServiceTest.java +++ b/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)); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java index ab8bf3b..c89d71b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java +++ b/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()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java index 79b3840..efcfb41 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java +++ b/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()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java index 8d322ae..edfc78f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java @@ -19,7 +19,7 @@ class GlobalExceptionHandlerStatusContractTest { private static final Set 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 NOT_FOUND = Set.of( "COMPANY_NOT_FOUND", "PROFILE_NOT_FOUND", "JOB_NOT_FOUND", "APPLICATION_NOT_FOUND", diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java index 5de1339..2cd2341 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java +++ b/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 numeric = ErrorHttpStatusRegistry.numericMappingsView(); assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) .isInstanceOf(UnsupportedOperationException.class); - assertThat(numeric).hasSize(118); + assertThat(numeric).hasSize(119); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java index 33efd4e..329b18c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java +++ b/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 PHASE_A_SERVICE_UNAVAILABLE = Set.of( "COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE"); + private static final Set 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); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java index 6bd1df2..52f63fd 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java +++ b/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 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 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 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 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]); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java index a2cae93..446649d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java +++ b/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 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(); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java index cf4e77d..a8a1b17 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java +++ b/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 diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java index d705f87..e390b6a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java +++ b/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()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java index 2ea3ee1..680f942 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java +++ b/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() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java index 134ad82..be0ea76 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java +++ b/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()); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java index b3391f7..1b8a110 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java +++ b/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)); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScorePersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScorePersistenceIntegrationTest.java index 74485f8..d56a240 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScorePersistenceIntegrationTest.java +++ b/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(); } } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreServiceTest.java index 32e9eca..7d5bc13 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreServiceTest.java +++ b/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()); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobServiceTest.java index 40bf93f..d9aa185 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobServiceTest.java +++ b/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()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java index 6657174..9c91286 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java +++ b/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 messageIds = new ArrayList<>(); private final List conversationIds = new ArrayList<>(); private final List userIds = new ArrayList<>(); + private final List 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(); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java index 4b61ca6..6d6c3b3 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java +++ b/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); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java index 29ae3a1..1dd9236 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationServiceTest.java +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java index efebc5a..0dce18b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java +++ b/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); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/phaseb/PhaseBVerifiedCompanyGateContractTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/phaseb/PhaseBVerifiedCompanyGateContractTest.java new file mode 100644 index 0000000..305b2c3 --- /dev/null +++ b/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> 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 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> 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)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGateMySqlBlackboxTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGateMySqlBlackboxTest.java new file mode 100644 index 0000000..ce0410a --- /dev/null +++ b/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 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 redisTemplate; + + private final List userIds = new ArrayList<>(); + private final List companyIds = new ArrayList<>(); + private final List jobIds = new ArrayList<>(); + private final List applicationIds = new ArrayList<>(); + private final List storagePaths = new ArrayList<>(); + + @Test + @SuppressWarnings("unchecked") + void unverifiedPendingAndRejectedCompaniesAreDeniedOverHttpWithoutDatabaseSideEffects() throws Exception { + ValueOperations 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> beforeRequest = snapshot(); + String body = "{\"status\":\"PUBLISHED\"}"; + ResponseEntity 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> before = snapshot(); + ResponseEntity list = exchange("/api/v1/company/candidates", HttpMethod.GET, + pending.token(), null); + assertGateDenied(list, pending.phone()); + ResponseEntity detail = exchange("/api/v1/company/candidates/" + applicationId, + HttpMethod.GET, pending.token(), null); + assertGateDenied(detail, pending.phone()); + RESUME_READS.set(0); + ResponseEntity 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 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> before = snapshot(); + Long missingId = foreignDraft.getId() + 900_000L; + for (String method : new String[]{"GET", "PUT"}) { + ResponseEntity foreign = exchange("/api/v1/jobs/" + foreignDraft.getId(), + HttpMethod.valueOf(method), companyA.token(), method.equals("PUT") + ? "{\"title\":\"attempted cross-tenant update\"}" : null); + ResponseEntity 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 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 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 authorized(String token, String body) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.AUTHORIZATION, token); + return new HttpEntity<>(body, headers); + } + + private void assertGateDenied(ResponseEntity 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 response) throws Exception { + JsonNode body = objectMapper.readTree(response.getBody()); + return body.path("code").asInt(); + } + + private List> snapshot() { + List> 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) {} +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGatePersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGatePersistenceIntegrationTest.java new file mode 100644 index 0000000..e4e505d --- /dev/null +++ b/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 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGuardTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGuardTest.java new file mode 100644 index 0000000..f9a7e14 --- /dev/null +++ b/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())); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationServiceTest.java index 4c071e3..8ae315f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationServiceTest.java +++ b/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();