Browse Source

merge: add N04 onboarding checklist and company knowledge APIs

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
36c0214723
  1. 12
      README.md
  2. 50
      changes/n04-onboard/.spec-superflow.yaml
  3. 26
      changes/n04-onboard/.superpowers/sdd/execution-plan.yaml
  4. 26
      changes/n04-onboard/.superpowers/sdd/progress.md
  5. 59
      changes/n04-onboard/.superpowers/sdd/reviews/W1-T1.md
  6. 72
      changes/n04-onboard/.superpowers/sdd/reviews/W1-T2.md
  7. 150
      changes/n04-onboard/.superpowers/sdd/reviews/W1-wave.md
  8. 75
      changes/n04-onboard/.superpowers/sdd/reviews/W2-T3.md
  9. 94
      changes/n04-onboard/.superpowers/sdd/reviews/W2-T4.md
  10. 75
      changes/n04-onboard/.superpowers/sdd/reviews/W2-T5.md
  11. 136
      changes/n04-onboard/.superpowers/sdd/reviews/W2-wave.md
  12. 115
      changes/n04-onboard/.superpowers/sdd/reviews/W3-T6-rereview.md
  13. 104
      changes/n04-onboard/.superpowers/sdd/reviews/W3-T6.md
  14. 81
      changes/n04-onboard/.superpowers/sdd/reviews/W3-T7.md
  15. 83
      changes/n04-onboard/.superpowers/sdd/reviews/W3-T8-rereview.md
  16. 78
      changes/n04-onboard/.superpowers/sdd/reviews/W3-T8.md
  17. 113
      changes/n04-onboard/.superpowers/sdd/reviews/W3-wave.md
  18. 50
      changes/n04-onboard/.superpowers/sdd/task-briefs/T1.md
  19. 53
      changes/n04-onboard/.superpowers/sdd/task-briefs/T2.md
  20. 56
      changes/n04-onboard/.superpowers/sdd/task-briefs/T3.md
  21. 53
      changes/n04-onboard/.superpowers/sdd/task-briefs/T4.md
  22. 57
      changes/n04-onboard/.superpowers/sdd/task-briefs/T5.md
  23. 30
      changes/n04-onboard/.superpowers/sdd/task-briefs/T6-repair-1.md
  24. 55
      changes/n04-onboard/.superpowers/sdd/task-briefs/T6.md
  25. 47
      changes/n04-onboard/.superpowers/sdd/task-briefs/T7.md
  26. 35
      changes/n04-onboard/.superpowers/sdd/task-briefs/T8-repair-1.md
  27. 48
      changes/n04-onboard/.superpowers/sdd/task-briefs/T8.md
  28. 51
      changes/n04-onboard/decision-point-audit.md
  29. 329
      changes/n04-onboard/design.md
  30. 165
      changes/n04-onboard/execution-contract.md
  31. 65
      changes/n04-onboard/proposal.md
  32. 20
      changes/n04-onboard/spec-merge-report.md
  33. 125
      changes/n04-onboard/specs/company-knowledge/spec.md
  34. 151
      changes/n04-onboard/specs/onboarding-checklist/spec.md
  35. 47
      changes/n04-onboard/tasks.md
  36. 25
      changes/n04-onboard/verification-report.md
  37. 30
      docs/backend-development-roadmap.md
  38. 127
      docs/frontend-integration.md
  39. 434
      docs/onboarding-api.md
  40. 5722
      docs/openapi-rc.json
  41. 119
      docs/verify_openapi.py
  42. 10
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  43. 10
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  44. 2
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  45. 63
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java
  46. 74
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java
  47. 12
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java
  48. 411
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java
  49. 120
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java
  50. 95
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java
  51. 39
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java
  52. 560
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java
  53. 64
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java
  54. 19
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java
  55. 28
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java
  56. 34
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java
  57. 41
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java
  58. 24
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java
  59. 53
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java
  60. 21
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java
  61. 24
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java
  62. 23
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java
  63. 15
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java
  64. 23
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java
  65. 37
      mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java
  66. 19
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java
  67. 35
      mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java
  68. 57
      mydreamjob-server/src/main/resources/db/migration/V38__onboarding_checklist_and_company_knowledge.sql
  69. 31
      mydreamjob-server/src/main/resources/db/migration/V39__onboarding_standard_checklist_seed.sql
  70. 228
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java
  71. 448
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java
  72. 211
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java
  73. 166
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java
  74. 305
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java
  75. 277
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java
  76. 374
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java
  77. 433
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java
  78. 755
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingEndToEndIntegrationTest.java
  79. 157
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java
  80. 227
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java
  81. 435
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java
  82. 105
      mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java
  83. 129
      specs/company-knowledge/spec.md
  84. 155
      specs/onboarding-checklist/spec.md

12
README.md

@ -6,15 +6,16 @@
| 项目 | 当前状态 | | 项目 | 当前状态 |
| --- | --- | | --- | --- |
| Git 检查点 | V34 已冻结,最新检查点为 `bf75fc` | | Git 检查点 | N04 已建立 T7 暂停检查点 `73afd87`;T8 完成后形成最终检查点 |
| 数据库迁移 | Flyway V1–V33;V34 为纯接口聚合模块,无新增迁移 | | 数据库迁移 | Flyway V1–V39;N04 新增 V38–V39 |
| API 契约 | 126 paths / 160 operations | | API 契约 | 135 paths / 172 operations |
| 自动化测试 | 1275 个通过,0 失败,0 错误 | | 自动化测试 | 1492 个执行:1481 通过、2 失败、9 错误;失败均在既有 CareerProgress/JobReviewDimension/LocalResumeStorage 测试,与 N04 文档变更无关 |
| 已验收范围 | V27–V34;V33 已完成真实 MySQL/Docker 黑盒验收 | | 已验收范围 | V27–V34;V33 已完成真实 MySQL/Docker 黑盒验收 |
| N04 入职清单与公司知识介绍 | 实现完成,待真实 MySQL/Docker 黑盒验收 |
| 账号注销冷静期 | 30 天 | | 账号注销冷静期 | 30 天 |
| 本地服务端口 | `8080` | | 本地服务端口 | `8080` |
V35 霍兰德职业兴趣测评目前仅完成设计,尚未进入编码和数据库迁移阶段。 N04 入职清单与公司知识介绍已完成代码、N04 专项测试、OpenAPI 和联调文档;全量 `mvn clean verify` 仍受既有非 N04 测试失败阻塞,待真实 MySQL/Docker 黑盒验收。V35 霍兰德职业兴趣测评目前仅完成设计,尚未进入编码和数据库迁移阶段。
## 仓库结构 ## 仓库结构
@ -123,6 +124,7 @@ python .\docs\verify_openapi.py
- [OpenAPI 契约](docs/openapi-rc.json) - [OpenAPI 契约](docs/openapi-rc.json)
- [前端联调说明](docs/frontend-integration.md) - [前端联调说明](docs/frontend-integration.md)
- [后端开发路线图](docs/backend-development-roadmap.md) - [后端开发路线图](docs/backend-development-roadmap.md)
- [入职清单与公司知识介绍](docs/onboarding-api.md)
- [统一求职进度接口](docs/career-progress-api.md) - [统一求职进度接口](docs/career-progress-api.md)
- [同岗位竞争力排名](docs/competition-ranking-api.md) - [同岗位竞争力排名](docs/competition-ranking-api.md)
- [举报与屏蔽](docs/report-block-api.md) - [举报与屏蔽](docs/report-block-api.md)

50
changes/n04-onboard/.spec-superflow.yaml

@ -0,0 +1,50 @@
version: 1
state: closing
workflow: full
change_name: n04-onboard
artifact_language: zh-CN
dp_0_confirmed: true
dp_0_result: confirmed
dp_0_timestamp: 2026-09-16T07:10:12Z
dp_0_decisions: |
变更名称:n04-onboard;一句话意图:为学生提供标准项与企业自定义项组合的入职清单,并基于企业资料、员工评价和公司维护内容提供公司知识介绍。
工作流:full;选择原因:新增业务模块、Flyway 表结构、九个 API、角色权限、幂等与并发控制、跨模块评价聚合。
约束:仅登录用户可访问;学生端读取/更新本人完成状态,企业端仅维护本企业自定义清单与知识内容;通用清单通过 Flyway 种子数据提供;企业未配置自定义项时回退通用清单;公司知识信息不足时返回企业基础信息;不调用 AI。
相关优化:保持聚焦,不纳入 ADMIN 模板治理、内容审核发布、版本回滚、通知提醒、职场内容推荐、试用期提醒、岗位或 Offer 个性化清单、前端开发。
沟通方式:四类规划工件一次生成,完成验证后统一进入 DP-2 审查。
artifact_language=zh-CN
dp_1_result: confirmed
dp_1_timestamp: 2026-09-16T07:10:12Z
artifacts:
order: [proposal, specs, design, tasks]
skip: []
dp_2_result: approved: proposal, two delta specs, design, and tasks validated
dp_2_confirmed: true
dp_2_timestamp: 2026-09-16T07:45:22Z
contract_hash: 79e718e36a2fd349a8c26dfd080e74a1844c01a6e52363613414694bc75a74d1
dp_3_result: approved: execution contract B1-B3; all 12 approved requirements mapped
dp_3_confirmed: true
dp_3_timestamp: 2026-09-16T07:55:53Z
execution_mode: sdd
plan_revision: 1
dp_4_result: approved: SDD selected; W1 serial T1-T2, W2 serial T3-T5 depends W1, W3 serial T6-T8 depends W2
dp_4_confirmed: true
dp_4_timestamp: 2026-09-16T07:58:31Z
batches_completed: 3
execution_paused: false
paused_at: 2026-09-16T09:51:43Z
paused_reason: user requested pause for the day; T7 tests are complete but independent T7 review and T8 remain pending
resume_from: T7-review
resume_mode: manual
resumed_at: 2026-09-17T01:17:47Z
resume_count: 1
verification_blocked: false
verification_outcome: conditional-pass-with-formal-waiver
dp_6_result: pass: N04 96/96 tests, OpenAPI 135 paths / 9 onboarding operations, package pass; 1492-test full suite has 2 failures and 9 errors in pre-existing non-N04 tests formally waived; real MySQL/Docker acceptance remains a pre-release gate
dp_6_timestamp: 2026-09-17T02:47:50Z
test_result: pass: scoped verification with formal waiver
spec_sync_result: manual publication passed: 12 requirements across 2 capabilities
spec_sync_timestamp: 2026-09-17T02:48:23Z
dp_7_result: confirmed: archive N04 and manually merge feat/n04-onboard to master
dp_7_timestamp: 2026-09-17T08:03:00Z
closed_at: 2026-09-17T08:03:00Z

26
changes/n04-onboard/.superpowers/sdd/execution-plan.yaml

@ -0,0 +1,26 @@
plan_revision: 1
selected_mode: sdd
contract_hash: 79e718e36a2fd349a8c26dfd080e74a1844c01a6e52363613414694bc75a74d1
created_at: 2026-09-16T07:58:31Z
current: verification
waves:
- id: W1
strategy: serial
tasks: [T1, T2]
depends_on: []
state: passed
receipt: .superpowers/sdd/reviews/W1-wave.md
- id: W2
strategy: serial
tasks: [T3, T4, T5]
depends_on: [W1]
state: passed
receipt: .superpowers/sdd/reviews/W2-wave.md
- id: W3
strategy: serial
tasks: [T6, T7, T8]
depends_on: [W2]
state: passed_with_external_blocker
receipt: .superpowers/sdd/reviews/W3-wave.md
verification_blocked: true
closure_requires: external full-suite repair or waiver; real MySQL 8.4 / Docker acceptance

26
changes/n04-onboard/.superpowers/sdd/progress.md

@ -0,0 +1,26 @@
# N04 SDD Progress Ledger
- 2026-09-16T07:58:31Z Plan revision 1 created for mode SDD.
- W1 eligible: T1 -> T2 (serial, no prerequisite).
- W2 blocked on W1: T3 -> T4 -> T5.
- W3 blocked on W2: T6 -> T7 -> T8.
- 2026-09-16T08:10:18Z Task T1: complete (commit 7fe96cc, review W1-T1 PASS, RED->GREEN, 2 tests).
- 2026-09-16T08:22:24Z Task T2: complete (commit ef4a9b8, review W1-T2 PASS, RED->GREEN, 5 tests).
- 2026-09-16T08:24:45Z W1 wave review PASS: 7 tests; W1 receipt accepted; W2/T3 eligible.
- 2026-09-16T08:38:07Z Task T3: complete (commit 037ddc7, review W2-T3 PASS, RED->GREEN, 15 tests).
- 2026-09-16T08:50:22Z Task T4: complete (commit 7370e51, review W2-T4 PASS, 22 tests; concurrent-create race deferred to T6).
- 2026-09-16T09:06:51Z Task T5: complete (commit a5730a7, review W2-T5 PASS, 22 tests).
- 2026-09-16T09:10:27Z W2 wave review PASS: 66 tests; W2 receipt accepted; W3/T6 eligible with concurrency obligations.
- 2026-09-16T09:27:35Z Task T6: failed attempt 1/3 - review W3-T6 FAIL; MySQL REPEATABLE READ concurrent 49->50 limit check can write item 51.
- 2026-09-16T09:40:55Z Task T6: complete after focused repair (commit 97b3b16; W3-T6-rereview PASS; 22 focused tests, 85 onboarding tests).
- 2026-09-16T09:51:11Z Task T7: complete (no commit per instruction; RED->GREEN coverage added; OnboardingEndToEndIntegrationTest 7 tests; 2-review/tag/HIDDEN focused gaps; 96 onboarding tests pass; randomized E2E order pass).
- 2026-09-16T09:51:43Z Execution paused by user for the day. T7 tests complete (96 onboarding tests green); independent T7 review and T8 pending. Manual resume from T7-review only.
- 2026-09-17T01:17:47Z Manual resume accepted. W3 resumes at T7 independent review; T6 remains passed and T8 remains pending.
- 2026-09-17T01:21:54Z Task T7: review W3-T7 PASS; 30 focused tests, 96 onboarding regressions, randomized E2E pass.
- 2026-09-17T09:33:54Z Task T8: documentation implementation complete; after adding missing onboarding assertions, verifier RED was `Missing onboarding path: /onboarding/companies/{companyId}/checklist`; static OpenAPI now 135 paths / 172 operations and contains 9 onboarding operations / 6 paths / 20 schemas; `python docs/verify_openapi.py` PASS; N04 focused regression PASS (96 tests); `mvn clean verify` FAIL (1492 tests, 2 failures, 9 errors) in existing CareerProgress/JobReviewDimension/LocalResumeStorage tests unrelated to T8; `mvn -DskipTests package` PASS; no commit; independent T8/W3 review pending.
- 2026-09-17T01:41:13Z Task T8: failed attempt 1/3 - review W3-T8 FAIL; OpenAPI knowledge example lacks version and create request schema omits runtime defaults.
- 2026-09-17T01:45:47Z Task T8: complete after focused repair (commit 310d931; W3-T8-rereview PASS; OpenAPI 135 paths / 172 operations, 9 onboarding operations / 6 paths; 96 tests pass).
- 2026-09-17T01:50:30Z W3 wave review PASS (conditional within N04): 96 tests, RR boundary 1, randomized E2E 7, OpenAPI 135 paths/9 operations; external blockers are existing full-suite failures and pending MySQL/Docker acceptance.
- 2026-09-17T02:47:50Z DP-6 pass with formal waiver: user accepted existing non-N04 full-suite failures; real MySQL/Docker remains a pre-release gate.
- 2026-09-17T02:48:23Z Spec merge completed manually: onboarding-checklist 6 requirements / 20 scenarios, company-knowledge 6 requirements / 16 scenarios; no conflicts.
- 2026-09-17T08:03:00Z DP-7 confirmed; state transitioned to closing; manual no-ff merge to master authorized.

59
changes/n04-onboard/.superpowers/sdd/reviews/W1-T1.md

@ -0,0 +1,59 @@
# W1 T1 Review: V38/V39 Migrations and Standard Seed
## Verdict
- **Spec compliance:** PASS
- **Task quality:** Approved
- **Overall:** PASS
- **Base:** `a2c66b3`
- **Head:** `7fe96cc`
- **Blocking findings:** none
## Verification
- Inspected the complete `a2c66b3..7fe96cc` diff, T1 brief, execution contract, design data model, V1 schema for FK type compatibility, and both new tests.
- Re-ran the exact T1 evidence command from `mydreamjob-server`:
`mvn -Dtest=OnboardingMigrationTest,OnboardingSeedDataIntegrationTest test`
- Result: `Tests run: 2, Failures: 0, Errors: 0, Skipped: 0`, `BUILD SUCCESS`.
## Spec Compliance
- V38 creates all three required tables and matches the approved column set, lengths, nullability, defaults, FK targets, uniqueness rules, and active/sort index in `design.md`: `V38__onboarding_checklist_and_company_knowledge.sql:4-57`.
- FK child/parent types are compatible with the existing `users.id` and `companies.id` BIGINT primary keys (`V1__base_schema.sql:5-6` and the existing company schema).
- V39 contains 12 fixed standard-checklist rows with explicit `item_key`, title, category, `sort_order`, `required`, and `active` values; there is no time-, request-, or random-dependent seed input (`V39__onboarding_standard_checklist_seed.sql:4-31`).
- `OnboardingMigrationTest.java:37-60` verifies the three tables, required columns, four unique indexes, four foreign keys, and the active/sort index.
- `OnboardingSeedDataIntegrationTest.java:51-79` verifies exact seed cardinality, content, category, required/active flags, `scope_type`, `scope_id`, and stable `sort_order ASC, id ASC` ordering.
- The diff adds only the two migrations plus their tests and SDD task-brief artifacts. No V1-V37 migration, existing API, or W2 entity/repository/service/controller was modified.
- Static inspection supports H2 `MODE=MySQL` and MySQL 8.4 syntax compatibility; the focused H2 test context passed.
## Issues
### Critical (Must Fix)
None.
### Important (Should Fix)
None.
### Minor (Nice to Have)
- `OnboardingMigrationTest.java:38-60` checks column presence but not the migrated column types, nullability, defaults, or CHECK constraints. The generated SQL matches the design and the key unique/FK/index obligations are covered, so this is only a future drift-detection gap.
- The T1 commit also adds `T2.md`, which is outside the T1 Files In Scope (`task-briefs/T1.md:14-19`). It has no runtime or contract impact, but implementation commits should normally remain limited to controller-created planning artifacts or T1-owned files.
## Residual Verification Warning
- Real MySQL 8.4 execution was not performed in this review. The SQL is compatible by inspection and the T1 Done Definition's two H2 evidence tests pass, but the B1 verification gate should retain the contract's real-MySQL check before final acceptance.
- TDD RED is credibly reported with a missing-table failure and the current checkout independently reproduces GREEN. The RED order cannot be reconstructed from the single final commit, because tests and migrations were committed together; this does not affect the implementation verdict.
## Assessment
**Task quality:** Approved
**Reasoning:** T1 implements the approved schema and deterministic standard seed with appropriate focused integration coverage, no scope-breaking implementation changes, and a reproducible green evidence run.
Controller receipt command when `ssf` is available:
```bash
ssf execution review changes/n04-onboard --wave W1 --base a2c66b3 --head 7fe96cc --report changes/n04-onboard/.superpowers/sdd/reviews/W1-T1.md --verdict pass
```

72
changes/n04-onboard/.superpowers/sdd/reviews/W1-T2.md

@ -0,0 +1,72 @@
# W1 T2 Review: Onboarding Entities, Repositories, and Review Aggregation
## Verdict
- **Spec compliance:** PASS
- **Task quality:** Approved
- **Overall:** PASS
- **Base:** `7fe96cc`
- **Head:** `ef4a9b8`
- **Blocking findings:** none
- **Cannot verify:** real MySQL 8.4 schema validation and JSON/tag retrieval; H2 `MODE=MySQL` passed.
## Verification
- Read `task-reviewer-prompt.md`, `writing-good-tests.md`, T2 brief, execution contract, design data model, V38, the complete `7fe96cc..ef4a9b8` diff, and the existing `JobReview`, `JobReviewDimensionScore`, `Job`, and `Company` mappings needed to judge the named risks.
- Scope check: the diff contains only the nine files allowed by the T2 brief, with 732 added lines and no unrelated or modified historical files.
- Re-ran the focused T2 evidence command from `mydreamjob-server` with `JAVA_HOME=D:\java` and Maven `3.9.7`.
- Result: `Tests run: 5, Failures: 0, Errors: 0, Skipped: 0`, `BUILD SUCCESS`.
- `git diff --check 7fe96cc..ef4a9b8` reported no whitespace errors.
## Spec Compliance
- `OnboardingChecklistItem.java:27-94` maps the approved table, all fields, unique constraints, defaults, optimistic version, and audit timestamps against `V38__onboarding_checklist_and_company_knowledge.sql:4-30`.
- `request_hash` is mapped with `CHAR(64)` at `OnboardingChecklistItem.java:80`, matching `V38__onboarding_checklist_and_company_knowledge.sql:17`; `OnboardingRepositoryIntegrationTest.java:107-133` verifies both persistence and the migrated H2 column type/length.
- `StudentOnboardingProgress.java:27-63` and `CompanyKnowledge.java:27-62` match the remaining V38 columns, nullability, lengths, unique keys, versions, and audit fields.
- `OnboardingChecklistItemRepository.java:13-25` filters `active=true`, selects active standard rows plus active rows for the requested company, requires both company discriminators for company rows, and orders by `sortOrder ASC, id ASC`.
- `StudentOnboardingProgressRepository.java:14-18` provides both the student/item batch lookup and the exact student/item lookup required by T2.
- `CompanyKnowledgeRepository.java:9-11` provides the unique company lookup.
- `JobReviewRepository.java:35-67` aggregates only reviews whose status is `PUBLISHED` and whose moderation status is `AUTO_APPROVED` or `APPROVED`; all company aggregation is scoped through `jobs.company_id`.
- `JobReviewDimensionScoreRepository.java:46-63` performs the eight-dimension aggregation in one database query and applies the same company, publication-status, and moderation-status filters.
- The review aggregation queries avoid loading review entities or issuing per-job/per-review queries, satisfying the no-N+1 requirement.
- `OnboardingRepositoryIntegrationTest.java:78-195` covers active/inactive standard and company items, cross-company isolation, stable ordering, request-hash mapping, student/item progress scoping, and company knowledge scoping.
- `OnboardingRepositoryIntegrationTest.java:198-264` covers company scoping, moderation filtering, score distribution, raw tags, and dimension aggregation. Tags are selected as the raw database column and ordered deterministically by review ID, as required.
- No service, controller, DTO, state machine, migration, or unrelated production file was added by T2.
## Strengths
- The entity mappings follow the approved V38 schema closely, including the non-obvious `CHAR(64)` request hash.
- The effective-checklist query encodes both company ownership discriminators, preventing a cross-company item from leaking through a mismatched `scope_id` or `company_id`.
- Company review aggregation is implemented as set-based JPQL and consistently applies public-status rules in every query.
- The integration test uses real Spring Data/JPA persistence and independently checks cross-company and moderation boundaries rather than mocking repository behavior.
## Issues
### Critical (Must Fix)
None.
### Important (Should Fix)
None.
### Minor (Nice to Have)
- `OnboardingRepositoryIntegrationTest.java:217-219` labels a review as hidden by setting `moderationStatus=PENDING`, but every company-A review still has `status=PUBLISHED`. A production mutation removing the `r.status = 'PUBLISHED'` predicate from all three company queries would therefore still pass the test. Add a review with `status=HIDDEN` and `moderationStatus=APPROVED`, then assert it is excluded from count, rating distribution, tags, and dimensions. The production queries are currently correct, so this is a coverage gap rather than a runtime defect.
- The checklist query trusts `scope_type=STANDARD` without also enforcing the approved `scope_id=0`/`company_id IS NULL` invariant. The design explicitly assigns that consistency check to the application layer and T2 adds no standard-writing path, so this is only defense in depth.
## Residual Verification Warning
- The implementer correctly disclosed that real MySQL 8.4 was not exercised. H2 `MODE=MySQL` validates the new mappings and executes the aggregation queries successfully, but MySQL schema validation and JSON/tag returns remain a B1 verification-gate check.
## Assessment
**Task quality:** Approved
**Reasoning:** T2 implements the approved persistence mappings and repository queries with correct scoping and public-review filtering, keeps the diff within scope, and passes focused integration evidence. The only findings are non-blocking coverage/defense-in-depth suggestions.
Controller receipt command when `ssf` is available:
```bash
ssf execution review changes/n04-onboard --wave W1 --base 7fe96cc --head ef4a9b8 --report changes/n04-onboard/.superpowers/sdd/reviews/W1-T2.md --verdict pass
```

150
changes/n04-onboard/.superpowers/sdd/reviews/W1-wave.md

@ -0,0 +1,150 @@
# W1 Wave Review: Onboarding Data Model and Read-Only Query Foundation
## Verdict
- **Wave:** W1
- **Base:** `a2c66b3`
- **Head:** `ef4a9b8`
- **Spec compliance:** PASS
- **Task quality:** PASS
- **Overall:** PASS
- **Blocking findings:** None
## Scope Reviewed
Read and checked:
- `changes/n04-onboard/execution-contract.md`
- `changes/n04-onboard/specs/onboarding-checklist/spec.md`
- `changes/n04-onboard/specs/company-knowledge/spec.md`
- `changes/n04-onboard/design.md`
- `changes/n04-onboard/.superpowers/sdd/task-briefs/T1.md`
- `changes/n04-onboard/.superpowers/sdd/task-briefs/T2.md`
- `changes/n04-onboard/.superpowers/sdd/reviews/W1-T1.md`
- `changes/n04-onboard/.superpowers/sdd/reviews/W1-T2.md`
- Complete `a2c66b3..ef4a9b8` implementation diff, including migrations, entities, repositories, review aggregation queries, and integration tests.
`git diff --check a2c66b3..ef4a9b8` reported no whitespace errors. The wave contains no service, controller, DTO, or state-machine implementation, matching W1 scope.
## Test Evidence
Command run from `mydreamjob-server`:
```powershell
$env:JAVA_HOME='D:\java'; $env:Path='D:\java\bin;'+$env:Path
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=OnboardingMigrationTest,OnboardingSeedDataIntegrationTest,OnboardingRepositoryIntegrationTest' test
```
Result:
```text
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
Per-class result:
- `OnboardingMigrationTest`: 1 test, 0 failures/errors/skips.
- `OnboardingRepositoryIntegrationTest`: 5 tests, 0 failures/errors/skips.
- `OnboardingSeedDataIntegrationTest`: 1 test, 0 failures/errors/skips.
## Cross-Task Integration Review
### Migrations vs Entity Mappings
PASS.
- V38 creates all three approved tables with the contract column set, nullability, lengths, defaults, foreign keys, uniqueness rules, and active/sort index.
- Entity mappings match V38 for all three tables: `OnboardingChecklistItem`, `StudentOnboardingProgress`, and `CompanyKnowledge`.
- `request_hash` is mapped explicitly as `CHAR(64)`, matching V38 and the design requirement.
- Unique constraint annotations on the entities match the database constraint names and column sets:
- `uk_onboarding_checklist_scope_item`
- `uk_onboarding_checklist_company_idempotency`
- `uk_student_onboarding_progress_student_item`
- `uk_company_knowledge_company`
- The new entities are validated under H2 `MODE=MySQL` with `ddl-auto=validate`; the test scopes validation to the three new onboarding tables because an unrelated legacy H2 type mismatch exists in `auth_tokens`.
- No V1-V37 migration or existing table definition was modified.
### Unique Constraints and Version Fields
PASS.
- Checklist item uniqueness matches the design for standard/company scope and per-company idempotency.
- Student progress is unique on `(student_user_id, item_id)`.
- Company knowledge is unique on `company_id`.
- All three entities use `@Version Long` with builder default `0L`, matching the database `version BIGINT NOT NULL DEFAULT 0`.
- No cross-task mismatch was found between database defaults and entity initial values.
### Effective Checklist Query
PASS for W1.
- `findEffectiveChecklistItems(companyId)` returns only active rows.
- It includes active standard rows and active company rows only when both `scopeId` and `companyId` equal the requested company.
- Ordering is deterministic: `sortOrder ASC, id ASC`.
- Integration coverage verifies inactive standard/company items are excluded, other-company items are excluded, and equal-sort-order rows are stable by ID.
- Non-blocking follow-up: the query trusts `scope_type=STANDARD` without also requiring `scope_id=0` and `company_id IS NULL`. The design assigns that consistency invariant to the application layer, and no W1 write path can create malformed standard rows. W2 should preserve that invariant when adding services.
### Progress Lookup
PASS for W1.
- Batch lookup is scoped by `studentUserId` and a collection of item IDs.
- Exact lookup is scoped by `studentUserId` and `itemId`.
- The database/entity unique constraint guarantees at most one progress row per student/item pair.
- Integration coverage verifies multi-student isolation and correct `completedAt` retrieval.
- Non-blocking follow-up for W2: first-write races must be translated through the unique constraint into the approved idempotent/concurrent behavior; W2 also needs to define behavior for an empty item collection if that path is possible.
### Public-Only Company Review Aggregation
PASS.
- Rating count/average, rating distribution, and tag queries all restrict to `status = 'PUBLISHED'` and moderation status `IN ('AUTO_APPROVED', 'APPROVED')`.
- Dimension aggregation applies the same publication and moderation predicates.
- Every company-level query scopes reviews through `jobs.company_id`; other-company data is excluded.
- Aggregation is set-based and does not introduce an obvious N+1 path.
- Integration coverage verifies company isolation, moderation filtering, rating distribution, tags, and eight-dimension aggregation.
- Non-blocking coverage follow-up: add a review with `status=HIDDEN` and `moderationStatus=APPROVED` to prove the status predicate independently of the moderation predicate. The production queries are currently correct.
### Scope Discipline
PASS.
- Production changes are limited to the three migrations, six onboarding persistence files, and additive methods in the two existing review repositories.
- No existing API behavior, historical migration, or unrelated production file was changed.
- T1 review noted that its commit also added the T2 task brief; this is planning-artifact housekeeping and has no runtime or contract impact.
- No W2/W3 service, controller, DTO, security, or OpenAPI implementation was introduced.
### Readiness for W2
PASS with documented follow-ups.
W2 can build on the W1 interfaces for effective checklist reads, student/item progress lookup, company knowledge lookup, and public company review aggregation. Before W2 closes, it must:
- Validate and enforce standard/company scope consistency in service code.
- Handle progress insertion races and version conflicts according to 9705 semantics.
- Preserve the required PUBLISHED + approved moderation filter in all knowledge-summary paths.
- Parse raw JSON tags robustly across H2 and MySQL, since H2 returns the raw JSON column in a representation that may include outer quoting.
- Preserve deterministic tie-breaking for top tags and ordering of response data.
- Keep the real MySQL 8.4 migration/schema check as the B1 verification-gate obligation; H2 `MODE=MySQL` passed, but real MySQL was not run in this wave.
## Findings
### Critical
None.
### Important
None.
### Minor
- Add the `status=HIDDEN, moderationStatus=APPROVED` aggregation test case noted above.
- Add defense-in-depth scope-consistency enforcement for standard rows in the effective-checklist service path.
- Preserve the existing validation-filter workaround as test-only infrastructure; it should not mask new onboarding mapping mismatches.
## Assessment
W1 is internally consistent and ready to hand off to W2. The migrations, entity mappings, repository queries, and focused integration evidence agree with the approved contract and both specs. No blocking finding prevents W2 from starting.

75
changes/n04-onboard/.superpowers/sdd/reviews/W2-T3.md

@ -0,0 +1,75 @@
# W2 T3 Review: Student Checklist and Completion APIs
## Verdict
- **Spec compliance:** PASS
- **Task quality:** Approved
- **Overall:** PASS
- **Base:** `3e7c6e7`
- **Head:** `037ddc7`
- **Blocking findings:** none
- **Cannot verify from this task alone:** actual MySQL persistence behavior and a concurrent first-create race; these remain B3 integration-gate checks.
## Verification
- Read `task-reviewer-prompt.md`, `writing-good-tests.md`, the T3 brief, execution contract, checklist specification, design response/error contracts, the complete `3e7c6e7..037ddc7` diff, and focused source/test files where the diff output was truncated.
- Scope check: the diff contains exactly ten files: the eight planned T3 files plus additive changes to `ErrorCode.java` and `GlobalExceptionHandler.java`.
- Re-ran the T3 evidence command from `mydreamjob-server` with `JAVA_HOME=D:\java` and Maven 3.9.7.
- Result: `Tests run: 15, Failures: 0, Errors: 0, Skipped: 0`, `BUILD SUCCESS`.
- `git diff --check 3e7c6e7..037ddc7` reported no whitespace errors.
- The final commit cannot reconstruct RED-before-GREEN ordering, but the independently reproduced current HEAD is GREEN.
## Spec Compliance
- Effective checklist fallback is correct: `OnboardingChecklistService.java:45-48` delegates to `OnboardingChecklistItemRepository.java:13-25`, whose query returns active standard items plus active items for the requested company only. `OnboardingChecklistServiceTest.java:49-74` verifies the standard-only fallback and `customized=false`.
- Merging and stable ordering are correct: `OnboardingChecklistService.java:114-126` sorts by `sortOrder ASC, id ASC`; `:154-169` builds the merged response and sets `customized` from the presence of a `COMPANY` item. `OnboardingChecklistServiceTest.java:77-103` independently verifies order `2,3,1`, source, progress, and `customized=true`.
- Company existence is enforced before checklist/item/progress work in both GET and PUT paths (`OnboardingChecklistService.java:42-43`, `:62-63`). `OnboardingChecklistServiceTest.java:121-130` verifies `COMPANY_NOT_FOUND` and no later repository interaction.
- STUDENT-only access is enforced at service level with `FORBIDDEN` for non-students (`OnboardingChecklistService.java:104-111`); `OnboardingChecklistServiceTest.java:133-142` verifies the behavior before company lookup.
- Completion PUT uses absolute state semantics and avoids writes for an identical state (`OnboardingChecklistService.java:77-89`). `OnboardingChecklistServiceTest.java:175-191` verifies a stale version is ignored for an identical state, returns current version/completedAt, and performs no save.
- First-create behavior is correct for the service contract: a new progress entity defaults to version `0` (`StudentOnboardingProgress.java:52-55`), is saved once, and the response returns version `0` when no persisted version exists (`OnboardingChecklistService.java:90-99`, `:185-186`). `OnboardingChecklistServiceTest.java:145-172` verifies the inserted entity, `completedAt`, count, total, and rate.
- Stale-version conflict behavior is correct: a different desired state with a non-current version raises `ONBOARDING_PROGRESS_CONCURRENT_UPDATE` before mutation (`OnboardingChecklistService.java:82-85`), defined as `9705` in `ErrorCode.java:177`, and mapped to HTTP 409 in `GlobalExceptionHandler.java:167-169`. `OnboardingChecklistServiceTest.java:193-210` verifies the error code and no save.
- `completedAt` lifecycle is correct: first completion writes a timestamp, cancellation clears it, and identical-state requests preserve it (`OnboardingChecklistService.java:86-95`, `:183-184`). Covered by `OnboardingChecklistServiceTest.java:145-172`, `:175-191`, and `:213-238`.
- Completion-rate math is correct and deterministic: the service uses the effective item IDs, counts only completed rows, returns `0.0` for an empty list, and rounds one decimal place (`OnboardingChecklistService.java:215-233`). Tests cover `0/1`, `1/1`, empty, orphan-row exclusion, and `1/3 = 33.3` (`OnboardingChecklistServiceTest.java:49-118`, `:77-103`).
- Item scoping is correct: PUT resolves the item from the effective checklist query, so an item from another company, an inactive item, or a missing item produces `9701` without touching progress (`OnboardingChecklistService.java:65-71`); `OnboardingChecklistServiceTest.java:240-253` verifies no progress interaction.
- DTOs are whitelist-only and contain no owner, token, IP, moderation, or internal audit fields (`ChecklistItemView.java:14-27`, `EffectiveChecklistResponse.java:14-22`, `ProgressUpdateRequest.java:8-14`, `ProgressUpdateResponse.java:14-22`). Controller tests assert the externally visible response contract (`OnboardingChecklistControllerTest.java:44-103`).
- The two task errors are mapped correctly: `9701` maps to 404 and `9705` maps to 409 (`ErrorCode.java:175-177`, `GlobalExceptionHandler.java:65-80`, `:167-169`). The service tests independently assert the business error codes.
## Test Falsifiability
- The service tests exercise observable service behavior rather than implementation-only helpers: fallback, merge order, progress aggregation, idempotency, conflict, timestamp lifecycle, and scoping each have a plausible production mutation that would fail them.
- The controller tests exercise real MockMvc security/exception handling with a mocked service; they verify authentication, role error propagation, company-not-found status, request identity, and serialized DTO fields.
- `OnboardingChecklistServiceTest.java:77-103` specifically catches removal or incorrect rounding of the one-decimal completion rate through `1/3 = 33.3`.
- The main test gap is status mapping for `9705`: the service test proves the error code and `GlobalExceptionHandler.java:168-169` proves the 409 branch statically, but no controller/integration test would fail if that handler branch were removed. This is non-blocking because the mapping is present and B3 owns the HTTP integration matrix.
## Issues
### Critical (Must Fix)
None.
### Important (Should Fix)
None.
### Minor (Nice to Have)
- `OnboardingChecklistControllerTest.java:105-135` covers 401, 403, and 404 but has no PUT case returning 409 for `ONBOARDING_PROGRESS_CONCURRENT_UPDATE`. Add a focused MockMvc assertion for status 409 and code 9705, or retain it as the B3 end-to-end obligation.
- `GlobalExceptionHandler.java:65-80` and `ErrorCode.java:175-177` are outside the explicit eight-file T3 scope (`task-briefs/T3.md:14-23`). Both changes are additive and necessary for the required 404/409 contract, and `ErrorCode.java` is explicitly allowed by the execution contract; the task brief and T6 ownership should be reconciled so T6 does not duplicate or unintentionally renumber them.
- The focused test run emits existing Spring/Mockito/H2 configuration warnings from the full `@SpringBootTest` controller context (`OnboardingChecklistControllerTest.java:34-42`). They do not affect the result, but a cleaner test context would reduce output noise.
## Residual Verification Warning
- The tests use mocks for the first insert, so the actual JPA insert version and a simultaneous first-create race are not established here. The current code can rely on the unique constraint for sequential idempotency, but concurrent first inserts should be exercised at the B3 gate.
- Real MySQL 8.4 execution was not performed; H2 `MODE=MySQL` passed and the remaining MySQL proof belongs to the verification gate.
## Assessment
**Task quality:** Approved
**Reasoning:** T3 implements the approved student checklist and completion behavior with correct fallback, ordering, scoping, idempotency, timestamp lifecycle, rate calculation, DTO minimization, and error-code behavior. The only findings are non-blocking test/plan reconciliation items.
Controller receipt command when `ssf` is available:
```bash
ssf execution review changes/n04-onboard --wave W2 --base 3e7c6e7 --head 037ddc7 --report changes/n04-onboard/.superpowers/sdd/reviews/W2-T3.md --verdict pass
```

94
changes/n04-onboard/.superpowers/sdd/reviews/W2-T4.md

@ -0,0 +1,94 @@
# W2 T4 Review: Company Custom Checklist CRUD and Idempotency
## Verdict
- **Spec compliance:** PASS
- **Task quality:** Approved
- **Overall:** PASS
- **Base:** `037ddc7`
- **Head:** `7370e51`
- **Blocking findings:** none
- **Deferred blocker:** concurrent create / 50-item limit races are not resolved by this task and must be treated as mandatory T6 concurrency-gate obligations.
## Verification
- Read `task-reviewer-prompt.md`, `writing-good-tests.md`, `task-briefs/T4.md`, the execution contract, `specs/onboarding-checklist/spec.md`, the design, and the complete `037ddc7..7370e51` diff. Focused reads were used only where the diff output was truncated mid-function.
- Scope check: the commit changes 9 files: the 7 files named by the T4 brief plus additive changes to `ErrorCode.java` and `GlobalExceptionHandler.java`.
- Re-ran the T4 evidence command from `mydreamjob-server` with `JAVA_HOME=D:\java` and Maven 3.9.7.
- Result: `Tests run: 22, Failures: 0, Errors: 0, Skipped: 0`, `BUILD SUCCESS`.
- `git diff --check 037ddc7..7370e51` reported no whitespace errors.
- RED-before-GREEN ordering cannot be reconstructed from the final commit; the independently reproduced current HEAD is GREEN.
## Spec Compliance
- COMPANY ownership is enforced centrally: `requireCompanyOwner` checks the COMPANY role and resolves the company by `ownerUserId`, missing roles return FORBIDDEN, and missing company profiles return COMPANY_NOT_FOUND (`OnboardingChecklistService.java:229-239`). The role/profile tests are at `OnboardingCompanyChecklistServiceTest.java:269-297`.
- Company listing is isolated to the current company and includes inactive rows: `findAll()` results are filtered by `scopeType=COMPANY`, `scopeId`, and `companyId`, then sorted (`OnboardingChecklistService.java:121-131`, `:241-257`). The test verifies own active/inactive items and excludes another company and a STANDARD item (`OnboardingCompanyChecklistServiceTest.java:47-68`).
- Create validation covers title, description, category, sortOrder, required default, and idempotency-key length in both the request DTO and service (`CompanyChecklistItemCreateRequest.java:9-33`, `OnboardingChecklistService.java:266-289`). Invalid requests return `BAD_REQUEST` before repository access (`OnboardingCompanyChecklistServiceTest.java:71-88`).
- Create normalization and hashing are deterministic: title/description/idempotency key are trimmed, blank optional strings become null, required defaults to false, sortOrder defaults to 1000, and the canonical request is SHA-256 hashed (`OnboardingChecklistService.java:140-148`, `:315-349`). The persistence test verifies the normalized entity and hash shape (`OnboardingCompanyChecklistServiceTest.java:91-122`).
- Sequential idempotency replay and conflict behavior are correct: an existing `(company, idempotencyKey)` with the same hash returns the original row without a second save; a different hash raises 9704 (`OnboardingChecklistService.java:150-163`; tests at `OnboardingCompanyChecklistServiceTest.java:125-166`).
- The 50-active-item boundary is enforced in the sequential path: active rows are counted, and 50 existing active rows produce 9703 before save (`OnboardingChecklistService.java:165-170`; test at `OnboardingCompanyChecklistServiceTest.java:169-185`).
- Update applies every mutable field and checks the supplied version before mutation (`OnboardingChecklistService.java:189-209`). Stale versions return 9702 and do not save (`OnboardingCompanyChecklistServiceTest.java:188-228`).
- Delete is a soft delete and repeated deletion is idempotent: the active flag is set false with the current version, then a second delete returns the already-inactive row without another save (`OnboardingChecklistService.java:211-227`; test at `OnboardingCompanyChecklistServiceTest.java:231-251`).
- Cross-company mutation is blocked by the same ownership predicate used by update and delete; an item belonging to another company returns 9701 and remains unchanged (`OnboardingChecklistService.java:252-264`; delete isolation test at `OnboardingCompanyChecklistServiceTest.java:254-266`).
- The response DTO is whitelist-only and exposes item identity, source, key, display fields, sort/active/version; it does not expose company IDs, owner IDs, idempotency keys, request hashes, timestamps, or internal audit fields (`CompanyChecklistItemView.java:14-23`).
- Error mappings are correct: 9702, 9703, and 9704 are defined (`ErrorCode.java:175-179`) and map to HTTP 409 (`GlobalExceptionHandler.java:167-172`); 9701 remains mapped to 404. Controller tests cover 409 for 9702/9704 and 404 for 9701 (`OnboardingCompanyChecklistControllerTest.java:120-132`, `:167-189`, `:211-233`).
- Controller wiring matches the fixed endpoints and role-neutral transport contract: all four company CRUD methods resolve the authenticated user and delegate to the service (`OnboardingChecklistController.java:65-108`). Authentication is covered at `OnboardingCompanyChecklistControllerTest.java:235-250`.
## Test Falsifiability
- The service tests assert observable results and side effects: persisted scope/company/defaults, replay returning the original ID with one save, conflict preventing a save, stale version preventing mutation, and repeat deletion not saving twice. Each has a plausible production mutation that would fail it.
- Controller tests exercise real MockMvc security, validation, serialization, and exception mapping with a mocked service. They would catch wrong paths, missing role passthrough, weak request validation, and incorrect 409/404 mappings.
- The 50-item test catches blocking at exactly 50, but does not independently prove that 49 active items are allowed or that inactive items do not count toward the limit. The implementation is correct by inspection, but the lower boundary/filtering semantics are not fully falsifiable from the current test.
- Cross-company isolation is directly tested for delete only. Update and list use the same ownership predicate, and the controller test mocks the cross-company update result, so the service-level update isolation path is not independently exercised.
- The HTTP mapping for 9703 is statically present but has no focused MockMvc assertion; only the service error code is tested. This is non-blocking because T6 owns the full HTTP/concurrency integration gate.
## Concurrent Create / Limit Race Disposition
The reported race is real, but it is **not a blocking finding for T4**.
- The current create path reads `findAll()` and checks idempotency/active count before inserting (`OnboardingChecklistService.java:150-186`). There is no row lock or atomic counter.
- V38 does have a unique key on `(company_id, idempotency_key)` (`V38__onboarding_checklist_and_company_knowledge.sql`/equivalent migration line 27; entity declaration `OnboardingChecklistItem.java:29-35`). Simultaneous identical creates can therefore make one request fail with `DataIntegrityViolationException`; `saveCompanyItem` only translates optimistic-lock failures (`OnboardingChecklistService.java:366-373`), so the loser can surface as a 500 instead of a safe replay.
- The 50-item limit is also non-atomic: two concurrent creates at 49 active items can both observe 49 and insert, producing 51 active items.
- T4's brief and Done Definition require sequential validation, replay/conflict, and the 50-item boundary, but do not assign concurrent execution to T4. The execution contract maps concurrency to B3, and the T6 brief explicitly requires concurrent/idempotent conflict behavior, error mapping, no partial writes, and the concurrency integration test (`task-briefs/T6.md:12`, `:18`, `:21`, `:30-33`, `:51`).
Mandatory T6 test obligations:
1. Fire two concurrent creates with the same company, key, and normalized body: assert exactly one row, both callers receive the same successful item (or the loser receives the same successful replay), and neither receives an internal/500 response.
2. Fire two concurrent creates with the same key and differing bodies: assert exactly one row, one successful create, and one 409 `9704`, not a unique-constraint 500.
3. Start with 49 active items and concurrently create two distinct items: assert the final active count is 50, one request succeeds, one returns 409 `9703`, and no partial/extra row remains.
4. Start at 50 active items and verify new creates return 409 `9703`; verify replaying an existing idempotency key still returns the original item without counting as a new item.
5. Repeat the key test across two companies to prove `(company_id, idempotency_key)` isolation and no cross-company replay.
## Issues
### Critical (Must Fix)
None.
### Important (Should Fix)
None for T4. The concurrent create/limit race is deferred by the approved execution plan to T6, but it must not be closed without the obligations above.
### Minor (Nice to Have)
- Strengthen `OnboardingCompanyChecklistServiceTest.java:169-185` with a 49-active success case and an inactive-item-does-not-count case; the current test proves the upper rejection only.
- Add a service-level cross-company update test alongside the existing delete isolation test, or explicitly retain this as a T6 integration obligation.
- Add a focused MockMvc assertion for 9703 -> HTTP 409, since only the service error code is currently exercised for the limit path.
- `ErrorCode.java` and `GlobalExceptionHandler.java` are outside the seven files named by `task-briefs/T4.md:14-23`. The additions are limited to the T4 error codes and are necessary for the required 409 contract; reconcile ownership in the task ledger so T6 does not duplicate or renumber them.
## Residual Verification Warning
- Real MySQL 8.4 execution and true concurrent JPA behavior were not exercised by this T4 unit/MockMvc suite. H2-backed MockMvc tests passed; the remaining database/concurrency proof belongs to the B3/T6 verification gate.
## Assessment
**Task quality:** Approved
**Reasoning:** T4 implements the approved sequential company CRUD, ownership isolation, validation, deterministic idempotency replay/conflict, optimistic update, soft-delete idempotency, DTO minimization, and error-code mappings. The only blocking-risk item is the explicitly deferred concurrent create/limit race, which must be proven and fixed at T6.
Controller receipt command when `ssf` is available:
```bash
ssf execution review changes/n04-onboard --wave W2 --base 037ddc7 --head 7370e51 --report changes/n04-onboard/.superpowers/sdd/reviews/W2-T4.md --verdict pass
```

75
changes/n04-onboard/.superpowers/sdd/reviews/W2-T5.md

@ -0,0 +1,75 @@
# W2-T5 Review: Company Knowledge Configuration and Public Review Summary
## Verdict
- Spec compliance: ✅ Spec compliant
- Task quality: **Approved**
- Blocking findings: **None**
## Scope Reviewed
- Task brief: `changes/n04-onboard/.superpowers/sdd/task-briefs/T5.md`
- Execution contract: `changes/n04-onboard/execution-contract.md`
- Spec: `changes/n04-onboard/specs/company-knowledge/spec.md`
- Diff: `7370e51..a5730a7`
- Implementer report: unverified implementer claims were checked against the diff and focused tests.
## Strengths
- The two endpoint groups use the required paths and contracts: COMPANY configuration is exposed at `CompanyKnowledgeController.java:32-49`, and the STUDENT company overview is exposed at `CompanyKnowledgeController.java:52-60`.
- COMPANY ownership and required role checks are enforced in `CompanyKnowledgeService.java:357-379`; STUDENT overview access is independently checked in the same service.
- Brand-new configuration returns `configured=false`, version `0`, and null content at `CompanyKnowledgeService.java:62-70`. Configured reads return the expected content and version at `CompanyKnowledgeService.java:323-331`.
- Upsert validation enforces the required per-field limits, at least one non-empty field, trimming, and HTML-tag rejection at `CompanyKnowledgeService.java:132-164`. Existing records require a current version and stale versions return 9706 at `CompanyKnowledgeService.java:92-102`.
- The company overview uses whitelist DTOs only: `CompanyKnowledgeOverviewResponse.java:16-52` excludes `ownerUserId` and other internal company fields, while `ReviewSummaryView.java:17-35` contains no review hashes, moderation reasons, IP, or User-Agent data.
- Public-review filtering is correctly applied at the query layer: `JobReviewRepository.java:35-67` and `JobReviewDimensionScoreRepository.java:46-63` constrain the company scope, `status='PUBLISHED'`, and moderation status `AUTO_APPROVED`/`APPROVED`.
- Threshold behavior is implemented at `CompanyKnowledgeService.java:166-191`: `EMPTY`, `INSUFFICIENT`, and `SUFFICIENT` are distinguished at 0, 1-2, and 3+ reviews, with aggregate queries skipped below 3.
- Tag parsing, deterministic count-descending/lexicographic sorting, and the 10-tag cap are implemented at `CompanyKnowledgeService.java:237-276`. The eight-dimension output is complete and deterministic at `CompanyKnowledgeService.java:287-320`, including zero-sample `INSUFFICIENT` dimensions.
- Error 9706 is defined and mapped to HTTP 409 at `ErrorCode.java:181` and `GlobalExceptionHandler.java:168-172`.
- The focused behavior tests assert observable service and HTTP contracts rather than only implementation shape. The public-filter repository integration test independently excludes non-approved and other-company reviews at `OnboardingRepositoryIntegrationTest.java:197-264`.
## Issues
### Critical (Must Fix)
None.
### Important (Should Fix)
None.
### Minor (Nice to Have)
- `CompanyKnowledgeServiceTest.java:216-237` covers one public review and `:240-287` covers three, but there is no exact two-review case. A threshold mutation from `< 3` to `< 2` would still pass these tests, leaving the 2/3 boundary insufficiently pinned. Add a two-review test asserting `INSUFFICIENT` and no aggregate output.
- The `INSUFFICIENT` branch test at `CompanyKnowledgeServiceTest.java:234-236` verifies the state, count, and that the distribution query is not called, but it does not assert that `averageRating`, `ratingDistribution`, `topTags`, and `dimensionSummary` are absent from the response. Current production behavior is correct at `CompanyKnowledgeService.java:177-181`, but the test would not catch a future mean-leak regression.
- Tag edge coverage is incomplete at `CompanyKnowledgeServiceTest.java:251-254`: the fixture has only three distinct tags and normal JSON representation. Removing the `.limit(MAX_TOP_TAGS)` guard or breaking the recursive H2 outer-quoted JSON handling would not fail the current suite. Add an 11+ tag case and a quoted JSON fixture if the recursive compatibility behavior is intended to remain supported.
- The public-filter integration test covers `PUBLISHED + PENDING` and another company, but not `HIDDEN + APPROVED`. The query itself explicitly filters status and is correct, so this is a coverage gap rather than a current behavior defect.
- `ErrorCode.java:181` and `GlobalExceptionHandler.java:168-172` are outside the T5 brief's explicit file list. The additive changes are necessary for the required 9706/409 contract, but file ownership should be reconciled during T8/documentation review.
## Deferred Concurrency Risk
Two concurrent first-upserts can both observe no existing row; `CompanyKnowledgeService.java:79-89` then attempts two inserts and only `ObjectOptimisticLockingFailureException` is translated at `:123-129`. A unique-key `DataIntegrityViolationException` can therefore escape as a different error. This is not a T5 blocking finding because B3/T6 owns the concurrency gate, but the B3 integration suite should exercise simultaneous first-create and define the expected conflict response.
## Evidence
Command run:
```powershell
$env:JAVA_HOME='D:\java'; $env:Path='D:\java\bin;'+$env:Path
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=CompanyKnowledgeServiceTest,CompanyKnowledgeControllerTest,OnboardingRepositoryIntegrationTest' test
```
Result:
```text
Tests run: 27, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
`git diff --check 7370e51..a5730a7` passed. The test output contains existing Spring/Mockito/H2 configuration warnings (Mockito dynamic agent, explicit H2 dialect, open-in-view, generated security password); they did not affect the result and are not introduced by this task.
## Receipt Command
```bash
ssf execution review changes/n04-onboard --wave W2-T5 --base 7370e51 --head a5730a7 --report "D:/1/zhi/zhilai/changes/n04-onboard/.superpowers/sdd/reviews/W2-T5.md" --verdict pass
```

136
changes/n04-onboard/.superpowers/sdd/reviews/W2-wave.md

@ -0,0 +1,136 @@
# W2 Wave Review: Onboarding Checklist, Company CRUD, and Company Knowledge
## Verdict
- **Wave:** W2
- **Base:** `3e7c6e7`
- **Head:** `a5730a7`
- **Verdict:** **PASS**
- **Blocking findings:** None
- **Deferred T6 obligations:** Mandatory, as listed below
## Scope Reviewed
Read and cross-checked:
- `changes/n04-onboard/execution-contract.md`
- `changes/n04-onboard/specs/onboarding-checklist/spec.md`
- `changes/n04-onboard/specs/company-knowledge/spec.md`
- `changes/n04-onboard/design.md`
- `.superpowers/sdd/task-briefs/T3.md`
- `.superpowers/sdd/task-briefs/T4.md`
- `.superpowers/sdd/task-briefs/T5.md`
- `.superpowers/sdd/reviews/W2-T3.md`
- `.superpowers/sdd/reviews/W2-T4.md`
- `.superpowers/sdd/reviews/W2-T5.md`
Inspected the combined diff `3e7c6e7..a5730a7`, including all production additions for student checklist access, company checklist CRUD, company knowledge configuration/overview, shared error changes, and the associated service/controller tests.
## Evidence
Command:
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
$env:JAVA_HOME='D:\java'
$env:Path='D:\java\bin;'+$env:Path
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=Onboarding*Test,CompanyKnowledge*Test' test
```
Result:
```text
Tests run: 66, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
Per-class results:
- `CompanyKnowledgeControllerTest`: 8 passed
- `CompanyKnowledgeServiceTest`: 14 passed
- `OnboardingChecklistControllerTest`: 5 passed
- `OnboardingChecklistServiceTest`: 10 passed
- `OnboardingCompanyChecklistControllerTest`: 10 passed
- `OnboardingCompanyChecklistServiceTest`: 12 passed
- `OnboardingMigrationTest`: 1 passed
- `OnboardingRepositoryIntegrationTest`: 5 passed
- `OnboardingSeedDataIntegrationTest`: 1 passed
`git diff --check 3e7c6e7..a5730a7` passed with no whitespace errors.
## Cross-Task Integration Findings
### Student checklist and company CRUD
- Company-created items use the same `onboarding_checklist_items` identity space and predicates consumed by the effective checklist query.
- `OnboardingChecklistItemRepository.findEffectiveChecklistItems` returns active STANDARD items plus active COMPANY items scoped to the requested company, with stable `sortOrder ASC, id ASC` ordering.
- Company soft deletion sets `active=false`, which removes the item from the student effective checklist while preserving the row and any student progress history.
- Student progress is keyed by the shared `itemId`, so a company item created through T4 is addressable through T3 without a translation layer.
- Cross-company ownership checks use the same `scopeType`, `scopeId`, and `companyId` predicate for company listing/update/delete paths.
### Company knowledge and checklist interactions
- Checklist and knowledge controllers share only the `/onboarding` namespace and API response conventions; their paths, services, tables, and ownership checks do not collide.
- Company knowledge reads are scoped by `company_id`; student overviews fall back to `BASIC_ONLY` without creating knowledge rows.
- Review aggregation is isolated to published, approved reviews through company jobs and does not mutate checklist or review data.
### Error-code consistency
The new codes are defined once and consistently mapped:
- `9701` `ONBOARDING_CHECKLIST_ITEM_NOT_FOUND` -> HTTP 404
- `9702` `ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE` -> HTTP 409
- `9703` `ONBOARDING_ITEM_LIMIT_EXCEEDED` -> HTTP 409
- `9704` `ONBOARDING_IDEMPOTENCY_CONFLICT` -> HTTP 409
- `9705` `ONBOARDING_PROGRESS_CONCURRENT_UPDATE` -> HTTP 409
- `9706` `ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE` -> HTTP 409
The shared exception-handler additions preserve the existing mappings for prior error codes.
### Sensitive-field check
The W2 DTOs are whitelist-shaped. Internal fields such as `ownerUserId`, `studentUserId`, `requestHash`, raw idempotency keys, review hashes, moderation reasons, tokens, IP addresses, and User-Agent values are not exposed in the onboarding response DTOs. Internal repository/entity field usage is restricted to persistence and ownership logic.
### Existing API semantics
The combined diff changes only shared error-code handling outside the new onboarding package. No existing company, job, offer, review, or dimension-score controller path, request contract, response contract, or authorization behavior was changed. The exception-handler edits are additive mappings for the new 97xx codes.
## Non-Blocking Findings
1. `GlobalExceptionHandler.java` was modified even though the W2 task briefs list only the onboarding files and `ErrorCode.java` explicitly. The changes are additive, necessary to realize the approved 404/409 contract, and do not alter existing error mappings. Reconcile ownership and scope wording during T6/T8 so the file is not treated as an unaccounted change.
2. The W2 suite uses service tests and MockMvc with mocked services. It does not yet prove a database-backed end-to-end flow from COMPANY create, through STUDENT checklist read, to STUDENT completion update. T6 integration tests should close this cross-layer gap.
3. Existing review coverage still lacks the exact two-review boundary, a full `HIDDEN + APPROVED` exclusion case, a 10-tag cap/quoted-JSON case, and direct MockMvc assertions for some conflict mappings. These are coverage gaps, not current W2 implementation defects.
4. Real MySQL 8.4 execution remains unverified; the W2 suite uses H2 `MODE=MySQL`. B3/T6 verification must run the approved MySQL path before the final gate.
5. `SecurityConfig` currently relies on `anyRequest().authenticated()` for `/onboarding/**`; the explicit path matcher and complete anonymous/STUDENT/COMPANY/ADMIN matrix remain T6 work.
## Mandatory T6 Obligations Carried Forward
The following must be implemented and proven by T6 before W3 can be considered complete:
### Concurrent company checklist create and limit
1. Fire two concurrent creates with the same company, same normalized `idempotencyKey`, and same body. Assert exactly one row, both callers receive the same successful item or a safe replay, and neither receives an internal 500.
2. Fire two concurrent creates with the same `idempotencyKey` but different bodies. Assert exactly one row, one successful create, and one 409 `9704`, not a unique-constraint 500.
3. Start with 49 active company items and concurrently create two distinct items. Assert the final active count is exactly 50, one request succeeds, one returns 409 `9703`, and no partial or 51st row remains.
4. Start with 50 active company items and assert new creates return 409 `9703`. Prove inactive items are excluded from the active limit and that replaying an existing idempotency key remains successful without counting as a new item.
5. Repeat the idempotency-key race across two companies and assert `(company_id, idempotency_key)` isolation with no cross-company replay.
### First progress version and first-create race
6. When no `student_onboarding_progress` row exists, a request carrying a non-zero `version` must return 409 `9705`, including the first completion path.
7. Exercise simultaneous first progress creation for the same student/item. Assert one row, one safe result, and a deterministic conflict/replay mapping rather than an uncaught unique-constraint 500.
### First company-knowledge upsert race
8. Exercise simultaneous first knowledge upserts for the same company. Assert one row, one successful create, and a deterministic `9706` or other contract-approved conflict response; do not allow an uncaught unique-constraint 500.
9. Confirm the same behavior through the HTTP integration tests with the final error/status mapping.
## Readiness for W3/T6
The W2 implementation and review evidence are sufficient to proceed. T6 has the required service surfaces, shared error constants, ownership predicates, and test scaffolding. If a database-level lock becomes necessary to make the 50-item boundary or first-create race deterministic, update the T6 scope/contract explicitly before editing repository or migration files; do not silently widen the file scope.
## Final Assessment
**Verdict: PASS**
W2 is spec-compliant at the service and controller contract level, passes the required evidence suite, preserves existing API semantics, maintains response-field minimization, and provides the necessary interfaces for T6. The unresolved concurrency and first-create-version behavior is correctly deferred to W3/T6 and must not be closed without the obligations above.

115
changes/n04-onboard/.superpowers/sdd/reviews/W3-T6-rereview.md

@ -0,0 +1,115 @@
# W3/T6 Repair Round 1 Re-Review
## Verdict
- **Base:** `ca85ab5`
- **Head:** `97b3b16`
- **Scope:** 4 files, +46/-23
- **Spec compliance:** PASS
- **Task quality:** Approved for the focused repair
- **Original W3-T6 Important finding:** **RESOLVED**
- **Blocking findings:** None
## Reviewed Inputs
- Prior failed report: `W3-T6.md`
- Repair brief: `.superpowers/sdd/task-briefs/T6-repair-1.md`
- Scoped diff: `ca85ab5..97b3b16`
- T6 brief, onboarding checklist spec, design, and prior wave reviews where relevant
## Original Finding Resolution
The original defect was that the active-item count was derived from a normal snapshot read after the transaction had already established its snapshot. Under MySQL-compatible `REPEATABLE READ`, two concurrent creates could both observe 49 active items and write the 51st row.
The repair addresses the actual transaction boundary:
1. `OnboardingChecklistService.createCompanyChecklistItem` now runs at `Isolation.READ_COMMITTED`.
2. It acquires a `PESSIMISTIC_WRITE` company lock before inspecting company items.
3. It reads company checklist rows through `findCompanyItemsForUpdate`, which is also `PESSIMISTIC_WRITE`.
4. Idempotency replay/conflict checks and the active-item count are performed from that locking read, not from an earlier ordinary snapshot.
5. The first writer locks the company row, inserts while holding the lock, and commits. The second writer waits; after acquiring the lock, its locking read observes the committed 50th item and returns `9703`.
The company-row lock plus locking item read are in the same transaction and use the same lock order as update/delete paths. This makes the 49-to-50 boundary deterministic for the supported write paths. The `PESSIMISTIC_WRITE` query includes inactive rows; active filtering remains in application logic, preserving the soft-delete accounting behavior.
## Regression Review
- Idempotent create replay still returns the existing row and performs no second insert.
- Different-body reuse of the same idempotency key still returns `9704`.
- Same idempotency key remains isolated by `company_id`.
- Soft-deleted items remain excluded from the active count.
- Existing rows with an idempotency key remain replayable after soft deletion.
- Company ownership and cross-company predicates are unchanged.
- No controller, DTO, error-code, migration, or user-visible API shape changed in this repair.
- Update/delete lock ordering is consistent with create: company row first, then company-scoped item access.
## Verification
### Required focused command
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
$env:JAVA_HOME='D:\java'; $env:Path='D:\java\bin;'+$env:Path
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=OnboardingConcurrencyIntegrationTest,OnboardingCompanyChecklistServiceTest' test
```
Result:
```text
Tests run: 22, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
### Stronger production-isolation reproduction
The boundary test was also run with `REPEATABLE_READ` forced at the Hikari/connection-default level, separately from the nested test property:
```powershell
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' `
'-Dtest=OnboardingConcurrencyIntegrationTest$RepeatableReadLimitBoundary' `
'-Dspring.datasource.hikari.transaction-isolation=TRANSACTION_REPEATABLE_READ' test
```
Result:
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
The SQL trace confirms both locking operations were issued:
```sql
select id from companies where id=? for update
select ... from onboarding_checklist_items
where scope_type='COMPANY' and scope_id=? and company_id=?
order by id for update
```
### Full onboarding regression
```powershell
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=Onboarding*Test,CompanyKnowledge*Test' test
```
Result:
```text
Tests run: 85, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
`git diff --check ca85ab5..97b3b16` passed. No implementation files were modified during this re-review.
## Remaining Findings
### Minor
1. The earlier W3-T6 minor finding about a few broader concurrency tests over-constraining all callers to succeed under global `REPEATABLE_READ` remains outside this focused repair. The contract permits deterministic `9704`/`9705`/`9706` conflicts. The repaired 49→50 boundary itself now asserts exactly one success and one `9703`.
2. Real MySQL 8.4 execution was not available in this environment. H2 `MODE=MySQL` with forced `REPEATABLE_READ` passes, but the B3 integration gate should still confirm the same boundary on the real database.
3. The prior knowledge-service lock-timeout/deadlock mapping observation remains unchanged and is outside T6 repair round 1.
None of these block acceptance of the focused repair.
## Assessment
The original concurrency finding is resolved. The implementation now uses a locking/current read inside the create transaction and the strengthened repeatable-read boundary test passes. No idempotency, soft-delete, cross-company ownership, or API behavior regression was found in the reviewed scope.

104
changes/n04-onboard/.superpowers/sdd/reviews/W3-T6.md

@ -0,0 +1,104 @@
# W3/T6 Review: Permissions, Error Codes, Concurrency, and Privacy
## Verdict
- **Base:** `a5730a7`
- **Head:** `ca85ab5`
- **Diff:** 6 files, +936/-3
- **Spec compliance:** ❌ Issues found
- **Task quality:** Needs fixes
- **Blocking findings:** 1 Important
## Scope Reviewed
Read the T6 brief, execution contract, checklist and company-knowledge specs, design decisions, reviewer guidance, production diff, and all three added integration-test classes.
Verified the explicit `/onboarding/**` authenticated matcher, STUDENT/COMPANY service role checks, company ownership predicates, checklist HTTP response whitelists, privacy assertions, optimistic-version mappings, and the placement of pessimistic locks relative to transaction reads.
## Evidence
Default focused suite:
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
$env:JAVA_HOME='D:\java'
$env:Path='D:\java\bin;'+$env:Path
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=OnboardingAuthorizationIntegrationTest,OnboardingConcurrencyIntegrationTest,OnboardingPrivacyTest' test
```
Result:
```text
Tests run: 19, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
Production-isolation regression:
```powershell
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' -q '-Dtest=OnboardingConcurrencyIntegrationTest' '-Dspring.datasource.hikari.transaction-isolation=TRANSACTION_REPEATABLE_READ' test
```
Result:
```text
Tests run: 10, Failures: 5, Errors: 0, Skipped: 0
```
The focused 49→50 test failed because both concurrent creates succeeded:
```text
concurrentCreatesAtLimitBoundaryNeverWriteFiftyFirstItem
Expected size: 1 but was: 2
```
`git diff --check a5730a7..ca85ab5` passed. No implementation files were modified during review.
## Strengths
- `SecurityConfig.java:130` explicitly marks `/onboarding/**` as authenticated; anonymous requests return 401 through the configured entry point.
- Student and company role checks are enforced in service methods, not only by token claims. Company ownership is checked through `owner_user_id` before item mutation.
- Cross-company update/delete paths return `9701` without returning the target item or modifying it.
- `OnboardingChecklistService.java:554` maps both optimistic-lock and duplicate-insert failures for progress to `9705`; `CompanyKnowledgeService.java:138` similarly maps knowledge conflicts to `9706`.
- Soft-delete preserves history and student effective-checklist queries filter inactive items.
- Privacy tests use response JSON for several write/read/error paths and assert removal of `ownerUserId`, `studentUserId`, hashes, tokens, and moderation internals.
## Issues
### Critical
None.
### Important
1. **The 50-active-item guard is not transaction-safe under the production database's default isolation.** `OnboardingChecklistService.java:153` calls `requireCompanyOwner()`, whose normal read at `OnboardingChecklistService.java:256` establishes the transaction snapshot before `lockCompany()` runs at `OnboardingChecklistService.java:155`. The subsequent `findAll()` at `OnboardingChecklistService.java:168` and active count at `OnboardingChecklistService.java:182` are ordinary consistent reads, so a waiting writer can still observe the pre-commit snapshot. With 49 active rows, two concurrent creates can both observe 49, both pass the check, and both insert, producing 51 active items. MySQL defaults to `REPEATABLE READ` and `application.yml` does not override it. The H2 test at `OnboardingConcurrencyIntegrationTest.java:40-41` also does not set the production isolation level, which is why the default run masks the defect.
Reproduction command:
```powershell
mvn -q '-Dtest=OnboardingConcurrencyIntegrationTest' '-Dspring.datasource.hikari.transaction-isolation=TRANSACTION_REPEATABLE_READ' test
```
The test fails at `OnboardingConcurrencyIntegrationTest.java:111` with two successful `CompanyChecklistItemView` results instead of one success and one `9703` conflict; the seeded 49 rows plus both inserts means the database has 51 active items.
**Required fix:** make the post-lock idempotency and active-count decisions from locking/current reads, or explicitly run these write transactions at `READ_COMMITTED`. A company-row `PESSIMISTIC_WRITE` lock alone is insufficient when the transaction snapshot was established by a pre-lock ordinary read. Add a regression test that runs the 49→50 boundary under the same isolation model as MySQL or against MySQL 8.4.
### Minor
1. Several concurrency tests over-constrain outcomes beyond the T6 contract. `OnboardingConcurrencyIntegrationTest.java:75` requires both same-key callers to succeed, while the task brief permits `9704`; `OnboardingConcurrencyIntegrationTest.java:184` and `OnboardingConcurrencyIntegrationTest.java:202` similarly require all progress/knowledge callers to succeed even though the contract permits deterministic `9705`/`9706` conflict responses. Under `TRANSACTION_REPEATABLE_READ`, those tests fail for contract-permitted outcomes. Assert exactly one successful mutation and one allowed conflict/replay, and reject unexpected exception types.
2. `CompanyKnowledgeService.java:135-140` only translates optimistic-lock and integrity exceptions. Under the repeatable-read regression run, the same-version knowledge test surfaced an unmapped H2 transaction-rollback/deadlock exception and failed the `9706` assertion at `OnboardingConcurrencyIntegrationTest.java:238-241`. Verify and, if reproducible on MySQL, map lock acquisition/lock-timeout failures at the service boundary to the appropriate `9702`/`9705`/`9706` contract error.
## Cannot Verify From Diff
- Real MySQL 8.4 execution was not available in this review. The repeatable-read H2 reproduction is sufficient to reject the current locking design, but the final B3 gate must also run the real MySQL path and assert 50 active items after concurrent boundary creation.
## Assessment
The permission, ownership, privacy, and default-isolation tests are well structured, and the focused suite is green. The implementation is not ready to accept because its central concurrency guarantee can be violated under the production MySQL default isolation: concurrent creates can write the 51st active checklist item.
## Receipt
```bash
ssf execution review changes/n04-onboard --wave W3 --base a5730a7 --head ca85ab5 --report changes/n04-onboard/.superpowers/sdd/reviews/W3-T6.md --verdict fail
```

81
changes/n04-onboard/.superpowers/sdd/reviews/W3-T7.md

@ -0,0 +1,81 @@
# W3 T7 Review: Onboarding End-to-End and Gap Tests
## Verdict
- **Spec compliance:** PASS
- **Task quality:** Approved
- **Overall:** PASS
- **Base:** `97b3b16`
- **Head:** `73afd87`
- **Blocking findings:** none
- **Cannot verify from this task alone:** real MySQL 8.4 execution remains a later verification-gate obligation.
## Verification
- Read `task-reviewer-prompt.md`, `writing-good-tests.md`, the T7 brief, execution contract, both onboarding specs, the full `97b3b16..73afd87` diff, and the complete changed test files where the diff output was truncated.
- Scope check: `git diff --name-only 97b3b16..73afd87` contains no `mydreamjob-server/src/main/**` files. The T7 implementation scope is limited to three test files plus workflow/review artifacts.
- Ran `OnboardingEndToEndIntegrationTest,CompanyKnowledgeServiceTest,OnboardingRepositoryIntegrationTest`: `Tests run: 30, Failures: 0, Errors: 0, Skipped: 0`, `BUILD SUCCESS`.
- Ran `Onboarding*Test,CompanyKnowledge*Test`: `Tests run: 96, Failures: 0, Errors: 0, Skipped: 0`, `BUILD SUCCESS`.
- Ran `OnboardingEndToEndIntegrationTest` with JUnit random method ordering: `Tests run: 7, Failures: 0, Errors: 0, Skipped: 0`, `BUILD SUCCESS`.
- Scoped `git diff --check 97b3b16..73afd87 -- <three T7 test files>` passed. Range-wide `git diff --check` reports two pre-existing blank lines at EOF in `W2-T4.md` and `W2-T5.md`, outside T7.
## Spec Compliance
- All nine distinct onboarding paths are exercised through the full Spring context, MockMvc, JWT filter, H2 `MODE=MySQL`, repositories, and DTO serialization: `OnboardingEndToEndIntegrationTest.java:82-212`.
- Student fallback and merged-company behavior are covered: standard-only after soft deletion, `customized=false`, merged standard/company items, source values, completion count, and rate (`:135-167`, `:202-208`).
- Student absolute-state completion behavior is covered end to end: first completion, idempotent replay with a stale version, cancellation clearing `completedAt`, stale-version conflict, persisted row count, and unchanged state after conflict (`:214-264`).
- Company checklist behavior is covered end to end: list, create, idempotent replay, request-hash conflict, 50-active-item boundary, update, soft delete, repeated delete, cross-company update/delete isolation, and state preservation (`:266-364`).
- Company knowledge behavior is covered across `BASIC_ONLY`, `CONFIGURED`, `EMPTY`, exact two-review `INSUFFICIENT`, and `SUFFICIENT`, including absence of aggregates below three reviews (`:366-441`).
- Public-review filtering excludes `HIDDEN` and non-public moderation states from counts, tags, ratings, and dimension aggregates. Repository coverage is explicit at `OnboardingRepositoryIntegrationTest.java:267-301`; the E2E flow also adds hidden/pending records before asserting the visible totals and aggregates (`OnboardingEndToEndIntegrationTest.java:410-440`).
- Anonymous 401, wrong-role 403, and missing-company/item 404 paths are covered across the endpoint families, with database-state assertions after failures (`OnboardingEndToEndIntegrationTest.java:443-543`).
- Response privacy checks run for every success and error response and cover owner/student IDs, moderation reason, request/content/identity hashes, IP/User-Agent, password/token fields, phone, email, and idempotency keys (`OnboardingEndToEndIntegrationTest.java:545-577`).
- The focused service tests cover the exact two-review threshold, all eight dimension summaries, deterministic tag top-ten limiting, and recursively quoted JSON tags (`CompanyKnowledgeServiceTest.java:292-358`).
## Test Falsifiability
- The E2E tests assert externally observable HTTP status, response JSON, persisted row state, and cross-company ownership effects. They would fail for incorrect routing, authorization, fallback, merging, version handling, soft-delete behavior, or aggregate filtering.
- The threshold tests independently assert the absence of aggregates at zero, one, and two public reviews, and their presence at three. A change to the threshold or query filtering would fail them.
- The repository test independently verifies `PUBLISHED` plus approved moderation filtering and excludes `HIDDEN` records from all aggregate queries. A filter regression would fail the counts, tags, and dimension sample assertions.
- The privacy helper checks serialized response bodies rather than trusting service DTO construction alone.
- The randomized-order E2E run passed without reliance on wall-clock precision, external services, or test-order state.
## Strengths
- The new E2E test uses the real application context and persistence layer, not mocked controllers or repositories.
- Error-path state preservation is asserted at the database level, not only through HTTP status codes.
- The coverage combines a broad endpoint contract flow with focused service/repository tests for threshold, tag, and moderation-filter edge cases.
- No production files were changed; the task stayed within the intended test-hardening scope.
## Issues
### Critical (Must Fix)
None.
### Important (Should Fix)
None.
### Minor (Nice to Have)
- `OnboardingEndToEndIntegrationTest.java:82-212` combines success checks for all nine endpoints into one long flow. It is correct, but a failure gives a broad diagnosis surface. Splitting the endpoint families into smaller E2E tests would improve failure isolation without changing behavior.
- `OnboardingEndToEndIntegrationTest.java:559-576` checks sensitive fields with raw serialized-string substrings. It covers the named fields in the current contract, but a recursive JSON field-name check would be more robust if DTO serialization changes or sensitive data is nested under a renamed field.
- Tag top-ten limiting and recursively quoted JSON parsing are covered at the service layer (`CompanyKnowledgeServiceTest.java:314-358`), while the E2E flow only exercises the three-tag sufficient case (`OnboardingEndToEndIntegrationTest.java:425-434`). This is acceptable for T7 because the focused behavior is covered, but retaining an HTTP-level edge case would provide broader integration evidence.
- Non-T7 range artifact: `git diff --check 97b3b16..73afd87` reports blank lines at EOF in `W2-T4.md:94` and `W2-T5.md:75`. The three T7 test files themselves pass `git diff --check`.
## Residual Verification Warning
- Real MySQL 8.4 behavior has not been run in this environment. H2 `MODE=MySQL` passed; MySQL/Docker black-box verification remains owned by the later verification gate.
- The prior T6 observation about knowledge-service lock-timeout/deadlock exception mapping remains outside T7 and was not modified here.
## Assessment
**Task quality:** Approved
**Reasoning:** The T7 additions provide real full-context coverage for all nine endpoints, the required fallback, idempotency, concurrency-conflict, threshold, filtering, authorization, privacy, and state-preservation behaviors. The focused and full required suites, plus randomized ordering, all pass, and no production files were changed.
Controller receipt command when `ssf` is available:
```bash
ssf execution review changes/n04-onboard --wave W3 --base 97b3b16 --head 73afd87 --report changes/n04-onboard/.superpowers/sdd/reviews/W3-T7.md --verdict pass
```

83
changes/n04-onboard/.superpowers/sdd/reviews/W3-T8-rereview.md

@ -0,0 +1,83 @@
# W3-T8 Re-review: OpenAPI 示例与默认值修复
- Wave: W3
- Task: T8
- Review round: repair round 1
- Base SHA: `aad0ba6`
- Head SHA: `310d931`
- Prior failed report: `W3-T8.md`
- Repair brief: `T8-repair-1.md`
- Verdict: **PASS**
## Original Findings
### Resolved: knowledge Upsert response example omitted `version`
`docs/openapi-rc.json:7439` now includes `data.version: 2` in the `PUT /onboarding/company/knowledge` 200 example. The request example uses `version: 1`, so the returned `version: 2` is consistent with update semantics. `CompanyKnowledgeView` still exposes `version`, and the schema remains unchanged.
### Resolved: create-item schema omitted runtime defaults
`docs/openapi-rc.json:11718` now declares `sortOrder.default = 1000`.
`docs/openapi-rc.json:11724` now declares `required.default = false`.
These match the initialized runtime fields in `CompanyChecklistItemCreateRequest`.
### Resolved: verifier did not assert the repaired contract
`docs/verify_openapi.py:300-309` now asserts:
- `CompanyChecklistItemCreateRequest.sortOrder.default == 1000`
- `CompanyChecklistItemCreateRequest.required.default is False`
- knowledge Upsert 200 example `data.version == 2`
The verifier diff adds 12 lines and removes none. Existing path, operation, schema, security, response-reference, field, and error-code checks remain intact, so the prior checks were not weakened.
## Scope Verification
The scoped semantic OpenAPI diff contains exactly the three intended changes:
- `CompanyChecklistItemCreateRequest.required.default`: added `false`
- `CompanyChecklistItemCreateRequest.sortOrder.default`: added `1000`
- knowledge Upsert 200 example `data.version`: added `2`
No runtime Java, test, migration, endpoint, schema, security, or unrelated documentation changes are present in `aad0ba6..310d931`.
## Evidence
```text
python .\docs\verify_openapi.py
paths: 135
N04 onboarding endpoints and schemas present (9 operations / 6 paths)
No empty ref keys found
Valid JSON: True
All checks passed!
```
```text
mvn -Dtest=Onboarding*Test,CompanyKnowledge*Test test
Tests run: 96, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
`git diff --check aad0ba6..310d931` passed.
## Remaining Findings
No blocking findings.
Non-blocking carried-forward findings from the prior review remain outside this focused repair:
- Error-code verification remains partly text-based rather than asserting exact operation/status/example structure.
- Success responses continue to use the repository's existing `*/*` media type instead of explicit `application/json`.
- Full `mvn clean verify` and real MySQL 8.4 / Docker acceptance remain pending; the previously identified full-suite failures are unrelated to N04/T8.
## Assessment
**Ready for T8 receipt:** Yes
The two original Important findings are resolved, and both are now protected by exact verifier assertions without weakening earlier checks.
Receipt command:
```bash
ssf execution review changes/n04-onboard --wave W3-T8 --base aad0ba6 --head 310d931 --report .superpowers/sdd/reviews/W3-T8-rereview.md --verdict pass
```

78
changes/n04-onboard/.superpowers/sdd/reviews/W3-T8.md

@ -0,0 +1,78 @@
# W3-T8 Review: OpenAPI、接口文档与前端联调
- Wave: W3
- Task: T8
- Base SHA: `73afd87`
- Head SHA: `aad0ba6`
- Review report: `.superpowers/sdd/reviews/W3-T8.md`
- Verdict: **FAIL**
## Strengths
- T8 的交付范围正确:diff 仅涉及 OpenAPI、文档、验证脚本和 SDD 状态文件,没有修改 Java 生产代码或测试代码。
- 与 `73afd87` 相比,OpenAPI 精确新增 6 个 `/onboarding` 路径、9 个 operation、20 个 schema;原有 129 个路径和 139 个 schema 的结构化内容均未变化。
- 9 个 operation 的 `operationId` 唯一,全部声明 `bearerAuth`;成功响应引用正确;9701-9706 均出现在对应错误响应中。
- `docs/onboarding-api.md` 覆盖角色、企业所有权、通用/企业清单合并、完成状态与版本、幂等创建、软删除、知识回退、3 条评价阈值、错误码和请求/响应示例。
- `docs/frontend-integration.md` 第 22 节覆盖 Base URL、鉴权、9 个接口、请求示例、状态回退及 400/401/403/404/409 处理。
- README 和 roadmap 已更新为 N04“实现完成,待真实 MySQL/Docker 验收”,并准确记录当前全量测试失败情况。
- `docs/verify_openapi.py` 是增强而非削弱:删除了旧的固定路径总数断言,改为更强的 135 paths / 172 operations 断言,并新增 N04 路径、operation、security、schema 和错误码检查。
- N04 专项回归通过:`Tests run: 96, Failures: 0, Errors: 0, Skipped: 0`,`BUILD SUCCESS`。
## Blocking Findings
### Important: OpenAPI 示例与运行时响应不一致
- File: `docs/openapi-rc.json:7430-7439`
- `PUT /onboarding/company/knowledge` 的 200 示例 `data` 缺少 `version`。
- Runtime `CompanyKnowledgeView`、OpenAPI schema 以及 `docs/onboarding-api.md` 都明确包含 `version`;企业客户端必须使用该字段进行下一次乐观锁更新。
- 因此该示例会误导前端在对更新响应建模或联调时遗漏版本,直接违反 T8“字段、默认值与运行时 DTO 完全一致”的要求。
- Fix: 在 200 示例中补充一个与更新语义一致的 `version`,并在 `docs/verify_openapi.py` 中增加示例字段断言。
### Important: 创建请求的 OpenAPI schema 未表达运行时默认值
- File: `docs/openapi-rc.json:11714-11722`
- `CompanyChecklistItemCreateRequest.sortOrder` 缺少 `default: 1000`。
- `CompanyChecklistItemCreateRequest.required` 缺少 `default: false`。
- Runtime DTO 明确初始化了这两个默认值,文档正文也说明默认值,但静态 OpenAPI schema 没有表达,违反 T8 对默认值一致性的要求。
- Fix: 在对应 schema 属性中加入 `default`,并在 `docs/verify_openapi.py` 中断言这些默认值。
## Minor Findings
- `docs/verify_openapi.py` 对 9704 的检查只验证响应描述或示例的序列化文本中存在该错误码;当前 9704 只出现在描述中。建议按 operation/status 精确断言错误码和示例结构,同时覆盖 `version` 默认值和响应示例,避免回归。
- OpenAPI 200 响应当前沿用仓库既有的 `*/*` content type,而不是更明确的 `application/json`。这不影响本次 T8 通过判定,但会给前端代码生成带来额外歧义。
- `changes/n04-onboard/.superpowers/sdd/progress.md` 尚未记录 T8 独立 review 结果,符合当前“review pending”状态,等待 receipt 后再更新。
## Non-Blocking Verification
- `python .\docs\verify_openapi.py`:
- `paths: 135`
- `N04 onboarding endpoints and schemas present (9 operations / 6 paths)`
- `All checks passed!`
- 结构化对比 `73afd87..aad0ba6`:
- added paths: 6
- added operations: 9
- added schemas: 20
- existing paths changed: 0
- existing schemas changed: 0
- N04 regression:
- `mvn -Dtest=Onboarding*Test,CompanyKnowledge*Test test`
- `Tests run: 96, Failures: 0, Errors: 0, Skipped: 0`
- `BUILD SUCCESS`
- Reported full-suite failure set reproduced independently:
- `mvn -Dtest=CareerProgressServiceTest,JobReviewDimensionControllerTest,JobReviewDimensionServiceTest,LocalResumeStorageServiceTest test`
- `Tests run: 69, Failures: 2, Errors: 9`
- failures are the reported CareerProgress/JobReviewDimension assertions and LocalResumeStorage temp-directory cleanup errors.
- `git diff --check 73afd87..aad0ba6` passed.
- `git diff --name-status 73afd87..aad0ba6` contains no Java production or test changes, so the reported failures are unrelated to the T8 commit. The N04 branch's only changes touching those packages are additive `JobReview` repository query methods that are not used by the failing paths; CareerProgress and LocalResumeStorage code is unchanged.
## Assessment
**Ready to merge?** No
**Reasoning:** The OpenAPI structure, documentation coverage, security declarations, error-code coverage, and N04 regression are strong, but the static contract is not yet fully runtime-consistent. The missing knowledge-response `version` example and missing create-request defaults are actionable contract defects that must be fixed and re-reviewed before T8 can pass.
Receipt command:
```bash
ssf execution review changes/n04-onboard --wave W3-T8 --base 73afd87 --head aad0ba6 --report .superpowers/sdd/reviews/W3-T8.md --verdict fail
```

113
changes/n04-onboard/.superpowers/sdd/reviews/W3-wave.md

@ -0,0 +1,113 @@
# W3 Wave Review: n04-onboard
- Wave: `W3`
- Base SHA: `a5730a7`
- Head SHA: `310d931`
- Verdict: **PASS (conditional)**
- Blocking findings inside W3: **None**
- Release status: **blocked by external verification gates**
## Scope Reviewed
Read the execution contract, onboarding-checklist and company-knowledge specs, design, W3 task reports (`W3-T6.md`, `W3-T6-rereview.md`, `W3-T7.md`, `W3-T8.md`, `W3-T8-rereview.md`), task briefs where needed, the combined diff `a5730a7..310d931`, and the relevant production, integration-test, OpenAPI, and documentation artifacts.
The combined diff contains 31 files, `+7123/-1903`. W3 production changes are limited to:
- `SecurityConfig.java`
- `OnboardingChecklistItemRepository.java`
- `OnboardingChecklistService.java`
- `CompanyKnowledgeService.java`
The remainder is onboarding integration/privacy/end-to-end testing, OpenAPI/docs, and SDD workflow artifacts. No V1-V38 migration is modified in W3. No existing API path, request/response shape, or error semantics are intentionally changed.
## Cross-Task Integration
### Permissions and ownership
- `/onboarding/**` is explicitly authenticated; anonymous requests return 401 through the existing security entry point.
- STUDENT and COMPANY role checks are enforced in service code, not merely by the HTTP matcher.
- Company operations resolve the company from `owner_user_id` and reject cross-company checklist mutation with `9701`/404 without modifying the target row.
- Privacy assertions cover success and error responses and verify absence of internal IDs, hashes, moderation fields, tokens, IP/User-Agent, phone, and email fields.
### Concurrency and isolation
- Company checklist creation runs at `READ_COMMITTED`, acquires a `PESSIMISTIC_WRITE` company-row lock, and uses a locking read for company item lookup before idempotency and active-count decisions.
- The 49-to-50 boundary is deterministic under H2 `MODE=MySQL` with the connection default forced to `REPEATABLE_READ`; the test observes exactly one success and one `9703`, with 50 active rows.
- Progress updates serialize per student user through a pessimistic user lock; concurrent first creation produces one progress row.
- Knowledge upserts serialize on the owning company row and map optimistic/duplicate-key conflicts to `9706`.
- The focused concurrency suite passes. Real MySQL 8.4 lock/deadlock behavior remains an external acceptance gate.
### End-to-end contract
- T7 exercises all 9 onboarding operations through the real Spring context, JWT/security chain, MockMvc, H2 persistence, and DTO serialization.
- Coverage includes standard/company checklist merge and fallback, completion and cancellation, stale versions, idempotent replay and hash conflict, 50-item limit, soft delete/repeat delete, cross-company isolation, `BASIC_ONLY`/`CONFIGURED`, `EMPTY`/`INSUFFICIENT`/`SUFFICIENT`, public-review filtering, tag limits, permission failures, and privacy assertions.
- T8 OpenAPI contains exactly 6 onboarding paths and 9 operations, all with bearer security and the expected response/error references. The repaired examples/defaults match runtime behavior.
## Verification Evidence
### Required focused regression
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
$env:JAVA_HOME='D:\java'; $env:Path='D:\java\bin;'+$env:Path
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=Onboarding*Test,CompanyKnowledge*Test' test
```
Result: `Tests run: 96, Failures: 0, Errors: 0, Skipped: 0`, `BUILD SUCCESS`.
### Production-isolation concurrency check
```powershell
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=OnboardingConcurrencyIntegrationTest$RepeatableReadLimitBoundary' '-Dspring.datasource.hikari.transaction-isolation=TRANSACTION_REPEATABLE_READ' test
```
Result: `Tests run: 1, Failures: 0, Errors: 0, Skipped: 0`, `BUILD SUCCESS`.
### Randomized end-to-end ordering
```powershell
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=OnboardingEndToEndIntegrationTest' '-Djunit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random' test
```
Result: `Tests run: 7, Failures: 0, Errors: 0, Skipped: 0`, `BUILD SUCCESS`.
### OpenAPI verification
```powershell
python docs\verify_openapi.py
```
Result: `paths: 135`; `N04 onboarding endpoints and schemas present (9 operations / 6 paths)`; `No empty ref keys found`; `Valid JSON: True`; `All checks passed!`.
Structured checks found 0 removed paths, 6 added paths, 0 removed schemas, and 20 added schemas.
## External Blockers
The reported full-suite failures were independently reproduced in isolation:
```powershell
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=CareerProgressServiceTest,JobReviewDimensionControllerTest,JobReviewDimensionServiceTest,LocalResumeStorageServiceTest' test
```
Result: `Tests run: 69, Failures: 2, Errors: 9, Skipped: 0`, `BUILD FAILURE`.
- `CareerProgressServiceTest.fullAggregation_returnsCorrectSummaries` fails with expected `1` but got `0`.
- `JobReviewDimensionControllerTest.listReviews_shouldReturnDimensionScores` fails with 500 due a null `Page`.
- `JobReviewDimensionServiceTest.listReviews_shouldReturnDimensionScoresWithoutN1` fails with null `Page.getContent()`.
- `LocalResumeStorageServiceTest` reports 8 JUnit extension cleanup errors caused by sandbox `AccessDeniedException` on temporary directories.
These failures are outside the W3 review range. `git diff --name-only a5730a7..310d931` contains no files in the failing controllers/services/storage paths. Across the full N04 branch from `a2c66b3`, the only review-package changes are additive query methods in `JobReviewRepository` and `JobReviewDimensionScoreRepository`; the failing CareerProgress and LocalResumeStorage code is unchanged. Therefore W3 itself can pass conditionally, but final release cannot be declared while `mvn clean verify` is red.
Real MySQL 8.4/Docker execution also remains outstanding. The contract requires it for the final completion proof, and it was not performed without separate authorization.
## Minor Findings
- `git diff --check a5730a7..310d931` reports two workflow-report files with blank lines at EOF (`W2-T4.md`, `W2-T5.md`). This is documentation-only and non-blocking.
- Some OpenAPI error-code assertions remain partly textual rather than exact response-shape assertions. The repaired defaults/examples are covered.
- H2 `MODE=MySQL` is strong regression coverage but is not a substitute for the pending real-database acceptance gate.
## Assessment
**W3 passes conditionally.** T6 concurrency repair, T7 end-to-end coverage, and T8 contract/docs repair are integrated and verified; no blocking W3 finding remains. The final release/DP-7 gate remains blocked by the independently reproduced non-N04 full-suite failures and the pending real MySQL 8.4/Docker acceptance run.
Report path: `D:\1\zhi\zhilai\changes\n04-onboard\.superpowers\sdd\reviews\W3-wave.md`

50
changes/n04-onboard/.superpowers/sdd/task-briefs/T1.md

@ -0,0 +1,50 @@
# T1 任务简报:V38/V39 迁移与通用清单种子
## Where This Fits
- Wave: W1
- Depends on: 无
- Contract: `changes/n04-onboard/execution-contract.md`
- Specs: `changes/n04-onboard/specs/onboarding-checklist/spec.md`、`changes/n04-onboard/specs/company-knowledge/spec.md`
- Design: `changes/n04-onboard/design.md` 的 Data Model 与 Architecture 部分
## Objective
新增 V38 建表迁移和 V39 通用清单种子,并先用失败测试锁定表结构、约束、通用项数量和稳定排序。
## Files In Scope
- `mydreamjob-server/src/main/resources/db/migration/V38__onboarding_checklist_and_company_knowledge.sql`
- `mydreamjob-server/src/main/resources/db/migration/V39__onboarding_standard_checklist_seed.sql`
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java`
- 如项目迁移测试需要共享测试配置,仅可修改同一测试包内的辅助文件。
## Required Behavior
- 创建 `onboarding_checklist_items`、`student_onboarding_progress`、`company_knowledge`。
- `onboarding_checklist_items` 至少包含合同规定的字段、唯一键、活动/排序索引、`company_id` 对 `companies(id)` 的外键。
- `student_onboarding_progress` 必须对学生与条目唯一,并建立 `users(id)`、`onboarding_checklist_items(id)` 外键。
- `company_knowledge.company_id` 唯一,并建立 `companies(id)` 外键。
- V39 必须写入稳定的通用清单;每一步使用明确 `item_key`、分类、排序和必填标记,不得使用随机值。
- 迁移必须兼容 H2 `MODE=MySQL` 与 MySQL 8.4。
## TDD Requirement
1. 先写 `OnboardingMigrationTest`、`OnboardingSeedDataIntegrationTest`,确认因表或种子不存在而失败。
2. 再写 V38/V39 让测试通过。
3. 不得先写迁移再补测试。
## Evidence Commands
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
mvn -Dtest=OnboardingMigrationTest,OnboardingSeedDataIntegrationTest test
```
## Done Definition
- 两条证据测试通过。
- 测试断言三张表存在、关键唯一约束/外键/索引可验证、通用种子数量大于 0 且排序稳定。
- 不修改 V1-V37。
- 不创建 W2 实体、Repository、Service 或 Controller。

53
changes/n04-onboard/.superpowers/sdd/task-briefs/T2.md

@ -0,0 +1,53 @@
# T2 任务简报:Onboarding 实体、Repository 与评价聚合查询
## Where This Fits
- Wave: W1
- Depends on: T1
- Contract: `changes/n04-onboard/execution-contract.md`
- Design: `changes/n04-onboard/design.md` 的 Architecture、Data Model、Decisions
## Objective
在 V38/V39 表结构之上新增实体、Repository 和公司评价聚合查询,先写持久化失败测试,再实现最小映射。
## Files In Scope
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java`
- 必要时只读扩展 `mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java` 和 `JobReviewDimensionScoreRepository.java` 的公司级聚合查询。
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java`
## Required Behavior
- `OnboardingChecklistItem` 映射 `onboarding_checklist_items`,含 `scopeType`、`scopeId`、`companyId`、`itemKey`、title、description、category、sortOrder、required、active、idempotencyKey、requestHash、version、审计时间。
- `StudentOnboardingProgress` 映射 `student_onboarding_progress`,含 studentUserId、itemId、completed、completedAt、version、审计时间。
- `CompanyKnowledge` 映射 `company_knowledge`,含 companyId、culture、workPolicies、onboardingGuide、version、审计时间。
- 清单 Repository 支持按 `companyId` 查询活动通用项+活动企业项,并按 `sortOrder ASC, id ASC` 稳定排序。
- 进度 Repository 支持按学生和多个 itemId 批量读取,并按学生+条目唯一查询。
- 知识 Repository 支持按 companyId 查询。
- 评价聚合查询支持公司下全部 `PUBLISHED` 且 `AUTO_APPROVED/APPROVED` 的评价评分分布、tags 原始列和八维汇总;不得逐岗位或逐评价产生 N+1。
## TDD Requirement
1. 先写 `OnboardingRepositoryIntegrationTest`,确认新实体/Repository 不存在或查询失败。
2. 再实现最小实体和 Repository 使测试通过。
3. 不创建 Service、Controller、DTO 或业务状态机。
## Evidence Commands
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
mvn -Dtest=OnboardingRepositoryIntegrationTest test
```
## Done Definition
- 新实体与三张表映射通过 Hibernate `ddl-auto=validate`。
- 合并查询只返回活动通用项和当前企业活动项。
- 多学生、多企业、软删除项和排序测试通过。
- 公司评分/tags/八维聚合仅统计公开评价。

56
changes/n04-onboard/.superpowers/sdd/task-briefs/T3.md

@ -0,0 +1,56 @@
# T3 任务简报:学生有效清单与完成状态
## Where This Fits
- Wave: W1
- Depends on: T2
- Contract: `changes/n04-onboard/execution-contract.md`
- Specs: `changes/n04-onboard/specs/onboarding-checklist/spec.md`
## Objective
实现 STUDENT 读取有效入职清单和更新本人完成状态的 Service、DTO 与 Controller,先用失败测试锁定回退、合并排序、完成率和幂等语义。
## Files In Scope
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java`
## Required Behavior
- `GET /api/v1/onboarding/companies/{companyId}/checklist` 仅服务 STUDENT。
- 企业没有活动自定义项时返回通用清单并设置 `customized=false`;有自定义项时返回通用项+企业项并按 `sortOrder ASC, id ASC` 排序。
- 响应包含 companyId、companyName、customized、totalItems、completedItems、completionRate 和 items。
- item 包含 itemId、source、itemKey、title、description、category、required、sortOrder、active、completed、completedAt、progressVersion。
- `PUT /api/v1/onboarding/companies/{companyId}/checklist/items/{itemId}/completion` 设置绝对完成状态。
- 首次完成写入 completedAt;取消完成清空 completedAt。
- 相同状态重复提交必须幂等成功,不增加数据库记录。
- 状态不同且提供过期 version 时返回 `ONBOARDING_PROGRESS_CONCURRENT_UPDATE`。
- item 必须属于该企业的活动项或活动通用项,否则返回 `ONBOARDING_CHECKLIST_ITEM_NOT_FOUND`。
- 企业不存在返回 `COMPANY_NOT_FOUND`;非 STUDENT 返回 `FORBIDDEN`。
- 完成率保留一位小数,空清单为 0.0,最大 100.0。
## TDD Requirement
1. 先写 Service 和 Controller 失败测试,确认 RED。
2. 再实现最小 DTO、Service、Controller 使测试 GREEN。
3. 不实现企业自定义项 CRUD 或公司知识接口。
## Evidence Commands
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
mvn -Dtest=OnboardingChecklistServiceTest,OnboardingChecklistControllerTest test
```
## Done Definition
- 测试覆盖通用回退、企业合并、稳定排序、首次完成、重复相同状态、取消完成、版本冲突、跨企业 itemId 和企业不存在。
- 响应不泄露内部用户字段。
- 不修改既有已验收接口。

53
changes/n04-onboard/.superpowers/sdd/task-briefs/T4.md

@ -0,0 +1,53 @@
# T4 任务简报:企业自定义清单 CRUD 与幂等
## Where This Fits
- Wave: W2
- Depends on: T3
- Contract: `changes/n04-onboard/execution-contract.md`
- Specs: `changes/n04-onboard/specs/onboarding-checklist/spec.md`
## Objective
在已有学生清单服务与 Controller 上增加 COMPANY 自定义清单 CRUD,先用失败测试锁定所有权、幂等和乐观锁。
## Files In Scope
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java`
## Required Behavior
- `GET /api/v1/onboarding/company/checklist-items` 仅 COMPANY,读取本企业全部自定义项,含软删除项与 version。
- `POST /api/v1/onboarding/company/checklist-items` 创建本企业项,校验 title 1-120、description <=1000、category 枚举、sortOrder 0-9999、required 默认 false、idempotencyKey <=64。
- 相同企业 + 相同 idempotencyKey + 相同规范化请求体返回原条目;不同请求体返回 `ONBOARDING_IDEMPOTENCY_CONFLICT`。
- 活动自定义项达到 50 条时返回 `ONBOARDING_ITEM_LIMIT_EXCEEDED`。
- `PUT /api/v1/onboarding/company/checklist-items/{itemId}` 更新标题、说明、分类、排序、必填和 active,必须校验 version。
- 过期 version 返回 `ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE`。
- `DELETE /api/v1/onboarding/company/checklist-items/{itemId}?version={version}` 软删除;重复删除已非活动项幂等成功。
- 所有企业操作仅限当前 COMPANY 拥有的企业,跨企业 itemId 返回 `ONBOARDING_CHECKLIST_ITEM_NOT_FOUND`。
- 企业不存在返回 `COMPANY_NOT_FOUND`,非 COMPANY 返回 `FORBIDDEN`。
## TDD Requirement
1. 先写 Service/Controller 失败测试并确认 RED。
2. 再实现最小 DTO、Service、Controller 使测试 GREEN。
3. 不实现公司知识接口。
## Evidence Commands
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
mvn -Dtest=OnboardingCompanyChecklistServiceTest,OnboardingCompanyChecklistControllerTest test
```
## Done Definition
- 覆盖查询、创建、幂等重放、幂等冲突、上限、更新、版本冲突、软删除、重复删除和跨企业隔离。
- 创建请求哈希可重复,不依赖随机或当前时间。
- 既有学生清单行为不回归。

57
changes/n04-onboard/.superpowers/sdd/task-briefs/T5.md

@ -0,0 +1,57 @@
# T5 任务简报:公司知识配置与公开评价摘要
## Where This Fits
- Wave: W2
- Depends on: T4
- Contract: `changes/n04-onboard/execution-contract.md`
- Specs: `changes/n04-onboard/specs/company-knowledge/spec.md`
## Objective
实现 COMPANY 知识配置读取/Upsert 和 STUDENT 公司知识聚合接口,先用失败测试锁定基础信息回退、小样本保护和八维摘要。
## Files In Scope
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java`
## Required Behavior
- `GET /api/v1/onboarding/company/knowledge` 仅 COMPANY;无记录返回 configured=false、空内容、version=0;有记录返回 configured=true、culture、workPolicies、onboardingGuide、updatedAt、version。
- `PUT /api/v1/onboarding/company/knowledge` 仅 COMPANY;首次创建允许无 version;已有记录必须提供当前 version;冲突返回 `ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE`。
- 知识字段按纯文本处理,culture <=2000,workPolicies <=4000,onboardingGuide <=4000,至少一个非空。
- `GET /api/v1/onboarding/companies/{companyId}/knowledge` 仅 STUDENT;企业不存在返回 `COMPANY_NOT_FOUND`。
- 无知识记录时 knowledgeState=BASIC_ONLY、knowledge=null,并返回企业基础资料白名单。
- 有知识记录时 knowledgeState=CONFIGURED。
- 评价只统计该公司全部岗位下 PUBLISHED 且 AUTO_APPROVED/APPROVED 的评价。
- 0 条:state=EMPTY,totalReviews=0,平均分/分布/标签/维度汇总为空。
- 1-2 条:state=INSUFFICIENT,返回 totalReviews,不返回任何均值。
- 至少 3 条:state=SUFFICIENT,返回 averageRating、ratingDistribution、topTags(次数降序、字典序升序,最多 10)、八个维度的 averageScore/sampleCount/dataState。
- 缺失维度 sampleCount=0、dataState=INSUFFICIENT。
- 响应不得包含 ownerUserId、studentUserId、审核原因、哈希、IP、User-Agent。
## TDD Requirement
1. 先写 Service/Controller 失败测试,确认 RED。
2. 再实现最小 DTO、Service、Controller 让测试 GREEN。
3. 不修改既有评价/企业接口响应。
## Evidence Commands
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
mvn -Dtest=CompanyKnowledgeServiceTest,CompanyKnowledgeControllerTest test
```
## Done Definition
- 覆盖 COMPANY 无记录/已有记录/创建/更新/版本冲突。
- 覆盖 STUDENT BASIC_ONLY、CONFIGURED、EMPTY、INSUFFICIENT、SUFFICIENT。
- 覆盖公开审核过滤、标签排序、八维缺失和数据最小化。

30
changes/n04-onboard/.superpowers/sdd/task-briefs/T6-repair-1.md

@ -0,0 +1,30 @@
# T6 聚焦修复 1:50 条并发边界
## Prior Failure
`W3-T6.md` 在 MySQL 默认 `REPEATABLE READ` 等价测试下复现:49 条活动自定义项时并发发起两个创建请求,两个请求都通过计数检查,最终写入第 51 条,预期为一个成功、另一个 9703。
## Objective
修复并发 50 条边界,保证在 MySQL 默认隔离级别下最多只能有 50 条活动企业自定义项。
## Required Changes
- 在检查活动条目前使用当前读或锁定读,不得依赖事务早期快照。
- 企业行锁与活动条目计数必须处于同一事务,并确保计数读取看到锁等待前的已提交结果。
- 允许使用 `PESSIMISTIC_WRITE` 查询公司自定义项、调整锁顺序或等效数据库级机制。
- 保留既有的幂等重放、9704、软删除和跨企业隔离行为。
- 将并发边界测试配置为重复读隔离级别,使其能在 H2 `MODE=MySQL` 下复现并验证 MySQL 风险。
## Evidence
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
mvn -Dtest=OnboardingConcurrencyIntegrationTest,OnboardingCompanyChecklistServiceTest test
```
## Repair Constraints
- 只做聚焦修复,不扩展接口或用户可见行为。
- 不修改 V1-V38。
- 修复后必须通过 `Onboarding*Test,CompanyKnowledge*Test`。

55
changes/n04-onboard/.superpowers/sdd/task-briefs/T6.md

@ -0,0 +1,55 @@
# T6 任务简报:权限、错误码、并发与幂等集成
## Where This Fits
- Wave: W3
- Depends on: T5
- Contract: `changes/n04-onboard/execution-contract.md`
- Design: `changes/n04-onboard/design.md` 的 API、Error Codes、Risks
## Objective
把 W2 已完成的清单与知识服务接入统一安全、错误码和并发语义,先用集成测试锁定权限矩阵、所有权隔离、版本冲突和响应最小化。
## Files In Scope
- `mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java`
## Required Behavior
- `/onboarding/**` 必须认证;未登录 401。
- 学生接口仅 STUDENT;企业接口仅 COMPANY;角色不符 403。
- 企业接口只能操作当前用户绑定的企业;跨企业 itemId 返回 9701,不能泄露其他企业数据。
- 新增 9701-9706:item not found、item concurrent update、item limit exceeded、idempotency conflict、progress concurrent update、knowledge concurrent update。
- 创建幂等冲突返回 9704;活动项上限返回 9703。
- 更新/删除清单项乐观锁冲突映射 9702;进度冲突映射 9705;知识冲突映射 9706。
- 所有响应使用白名单 DTO,不包含 ownerUserId、studentUserId、审核原因、哈希、IP、User-Agent、Token。
- 失败请求不得产生部分写入或修改其他企业数据。
## TDD Requirement
1. 先写三个集成测试并确认 RED。
2. 再修改安全配置、错误码和 Service 异常映射使测试 GREEN。
3. 不改变既有非 onboarding 路径的安全语义。
## Evidence Commands
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
mvn -Dtest=OnboardingAuthorizationIntegrationTest,OnboardingConcurrencyIntegrationTest,OnboardingPrivacyTest test
```
## Done Definition
- 权限矩阵覆盖匿名、STUDENT、COMPANY、ADMIN、错误企业。
- 并发与幂等冲突均返回契约规定的 HTTP 状态和错误码。
- 隐私测试检查 JSON 响应不含内部字段。
- onboarding 专项测试全部通过。
- 并发创建:相同 idempotencyKey 并发请求只能产生一条记录并返回成功或 9704;并发达到 50 条边界不得写入第 51 条活动项。
- 首次进度记录若携带非 0 version,必须返回 9705。

47
changes/n04-onboard/.superpowers/sdd/task-briefs/T7.md

@ -0,0 +1,47 @@
# T7 任务简报:Onboarding 端到端与缺口测试
## Where This Fits
- Wave: W3
- Depends on: T6
- Contract: `changes/n04-onboard/execution-contract.md`
## Objective
补齐模块级端到端和缺口测试,验证 9 个接口在真实 Spring 上下文、H2 MODE=MySQL 持久化、安全过滤和响应契约下协同工作。
## Files In Scope
- `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingEndToEndIntegrationTest.java`
- 可在 `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/` 内补充测试缺口。
- 只为修复测试暴露的 onboarding 缺陷修改 `com.mydreamjob.onboarding`、`ErrorCode.java` 或 onboarding 安全匹配。
## Required Coverage
- 9 个路径均至少有成功或权限错误契约测试。
- STUDENT:通用回退、企业合并、完成/取消、重复状态、版本冲突。
- COMPANY:查询、创建、幂等重放、幂等冲突、上限、更新、软删除、重复删除、跨企业隔离。
- STUDENT 公司知识:BASIC_ONLY、CONFIGURED、EMPTY、INSUFFICIENT、SUFFICIENT。
- 评价过滤排除 HIDDEN 或非公开审核状态。
- 匿名 401、错误角色 403、企业不存在 404。
- 响应 JSON 不含 ownerUserId、studentUserId、moderationReason、hash、ip、userAgent、token。
- 数据库行数和状态在所有错误路径后保持正确。
## TDD Requirement
1. 先写端到端/缺口测试并确认 RED 或明确复现缺口。
2. 修复最小实现使测试 GREEN。
3. 不扩大用户可见行为。
## Evidence Commands
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
mvn -Dtest=Onboarding*Test test
```
## Done Definition
- 所有 onboarding 测试通过。
- 没有仅覆盖 happy path 而遗漏契约错误路径。
- 测试在随机顺序下仍稳定,不依赖真实时钟精度或外部服务。

35
changes/n04-onboard/.superpowers/sdd/task-briefs/T8-repair-1.md

@ -0,0 +1,35 @@
# T8 聚焦修复 1:OpenAPI 示例与默认值
## Prior Failure
`W3-T8.md` 发现:
- `PUT /onboarding/company/knowledge` 的 200 示例缺少运行时响应必备的 `version`。
- `CompanyChecklistItemCreateRequest` 缺少运行时默认值 `sortOrder=1000`、`required=false`。
- `docs/verify_openapi.py` 未断言示例字段和 schema 默认值,因此未捕获上述偏差。
## Objective
修正 OpenAPI 契约,并增强验证脚本,使示例、默认值与运行时 DTO 一致。
## Required Changes
- 在 `docs/openapi-rc.json` 中修正 `PUT /onboarding/company/knowledge` 的 200 示例,包含 `version`。
- 为 `CompanyChecklistItemCreateRequest.sortOrder` 增加默认值 `1000`。
- 为 `CompanyChecklistItemCreateRequest.required` 增加默认值 `false`。
- 在 `docs/verify_openapi.py` 中增加精确断言:
- 知识 Upsert 200 示例的 `data.version` 存在。
- 创建请求 schema 的两个默认值正确。
- 不修改 runtime API 行为。
## Evidence
```powershell
cd D:\1\zhi\zhilai
python .\docs\verify_openapi.py
```
```powershell
cd D:\1\zhi\zhilai\mydreamjob-server
mvn -Dtest=Onboarding*Test,CompanyKnowledge*Test test
```

48
changes/n04-onboard/.superpowers/sdd/task-briefs/T8.md

@ -0,0 +1,48 @@
# T8 任务简报:OpenAPI、接口文档与前端联调
## Where This Fits
- Wave: W3
- Depends on: T7
- Contract: `changes/n04-onboard/execution-contract.md`
## Objective
发布 onboarding 的静态 OpenAPI、接口说明、前端联调说明和路线图状态,先用契约校验暴露文档缺口,再补齐文档。
## Files In Scope
- `docs/openapi-rc.json`
- `docs/onboarding-api.md`
- `docs/frontend-integration.md`
- `docs/backend-development-roadmap.md`
- `README.md`
## Required Behavior
- OpenAPI 包含 9 个 `/onboarding` 路径和全部请求/响应 schema,operationId 全局唯一。
- 文档说明角色、企业所有权、通用/企业项合并、完成状态版本、幂等键、软删除、知识回退、评价小样本阈值和 9701-9706。
- 前端联调文档给出 Base URL、鉴权头、请求示例、响应示例、409/404/401/403 处理。
- README 和 roadmap 将入职清单与公司知识介绍状态从“未开始”更新为“实现完成/待验收”。
- 文档字段名、默认值、错误码与运行时 DTO 完全一致。
## TDD Requirement
1. 先运行 `python docs\verify_openapi.py`,记录缺失路径或 schema 的失败证据。
2. 再更新 OpenAPI 和文档,直到校验通过。
3. 文档校验失败不得通过删除既有路径或放宽校验绕过。
## Evidence Commands
```powershell
cd D:\1\zhi\zhilai
python .\docs\verify_openapi.py
cd .\mydreamjob-server
mvn clean verify
```
## Done Definition
- OpenAPI 校验通过,9 个新路径可被 Apifox/前端消费。
- 文档与 runtime 注解一致,无未说明错误。
- 全量测试和打包通过。

51
changes/n04-onboard/decision-point-audit.md

@ -0,0 +1,51 @@
# N04 Decision Point Audit
审计时间:2026-09-17T08:03:00Z
## Decision Point Status
| DP | Status | Evidence |
| --- | --- | --- |
| DP-0 | confirmed | Full workflow、范围、非目标、语言和最小可用闭环已确认 |
| DP-1 | confirmed | 学生清单、企业配置、公司知识、评价摘要边界已确认 |
| DP-2 | approved | proposal、两份 delta specs、design、tasks 已审阅 |
| DP-3 | approved | execution contract B1-B3 与 12 条 Requirement 映射已批准 |
| DP-4 | approved | SDD;W1/W2/W3 串行 wave 和依赖已固化 |
| DP-5 | not-triggered | T6/T8 各一次聚焦修复后复评通过,未达到三轮升级条件 |
| DP-6 | pass with formal waiver | N04 专项、OpenAPI、打包通过;既有非 N04 全量失败由用户正式豁免;真实 MySQL/Docker 保留为发布前门禁 |
| DP-7 | confirmed | 用户授权归档并手工合并 feat/n04-onboard 到 master |
## Contract Coverage
- W1:V38/V39 三张表、通用清单种子、实体、Repository 和公开评价聚合。
- W2:学生有效清单与完成状态、企业清单 CRUD、公司知识配置与聚合,66 个 wave 测试通过。
- W3:权限、所有权、幂等、并发、端到端、隐私、OpenAPI 和文档,96 个 onboarding 回归通过。
- 12 条 MUST/SHALL 行为均有实现、测试和批次证据。
- 两份 delta specs 已发布到根 specs/,共 12 条 Requirements,无冲突。
## Verification Evidence
- N04 专项:mvn -Dtest=Onboarding*Test,CompanyKnowledge*Test test,96 tests,0 failures,0 errors,BUILD SUCCESS。
- 并发边界:REPEATABLE_READ 等价测试通过,49→50 时最多一条成功。
- 随机顺序 E2E:7 tests,0 failures,0 errors,BUILD SUCCESS。
- OpenAPI:135 paths / 172 operations;N04 6 paths / 9 operations;python docs\verify_openapi.py 全部通过。
- 打包:mvn -DskipTests package,BUILD SUCCESS。
- 全量:mvn clean verify,1492 tests,2 failures,9 errors,BUILD FAILURE。
## External Blockers
- CareerProgressServiceTest.fullAggregation_returnsCorrectSummaries 既有失败。
- JobReviewDimensionControllerTest.listReviews_shouldReturnDimensionScores 既有失败。
- JobReviewDimensionServiceTest.listReviews_shouldReturnDimensionScoresWithoutN1 既有错误。
- LocalResumeStorageServiceTest 8 个 JUnit 临时目录清理错误,属于既有环境问题。
- 上述失败路径不在 N04 diff 中;需要在独立任务处理或由用户正式豁免。
- 真实 MySQL 8.4 / Docker 黑盒验收尚未执行,当前证据为 H2 MODE=MySQL。
## Scope and Coherence
- N04 变更集中在 com.mydreamjob.onboarding、V38/V39、SecurityConfig、ErrorCode、GlobalExceptionHandler、OpenAPI、接口文档和 onboarding 测试。
- 未修改 V1-V38 历史迁移,未改变既有企业、岗位、Offer 或评价接口语义。
- 所有 W3 任务和 wave 复审均 PASS;全量测试外部失败已单独记录。
## Formal Waiver
用户于 2026-09-17T02:47:50Z 明确批准:将与 N04 无关的既有全量测试失败作为正式豁免,并将真实 MySQL 8.4 / Docker 黑盒验收保留为发布前门禁。

329
changes/n04-onboard/design.md

@ -0,0 +1,329 @@
# n04-onboard 设计
## Facts and Constraints
- 当前后端为 Java 17、Spring Boot 3.5.16、Spring MVC、Spring Security、Spring Data JPA、Flyway、MySQL 8.4,测试使用 JUnit、Mockito、MockMvc 和 H2 `MODE=MySQL`。
- 现有迁移已到 V37。本模块使用 V38 建表、V39 写入通用清单种子,不修改既有迁移或既有表结构。
- `companies.owner_user_id` 唯一,现有 `CompanyService` 已提供 COMPANY 角色与所有权校验模式。
- `job_reviews` 关联 `job_id`,企业评价必须通过 `jobs.company_id` 关联;公开评价限定为 `status=PUBLISHED` 且 `moderation_status IN (AUTO_APPROVED, APPROVED)`。
- 现有八维评价维度由 `ReviewDimension` 枚举统一维护;公司知识摘要复用该枚举,不新增维度定义。
- 统一响应使用 `ApiResponse`,错误使用 `BusinessException` 与 `ErrorCode`,请求 ID 从 `X-Request-Id` 读取。
- 产品要求企业未配置时回退通用清单,公司信息不足时回退企业基础信息,且不调用 AI。
## Goals
- 用最少的数据表和接口完成学生清单、个人完成状态、企业清单配置、公司知识配置与公开评价摘要。
- 保持角色、企业所有权、响应字段和错误码可测试。
- 保持创建幂等、更新并发可控、软删除可重复执行。
- 不改变企业、岗位、Offer、评价等既有接口语义。
## Non-Goals
- 不实现 ADMIN 模板治理、审核发布、版本回滚和 CMS。
- 不实现通知、内容推荐、试用期提醒、入职回访、在职反馈。
- 不按岗位、投递或 Offer 生成个性化内容。
- 不存储或渲染 HTML/富文本,不接入 AI 生成或 AI 审核。
- 不实现前端页面和前端缓存策略。
## Architecture
新增包 `com.mydreamjob.onboarding`:
```text
onboarding/
├─ OnboardingChecklistController.java
├─ CompanyKnowledgeController.java
├─ OnboardingChecklistService.java
├─ CompanyKnowledgeService.java
├─ OnboardingChecklistItem.java
├─ StudentOnboardingProgress.java
├─ CompanyKnowledge.java
├─ OnboardingChecklistItemRepository.java
├─ StudentOnboardingProgressRepository.java
├─ CompanyKnowledgeRepository.java
└─ dto/
├─ ChecklistItemView.java
├─ EffectiveChecklistResponse.java
├─ ProgressUpdateRequest.java
├─ ProgressUpdateResponse.java
├─ CompanyChecklistItemCreateRequest.java
├─ CompanyChecklistItemUpdateRequest.java
├─ CompanyChecklistItemView.java
├─ CompanyKnowledgeUpdateRequest.java
├─ CompanyKnowledgeView.java
├─ CompanyKnowledgeOverviewResponse.java
└─ ReviewSummaryView.java
```
`SecurityConfig` 将 `/onboarding/**` 设为认证访问;Controller/Service 再按角色和所有权执行细粒度校验。所有接口返回 `ApiResponse<T>`。
## API Contract
所有路径均位于 `/api/v1` 之下。
| Method | Path | Role | Request | Success Response |
|---|---|---|---|---|
| GET | `/onboarding/companies/{companyId}/checklist` | STUDENT | path `companyId` | `EffectiveChecklistResponse` |
| PUT | `/onboarding/companies/{companyId}/checklist/items/{itemId}/completion` | STUDENT | `ProgressUpdateRequest` | `ProgressUpdateResponse` |
| GET | `/onboarding/companies/{companyId}/knowledge` | STUDENT | path `companyId` | `CompanyKnowledgeOverviewResponse` |
| GET | `/onboarding/company/checklist-items` | COMPANY | 无 | `List<CompanyChecklistItemView>` |
| POST | `/onboarding/company/checklist-items` | COMPANY | `CompanyChecklistItemCreateRequest` | `CompanyChecklistItemView` |
| PUT | `/onboarding/company/checklist-items/{itemId}` | COMPANY | `CompanyChecklistItemUpdateRequest` | `CompanyChecklistItemView` |
| DELETE | `/onboarding/company/checklist-items/{itemId}?version={version}` | COMPANY | query `version` | `CompanyChecklistItemView`,`active=false` |
| GET | `/onboarding/company/knowledge` | COMPANY | 无 | `CompanyKnowledgeView` |
| PUT | `/onboarding/company/knowledge` | COMPANY | `CompanyKnowledgeUpdateRequest` | `CompanyKnowledgeView` |
### Effective Checklist Response
```json
{
"companyId": 2,
"companyName": "本源科技",
"customized": true,
"totalItems": 13,
"completedItems": 4,
"completionRate": 30.8,
"items": [
{
"itemId": 11,
"source": "COMPANY",
"itemKey": "company-2-uuid",
"title": "提交导师联系方式",
"description": "入职前由 HR 补充",
"category": "ADMIN",
"required": false,
"sortOrder": 200,
"active": true,
"completed": false,
"completedAt": null,
"version": 0
}
]
}
```
通用项使用 `source=STANDARD`;企业项使用 `source=COMPANY`。完成率保留一位小数,最大为 100.0;清单为空时完成率为 0.0。
### Completion Request
```json
{
"completed": true,
"version": 0
}
```
- 无完成记录时 `version` 可省略或为 0。
- 已有记录且请求值不同,必须提供当前 `version`;版本不一致返回 409 `ONBOARDING_PROGRESS_CONCURRENT_UPDATE`。
- 已有记录且请求值与当前值相同,按幂等成功返回,不因旧版本报错。
### Company Item Create Request
```json
{
"title": "提交导师联系方式",
"description": "入职前由 HR 补充",
"category": "ADMIN",
"sortOrder": 200,
"required": false,
"idempotencyKey": "client-generated-key"
}
```
- `title` 必填,长度 1-120。
- `description` 可空,最大 1000。
- `category` 仅允许 `DOCUMENT`、`ADMIN`、`EQUIPMENT`、`TRAINING`、`LOGISTICS`、`OTHER`。
- `sortOrder` 范围 0-9999,默认 1000。
- `required` 默认 `false`。
- `idempotencyKey` 可空,最大 64;服务端保存规范化请求体 SHA-256,用于同 key 不同内容冲突检测。
### Company Item Update/Delete
更新请求包含 `version`;删除请求通过 `version` 查询参数传入。两者版本不匹配返回 409 `ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE`。删除采用软删除,重复删除按成功返回。
### Company Knowledge Request
```json
{
"culture": "重视透明沟通和学习",
"workPolicies": "弹性工作制;每周三远程",
"onboardingGuide": "9:30 到 A 座 8 层前台报到",
"version": 0
}
```
- 三个文本字段可空,但至少一个非空。
- `culture` 最大 2000,`workPolicies` 最大 4000,`onboardingGuide` 最大 4000。
- 首次创建不要求版本;已有记录必须提供当前版本,冲突返回 409 `ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE`。
### Student Company Knowledge Response
```json
{
"company": {
"id": 2,
"name": "本源科技",
"logoUrl": null,
"description": "企业简介",
"industry": "互联网",
"website": "https://example.com",
"address": "上海市",
"verified": true
},
"knowledgeState": "CONFIGURED",
"knowledge": {
"culture": "重视透明沟通和学习",
"workPolicies": "弹性工作制;每周三远程",
"onboardingGuide": "9:30 到 A 座 8 层前台报到",
"updatedAt": "2026-09-16T07:00:00"
},
"reviewSummary": {
"state": "SUFFICIENT",
"totalReviews": 12,
"averageRating": 4.4,
"ratingDistribution": {
"rating1": 0,
"rating2": 1,
"rating3": 2,
"rating4": 4,
"rating5": 5
},
"topTags": ["成长空间", "团队氛围"],
"dimensionSummary": {
"TEAM_ATMOSPHERE": {
"averageScore": 4.5,
"sampleCount": 8,
"dataState": "SUFFICIENT"
}
}
}
}
```
无知识记录时 `knowledgeState=BASIC_ONLY`、`knowledge=null`,企业基础信息仍返回。评价少于 3 条时 `reviewSummary.state=INSUFFICIENT` 或 `EMPTY`,且 `averageRating`、`ratingDistribution`、`topTags`、`dimensionSummary` 为空。
## Data Model
### `onboarding_checklist_items`
```text
id BIGINT PK
scope_type VARCHAR(20) NOT NULL -- STANDARD / COMPANY
scope_id BIGINT NOT NULL -- 0 或 company_id
company_id BIGINT NULL -- 企业项外键;通用项为 NULL
item_key VARCHAR(80) NOT NULL
title VARCHAR(120) NOT NULL
description VARCHAR(1000) NULL
category VARCHAR(30) NOT NULL
sort_order INT NOT NULL DEFAULT 1000
required BOOLEAN NOT NULL DEFAULT FALSE
active BOOLEAN NOT NULL DEFAULT TRUE
idempotency_key VARCHAR(64) NULL
request_hash CHAR(64) NULL
version BIGINT NOT NULL DEFAULT 0
created_at DATETIME NOT NULL
updated_at DATETIME NOT NULL
UNIQUE (scope_type, scope_id, item_key)
UNIQUE (company_id, idempotency_key)
INDEX (scope_type, scope_id, active, sort_order, id)
```
`scope_type/scope_id` 为公司项唯一性提供稳定语义,同时允许通用项使用 `scope_id=0`;`company_id` 仅对企业项建立外键,标准项为 NULL。应用层检查 `scope_type=COMPANY` 时 `scope_id=company_id` 且 `company_id` 非空。
### `student_onboarding_progress`
```text
id BIGINT PK
student_user_id BIGINT NOT NULL
item_id BIGINT NOT NULL
completed BOOLEAN NOT NULL
completed_at DATETIME NULL
version BIGINT NOT NULL DEFAULT 0
created_at DATETIME NOT NULL
updated_at DATETIME NOT NULL
UNIQUE (student_user_id, item_id)
FOREIGN KEY student_user_id -> users(id)
FOREIGN KEY item_id -> onboarding_checklist_items(id)
```
完成状态采用绝对值 Upsert;同一学生与条目唯一。`completedAt` 在首次置为完成时写入,取消完成时清空。
### `company_knowledge`
```text
id BIGINT PK
company_id BIGINT NOT NULL
culture VARCHAR(2000) NULL
work_policies VARCHAR(4000) NULL
onboarding_guide VARCHAR(4000) NULL
version BIGINT NOT NULL DEFAULT 0
created_at DATETIME NOT NULL
updated_at DATETIME NOT NULL
UNIQUE (company_id)
FOREIGN KEY company_id -> companies(id)
```
## Decisions
### Decision 1: 使用统一清单表并保留标准/企业来源
- **Choice**:`onboarding_checklist_items` 使用 `scope_type` 区分 `STANDARD` 与 `COMPANY`;有效清单查询返回两类记录。
- **Rationale**:学生只需要一个稳定 itemId 更新完成状态,统一表避免两套进度表、两套排序和重复响应逻辑。
- **Alternatives**:标准表与企业表分离;企业配置 JSON 存储在一个字段中。
- **Consequences**:需要额外维护 scope 一致性检查;换来了统一的完成状态和软删除语义。
### Decision 2: 通用清单固定种子,企业项只做增量
- **Choice**:V39 写入通用清单;企业自定义项只追加,不允许覆盖或替换通用项。
- **Rationale**:产品明确要求企业未配置时显示通用清单,固定基线可保证所有企业至少有可用内容。
- **Alternatives**:允许企业禁用通用项;允许企业完全替换模板。
- **Consequences**:MVP 不支持企业裁剪通用项;后续若需要,需新增停用映射而不是修改现有响应语义。
### Decision 3: 创建幂等与更新乐观锁分离
- **Choice**:创建使用 `idempotencyKey + request_hash`;更新、删除和知识内容使用 `@Version` 乐观锁。
- **Rationale**:创建需要处理网络重试,更新需要避免管理员或企业多端并发覆盖。
- **Alternatives**:所有写接口只依赖数据库唯一键;所有接口都使用请求幂等键。
- **Consequences**:客户端创建时需保存幂等键;更新时必须先取得当前版本。
### Decision 4: 公司知识摘要只使用公开评价并执行小样本保护
- **Choice**:只聚合 `PUBLISHED` 且审核为 `AUTO_APPROVED/APPROVED` 的评价;少于 3 条不返回均值。
- **Rationale**:与现有公开评价语义一致,降低小样本隐私和误导风险。
- **Alternatives**:聚合全部评价;无阈值返回所有均值。
- **Consequences**:新企业或评价较少企业只能看到基础资料与评价数量,不显示汇总分数。
### Decision 5: 学生访问不绑定 Offer
- **Choice**:任何 STUDENT 可按公开存在的 `companyId` 读取清单和知识,完成状态只属于当前学生。
- **Rationale**:产品没有要求入职资格校验,企业基础信息和通用清单也不属于敏感业务数据;此设计避免跨模块状态耦合。
- **Alternatives**:必须存在 ACCEPTED Offer;必须存在投递记录。
- **Consequences**:后续若要增加资格门槛,需新增权限规则和兼容测试,不改变现有路径。
## Error Codes
新增 97xx:
```text
9701 ONBOARDING_CHECKLIST_ITEM_NOT_FOUND
9702 ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE
9703 ONBOARDING_ITEM_LIMIT_EXCEEDED
9704 ONBOARDING_IDEMPOTENCY_CONFLICT
9705 ONBOARDING_PROGRESS_CONCURRENT_UPDATE
9706 ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE
```
复用 `FORBIDDEN`、`UNAUTHORIZED`、`BAD_REQUEST`、`COMPANY_NOT_FOUND`。
## Risks and Verification
| Risk | Verification |
|---|---|
| V38/V39 与 MySQL/H2 类型不一致 | 迁移集成测试检查三张表、唯一键、外键、种子数量与排序 |
| 企业跨租户访问条目 | Service/MockMvc 测试使用两个企业,断言跨企业返回 404 且数据未变化 |
| 重复创建产生重复项 | 持久化测试断言相同 idempotencyKey 只有一行;不同请求体断言 9704 |
| 并发更新静默覆盖 | 持久化测试和乐观锁冲突测试断言 9702/9706 |
| 完成状态重复或丢失 | 同一学生并发/重复 PUT 测试断言唯一记录、完成率和 completedAt 正确 |
| 评价聚合暴露小样本或内部字段 | DTO 白名单测试、少于 3 条测试、无知识回退测试 |
| 公司知识内容注入 HTML | 请求字段按纯文本存储和返回;长度与内容验证测试 |
| 既有模块回归 | 扩展评价/企业相关测试后执行 `mvn clean verify`,并与 baseline 对比路径数 |

165
changes/n04-onboard/execution-contract.md

@ -0,0 +1,165 @@
# N04 入职清单与公司知识介绍执行契约
## Intent Lock
**问题**:产品一期要求提供入职清单和公司知识介绍,但当前后端没有对应表、接口、权限或测试。学生无法获得“通用准备项 + 企业自定义项”的有效清单,无法保存个人完成状态,也无法集中查看企业基础资料、文化制度、入职指南和公开评价摘要;企业无法维护本公司差异内容。
**意图**:新增最小可用的 `onboarding` 后端模块。通过 V38/V39 建立三张业务表和通用清单种子;提供 9 个确定性接口;STUDENT 读取有效清单、更新本人完成状态、读取公司知识,COMPANY 仅维护本企业自定义清单与知识内容;公司知识使用企业基础资料、企业维护内容及公开评价摘要,其中公开评价少于 3 条时不返回均值。不调用 AI,不引入 ADMIN 内容治理。
**完成证明**:V38/V39 在 H2 与真实 MySQL 成功执行;9 个接口的权限、所有权、幂等、并发、回退、排序、完成率、评价摘要和小样本保护均通过自动化测试;OpenAPI、接口文档和前端联调文档与运行时一致;`mvn clean verify` 与 `python docs/verify_openapi.py` 通过。
## Scope Fence
### 允许范围
- 新增 `mydreamjob-server/src/main/resources/db/migration/V38__onboarding_checklist_and_company_knowledge.sql` 和 `V39__onboarding_standard_checklist_seed.sql`。
- 新增 `com.mydreamjob.onboarding` 领域包,包括实体、Repository、Service、Controller、DTO 和测试。
- 修改 `SecurityConfig.java`,使 `/onboarding/**` 仅允许认证访问,并在服务层执行 STUDENT/COMPANY 角色与公司所有权校验。
- 修改 `ErrorCode.java`,新增 9701-9706。
- 更新 `docs/openapi-rc.json`、`docs/onboarding-api.md`、`docs/frontend-integration.md`、`docs/backend-development-roadmap.md` 和 `README.md`。
- 只读复用 `Company`、`CompanyRepository`、`Job`、`JobReview`、`JobReviewDimensionScore` 及其 Repository。
- 采用 9 个固定接口:
- `GET /api/v1/onboarding/companies/{companyId}/checklist`
- `PUT /api/v1/onboarding/companies/{companyId}/checklist/items/{itemId}/completion`
- `GET /api/v1/onboarding/companies/{companyId}/knowledge`
- `GET /api/v1/onboarding/company/checklist-items`
- `POST /api/v1/onboarding/company/checklist-items`
- `PUT /api/v1/onboarding/company/checklist-items/{itemId}`
- `DELETE /api/v1/onboarding/company/checklist-items/{itemId}?version={version}`
- `GET /api/v1/onboarding/company/knowledge`
- `PUT /api/v1/onboarding/company/knowledge`
### 禁止范围
- 不修改 V1-V37 历史迁移或既有表结构。
- 不修改既有企业、岗位、Offer、评价和维度评分接口的路径、请求、响应或权限语义。
- 不实现 ADMIN 模板管理、审核发布、版本回滚、CMS 或内容治理后台。
- 不实现通知、内容推荐、试用期提醒、入职回访、在职反馈或前端页面。
- 不按岗位、投递或 Offer 生成个性化清单,不引入入职资格门槛。
- 不接 AI、第三方内容平台、外部搜索或实时同步。
- 不存储或渲染 HTML/富文本;知识字段按纯文本处理并限制长度。
- 不在响应或日志中输出 `ownerUserId`、员工用户 ID、手机号、邮箱、Token、IP、User-Agent、评价哈希或审核原因。
- 不启动或停止 Docker;真实 MySQL/Docker 黑盒验收需要用户单独授权。
## Approved Behavior
| ID | 已批准行为 | 批次 |
| --- | --- | --- |
| REQ-OC-1 | STUDENT 按存在的 `companyId` 获取有效清单;响应包含通用项与企业活动自定义项、排序、分类、必填标记、个人完成状态、完成数、总数、完成率和 `customized`;企业不存在返回 404。 | B1、B2 |
| REQ-OC-2 | STUDENT 通过 PUT 将本人对有效条目的状态设置为完成或未完成;相同状态重复提交幂等;取消完成清除 `completedAt`;跨企业或非活动 itemId 返回 404;响应返回版本和最新完成率。 | B1、B2、B3 |
| REQ-OC-3 | COMPANY 创建本企业自定义项;请求校验标题、说明、分类、排序和必填标记;相同 `idempotencyKey` 与相同请求体返回原条目,不同请求体返回 409 `ONBOARDING_IDEMPOTENCY_CONFLICT`;活动自定义项达到 50 条后返回 409 `ONBOARDING_ITEM_LIMIT_EXCEEDED`。 | B1、B2、B3 |
| REQ-OC-4 | COMPANY 查询本企业全部自定义项;使用当前 `version` 更新标题、说明、分类、排序、必填和活动状态;过期版本返回 409 `ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE`;不得读取或修改其他企业条目。 | B1、B2、B3 |
| REQ-OC-5 | COMPANY 使用当前 `version` 软删除本企业条目;条目从学生有效清单消失,历史和完成记录保留;重复删除幂等成功;跨企业删除返回 404 且状态不变。 | B1、B2、B3 |
| REQ-OC-6 | 学生接口仅允许 STUDENT;企业配置接口仅允许 COMPANY 且只能操作当前用户拥有的企业;未登录返回 401,角色不符返回 403,无企业资料返回 404;所有响应使用白名单 DTO。 | B2、B3 |
| REQ-CK-1 | STUDENT 按存在的 `companyId` 读取公司知识;企业有知识记录返回 `CONFIGURED`,无记录返回 `BASIC_ONLY` 和企业基础资料;不得返回 `ownerUserId` 等内部字段;企业不存在返回 404。 | B1、B2 |
| REQ-CK-2 | 公司评价摘要只使用该公司岗位下 `PUBLISHED` 且审核为 `AUTO_APPROVED/APPROVED` 的评价;无评价返回 `EMPTY`;少于 3 条返回 `INSUFFICIENT` 和不含均值的真实数量;至少 3 条返回平均分、评分分布、热门标签和八维摘要,维度返回样本数和 `SUFFICIENT/INSUFFICIENT`。 | B1、B2、B3 |
| REQ-CK-3 | COMPANY 读取本企业知识配置;无记录返回 `configured=false`、空内容和版本 0;有记录返回内容、更新时间和当前版本。 | B1、B2 |
| REQ-CK-4 | COMPANY Upsert 本企业知识内容;文化最多 2000 字符,工作制度和入职指南最多 4000 字符,至少一个字段非空,按纯文本处理;已有记录使用当前版本,冲突返回 409 `ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE`。 | B1、B2、B3 |
| REQ-CK-5 | 公司知识学生读取仅允许 STUDENT;企业配置仅允许 COMPANY 且只操作本企业;非角色访问返回 401/403;不存在的公司返回 404。 | B2、B3 |
| REQ-CK-6 | 本变更只读复用既有企业、岗位、评价和维度数据,不修改历史表结构和既有接口语义;既有测试与 OpenAPI 路径必须回归通过。 | B3 |
需求覆盖结果:两份 spec 中全部 12 条 Requirement 的 `MUST`/`SHALL` 行为均已映射到 Approved Behavior、至少一个测试义务和至少一个执行批次,无未映射需求。
## Architecture and Interface Constraints
- 数据模型固定为 `onboarding_checklist_items`、`student_onboarding_progress`、`company_knowledge` 三张表。
- `onboarding_checklist_items` 使用 `scope_type/scope_id/item_key` 区分并唯一化 STANDARD 与 COMPANY 条目;`company_id` 仅对企业项建立外键。
- `student_onboarding_progress(student_user_id, item_id)` 唯一;完成状态采用绝对值 Upsert,`completedAt` 仅在完成时存在。
- `company_knowledge.company_id` 唯一;知识内容按纯文本存储并使用 `@Version`。
- 通用清单由 V39 固定种子提供,企业自定义项只增量追加,不允许覆盖或裁剪通用项。
- 学生有效清单必须在一次查询中合并活动通用项和企业项,并使用 `sortOrder ASC, id ASC` 稳定排序。
- 完成率计算为 `completedItems * 100.0 / totalItems`,保留一位小数并最大为 100.0;空清单为 0.0。
- 创建清单项使用 `idempotencyKey` 和规范化请求体 SHA-256;更新、删除、完成状态和知识内容使用乐观锁语义。
- 公司评价摘要必须通过公司下所有岗位聚合,不得只使用单个岗位汇总;热门标签从公开评价 tags 中计数,按次数降序、字典序升序,最多 10 个。
- 八维摘要复用 `ReviewDimension`,八个维度均返回,缺失维度样本数为 0、`dataState=INSUFFICIENT`。
- 所有响应使用 `ApiResponse<T>`;请求 ID 从 `X-Request-Id` 读取。
- 新增错误码固定为:
- `9701 ONBOARDING_CHECKLIST_ITEM_NOT_FOUND`
- `9702 ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE`
- `9703 ONBOARDING_ITEM_LIMIT_EXCEEDED`
- `9704 ONBOARDING_IDEMPOTENCY_CONFLICT`
- `9705 ONBOARDING_PROGRESS_CONCURRENT_UPDATE`
- `9706 ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE`
- 复用 `FORBIDDEN`、`UNAUTHORIZED`、`BAD_REQUEST` 和 `COMPANY_NOT_FOUND`。
- 迁移编号以当前仓库下一个可用版本为准;若 V38/V39 被其他变更占用,必须回退规划阶段刷新编号和契约。
## Execution Batches
### Batch B1: 数据模型、迁移与只读聚合基础
- 覆盖任务:T1、T2。
- 交付物:V38/V39 迁移、三个实体、三个模块 Repository、现有评价跨岗位聚合查询、迁移和 Repository 集成测试。
- 完成定义:H2/MySQL 可创建三张表、约束和索引;通用种子数量和排序稳定;活动通用项与企业项可合并查询;学生完成记录可批量读取;公司公开评价可按评分、维度和 tags 聚合。
- 依赖:无。
- 评审门:B1 完成并由 `code-reviewer` 记录 `pass` 后才能开始 B2。
### Batch B2: 学生清单、企业配置与公司知识接口
- 覆盖任务:T3、T4、T5。
- 交付物:两个 Controller、两个 Service、全部 DTO、清单合并与完成状态、自定义项 CRUD、知识 Upsert、基础信息和评价摘要组装、Service 与 MockMvc 专项测试。
- 完成定义:REQ-OC-1 至 REQ-OC-6、REQ-CK-1 至 REQ-CK-5 的接口和服务行为实现;企业无自定义项时回退通用清单;无知识时返回基础信息;公开评价少于 3 条不返回均值。
- 依赖:B1 通过评审。
- 评审门:B2 完成并由 `code-reviewer` 记录 `pass` 后才能开始 B3。
### Batch B3: 权限、并发、契约、全量测试与文档
- 覆盖任务:T6、T7、T8。
- 交付物:`SecurityConfig` 与 `ErrorCode` 更新、角色和所有权集成测试、幂等与乐观锁冲突测试、隐私断言、端到端测试、OpenAPI、接口文档和前端联调文档。
- 完成定义:匿名 401、非角色 403、企业不存在 404、跨企业 404、版本冲突 409、幂等冲突 409、活动项上限 409 均符合契约;OpenAPI 包含 9 个路径并运行时一致;`mvn clean verify` 与 `python docs/verify_openapi.py` 通过。
- 依赖:B2 通过评审。
- 评审门:B3 完成并由 `code-reviewer` 记录 `pass` 后才能进入验证和收尾。
## Test Obligations
| ID | 测试义务 | 批次 | 证据 |
| --- | --- | --- | --- |
| TO-1 | V38/V39 创建三张表、主外键、唯一键和索引;通用种子内容、分类和排序稳定。 | B1 | `mvn -Dtest=OnboardingMigrationTest,OnboardingSeedDataIntegrationTest test` |
| TO-2 | 企业无自定义项回退通用清单;有项时合并排序、来源和 category 正确。 | B1、B2 | Repository 集成测试、`OnboardingChecklistServiceTest` |
| TO-3 | 完成状态首次设置、重复相同状态、取消完成、跨企业 itemId 和完成率。 | B2 | `mvn -Dtest=OnboardingChecklistServiceTest,OnboardingChecklistControllerTest test` |
| TO-4 | 创建校验、幂等重放、请求体哈希冲突和 50 条活动项上限。 | B2、B3 | `OnboardingCompanyChecklistServiceTest`、持久化测试 |
| TO-5 | 企业列表、更新、软删除、版本冲突、重复删除和跨企业隔离。 | B2、B3 | `OnboardingCompanyChecklistServiceTest`、`OnboardingConcurrencyIntegrationTest` |
| TO-6 | STUDENT/COMPANY/ADMIN/匿名权限矩阵和白名单响应。 | B2、B3 | `OnboardingAuthorizationIntegrationTest`、`OnboardingPrivacyTest` |
| TO-7 | 公司知识无记录回退、企业读取、首次创建、更新和长度校验。 | B2 | `mvn -Dtest=CompanyKnowledgeServiceTest,CompanyKnowledgeControllerTest test` |
| TO-8 | 评价只取公开记录;0、1、2、3 条边界;评分分布、标签和八维摘要。 | B1、B2 | 聚合 Repository 测试、`CompanyKnowledgeServiceTest` |
| TO-9 | 知识版本冲突返回 9706,不覆盖新内容。 | B2、B3 | `OnboardingConcurrencyIntegrationTest` |
| TO-10 | 9 个接口的状态码、请求/响应 DTO、错误码和 OpenAPI 一致性。 | B3 | `OnboardingEndToEndIntegrationTest`、`python docs/verify_openapi.py` |
| TO-11 | 迁移、种子、实体、Repository、Service、Controller 和权限集成场景全部可重复。 | B1、B2、B3 | `mvn -Dtest=Onboarding*Test test` |
| TO-12 | 既有模块回归、完整打包、静态契约和无敏感字段泄露。 | B3 | `mvn clean verify`、`python docs/verify_openapi.py` |
## Build Rules
- 每个批次按 TDD 先写失败测试,再实现最小行为,不得跳过测试。
- 只修改允许范围内文件;发现需要修改已验收接口、历史迁移或既有响应语义时立即停止并触发 Rewind。
- 迁移和种子数据必须可在 H2(`MODE=MySQL`)与真实 MySQL 执行,不依赖外部文件路径。
- 所有清单排序、完成率、评价汇总和标签排序必须确定性;禁止随机数、当前时间或请求顺序影响结果。
- 新增日志不得包含个人完成明细、Token、手机号、邮箱、IP、User-Agent、评价哈希或审核原因。
- OpenAPI 注解、示例和实际响应必须一致,不能只改文档或只改运行时。
- 企业内容按纯文本处理,不拼接 HTML;前端展示安全由 JSON 字符串和前端转义共同保证。
- 不启动或停止 Docker;若需要真实 MySQL/Docker 黑盒验证,必须先取得用户单独授权。
## Review Gates
- **DP-3**:本执行契约必须由用户明确批准并记录 `dp_3_result`、`dp_3_timestamp` 后,才能开始任何实现。
- **Batch Review**:B1、B2、B3 每批完成后由 `code-reviewer` 按规范符合性和代码质量评审,记录 `pass` 或 `fail`。
- **DP-4**:批准契约后由 `build-executor` 给出执行模式和 wave 计划,用户确认后开始执行。
- **Verification Gate**:实现完成后执行专项测试、全量测试、OpenAPI 校验和必要的 MySQL/Docker 黑盒验证。
- **DP-7**:由 `release-archivist` 完成最终验证与关闭,不在本契约内自动完成。
## Rewind Conditions
出现以下任一情况必须停止执行并回到规划阶段刷新工件和本契约:
- 需要新增、删除或改变用户可见接口、请求字段、响应字段、权限或错误语义。
- 需要改变数据表结构、唯一键、外键、通用/企业项来源模型或迁移编号。
- 需要改变完成状态幂等、乐观锁、完成率公式或软删除语义。
- 需要改变公开评价审核状态、3 条小样本阈值、八维维度或热门标签排序。
- 需要引入 ADMIN 模板治理、内容审核、版本回滚、通知、推荐、Offer 资格门槛或 AI。
- 需要修改 V1-V37、既有企业/岗位/评价接口或既有表结构。
- V38/V39 被其他变更占用。
- 发现任一已批准 `MUST`/`SHALL` 无法映射到测试义务或执行批次。
## DP-3 Approval Gate
执行者确认:本契约完整覆盖已批准的 12 条 Requirement,无未映射需求;B1→B2→B3 依赖清晰;不同批次之间的实体、Repository、DTO 和错误码接口已在 Architecture and Interface Constraints 中固定;所有测试义务均有证据命令。
**批准方式**:用户明确回复批准本执行契约后,记录 `dp_3_result`、`dp_3_timestamp`,再执行 `specifying → bridging → approved-for-build` 状态转换。DP-3 记录前禁止进入 `build-executor`。

65
changes/n04-onboard/proposal.md

@ -0,0 +1,65 @@
# n04-onboard 入职清单与公司知识介绍
## Why
产品功能表将 M1.1「入职清单」和 M1.2「公司了解」列为一期 P2 功能,但当前后端没有对应表、服务、API、权限或测试。学生缺少可执行的标准入职准备事项,也无法在一个接口中集中查看企业基础资料、企业文化与制度、员工评价摘要;企业也没有维护自定义清单和公司知识内容的入口。
本变更以最小可用闭环补齐后端能力:通用清单提供稳定基线,企业自定义项补足差异,学生的个人完成状态可持续计算完成率;公司知识优先展示企业已维护内容与公开评价摘要,信息不足时合法回退到企业基础信息。
## What Changes
- 新增 `onboarding` 后端模块,提供 9 个接口:
- `GET /onboarding/companies/{companyId}/checklist`
- `PUT /onboarding/companies/{companyId}/checklist/items/{itemId}/completion`
- `GET /onboarding/companies/{companyId}/knowledge`
- `GET /onboarding/company/checklist-items`
- `POST /onboarding/company/checklist-items`
- `PUT /onboarding/company/checklist-items/{itemId}`
- `DELETE /onboarding/company/checklist-items/{itemId}`
- `GET /onboarding/company/knowledge`
- `PUT /onboarding/company/knowledge`
- 新增三类持久化数据:通用/企业自定义清单项、学生对清单项的完成状态、企业知识内容。
- 通过 Flyway 写入通用入职清单种子数据;企业未配置自定义项时,学生仍获得通用清单。
- 公司知识聚合企业基础资料、企业维护的文化/制度/入职指南,以及该公司所有公开岗位评价的汇总;有效公开评价少于 3 条时不展示平均分,避免小样本暴露。
- 企业配置接口仅允许企业维护本公司数据;学生接口仅允许学生读取公司资料并更新本人清单状态。
- 补齐错误码、幂等键、乐观锁、MockMvc、Service、持久化、迁移、端到端测试、OpenAPI 与前端联调文档。
## Scope
### In Scope
- 通用清单的数据库种子数据、排序、分类和必填标记。
- 企业自定义清单项的创建、查询、更新和软删除。
- 学生按企业读取有效清单、查看个人完成状态、设置完成或未完成。
- 企业知识内容的读取与 Upsert。
- 公司知识介绍聚合公司基础资料与公开评价摘要,并支持 `BASIC_ONLY` 回退。
- COMPANY 所有权校验、STUDENT 角色校验、敏感字段白名单响应。
- 创建幂等、更新/删除乐观锁、并发冲突错误码和重复请求重放。
- 模块自动化测试、OpenAPI 静态契约校验和前端联调文档。
### Out of Scope
- ADMIN 管理通用模板、发布审核、版本回滚和内容治理后台。
- 通知提醒、职场内容推荐、试用期提醒、入职回访和在职反馈。
- 按岗位、投递或 Offer 生成个性化清单。
- 第三方 CMS、富文本 HTML、AI 生成公司介绍或 AI 内容审核。
- 学生端页面、企业端页面和前端状态管理。
- 修改已有企业、岗位、Offer 或评价接口的响应语义。
## Impact
- `mydreamjob-server/src/main/resources/db/migration/`:新增 V38、V39 迁移,不修改 V1-V37。
- `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/`:新增领域实体、Repository、Service、Controller 和 DTO。
- `mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java`:新增 97xx 错误码。
- `docs/openapi-rc.json`、`docs/onboarding-api.md`、`docs/frontend-integration.md`、路线图和 README 状态说明。
- 现有 `Company`、`Job`、`JobReview`、`JobReviewDimensionScore` 只读复用,不改变其表结构和接口。
- 新增表使用 Flyway,支持 MySQL 8.4 与 H2 `MODE=MySQL` 集成测试。
## Proof of Completion
- 学生可在企业无自定义项时取得通用清单;有自定义项时取得合并后的有效清单。
- 学生重复设置同一完成状态不产生重复行,完成率和完成时间正确;并发冲突返回明确 409 错误码。
- 企业只能操作本企业数据;跨企业 itemId、非 COMPANY 或非 STUDENT 请求返回 403/404,不泄露其他企业数据。
- 公司知识响应只包含白名单字段;无企业知识内容时返回 `BASIC_ONLY` 和企业基础资料;评价少于 3 条时不返回平均分。
- `mvn -Dtest=Onboarding*Test test`、`mvn clean verify`、`python docs/verify_openapi.py` 全部通过。
- OpenAPI 契约包含全部 9 个路径、请求体和响应模型,且与运行时文档一致。

20
changes/n04-onboard/spec-merge-report.md

@ -0,0 +1,20 @@
# N04 Spec Merge Report
同步时间:2026-09-17T02:48:23Z
| Capability | ADDED | MODIFIED | REMOVED | RENAMED | Status |
| --- | ---: | ---: | ---: | ---: | --- |
| onboarding-checklist | 6 | 0 | 0 | 0 | published |
| company-knowledge | 6 | 0 | 0 | 0 | published |
- Total requirements published: 12
- Conflicts: none
- Delta files retained for traceability.
- Manual fallback used because ssf sync is unavailable in this environment.
## Hashes
- specs/onboarding-checklist/spec.md: 16dfd71160065d7df6dbd5457557de14ea82177f9b6fe2c128182f8414b74cf6
- changes/n04-onboard/specs/onboarding-checklist/spec.md: 22a458ea81e3081d0f427089712269f3481a91cb74f5b46295d16f369ee1a50b
- specs/company-knowledge/spec.md: fdbba93e72477c4616c8fa35682e3603921000e75a537c4327fbcd81ea966887
- changes/n04-onboard/specs/company-knowledge/spec.md: 254cc5df3b9d758d0f26321d6554b634b75672d61331fde74c317ec8d1da4e6e

125
changes/n04-onboard/specs/company-knowledge/spec.md

@ -0,0 +1,125 @@
# Company Knowledge
## ADDED Requirements
### Requirement: 学生读取公司知识介绍
系统 SHALL 允许已登录 STUDENT 通过 `GET /onboarding/companies/{companyId}/knowledge` 读取公司知识介绍。响应 MUST 聚合企业基础资料、企业维护的知识内容以及公开评价摘要,并使用白名单 DTO 排除 `ownerUserId` 等内部字段。
#### Scenario: 企业已维护知识内容
- **WHEN** STUDENT 请求存在企业且该企业已有知识内容
- **THEN** 系统返回企业基础资料、文化、工作制度、入职指南和更新时间
- **AND** `knowledgeState` 为 `CONFIGURED`
- **AND** 不返回企业 `ownerUserId`、员工用户 ID、评价哈希或审核原因
#### Scenario: 企业未维护知识内容
- **WHEN** STUDENT 请求存在企业且该企业没有知识内容记录
- **THEN** 系统仍返回企业基础资料
- **AND** `knowledgeState` 为 `BASIC_ONLY`
- **AND** 知识内容字段为 `null` 或空集合,不返回 404
#### Scenario: 企业不存在
- **WHEN** STUDENT 请求不存在的 `companyId`
- **THEN** 系统返回 404 `COMPANY_NOT_FOUND`
### Requirement: 公司知识评价摘要使用公开数据和小样本保护
系统 SHALL 聚合该企业全部岗位中状态为 `PUBLISHED` 且审核状态为 `AUTO_APPROVED` 或 `APPROVED` 的评价。公开评价少于 3 条时 MUST 返回 `INSUFFICIENT` 或 `EMPTY`,且不得返回平均分或维度均值。达到 3 条时 MAY 返回平均评分、评分分布、热门标签和八维均值。
#### Scenario: 无公开评价
- **WHEN** 企业没有任何符合条件的公开评价
- **THEN** `reviewSummary.state` 为 `EMPTY`
- **AND** `totalReviews` 为 0
- **AND** `averageRating` 与 `dimensionSummary` 为空
#### Scenario: 公开评价少于 3 条
- **WHEN** 企业只有 1 或 2 条符合条件的公开评价
- **THEN** `reviewSummary.state` 为 `INSUFFICIENT`
- **AND** 系统返回真实 `totalReviews`
- **AND** 不返回平均分、评分分布或维度均值
#### Scenario: 公开评价达到 3 条
- **WHEN** 企业有至少 3 条符合条件的公开评价
- **THEN** `reviewSummary.state` 为 `SUFFICIENT`
- **AND** 系统返回平均评分、评分分布、热门标签和维度汇总
- **AND** 每个维度返回样本数及是否达到小样本阈值
### Requirement: 企业读取本企业知识配置
系统 SHALL 允许 COMPANY 通过 `GET /onboarding/company/knowledge` 读取本企业知识配置。无记录时 MUST 返回 `configured=false` 和空内容,而不是 404;有记录时 MUST 返回内容和当前版本。
#### Scenario: 尚未配置
- **WHEN** COMPANY 请求本企业知识配置且尚未创建记录
- **THEN** 系统返回 `configured=false`
- **AND** 内容字段为空,版本为 `0`
#### Scenario: 已配置
- **WHEN** COMPANY 请求本企业已有知识配置
- **THEN** 系统返回文化、工作制度、入职指南、更新时间和当前版本
- **AND** `configured=true`
### Requirement: 企业 Upsert 公司知识内容
系统 SHALL 允许 COMPANY 通过 `PUT /onboarding/company/knowledge` 创建或更新本企业知识内容。字段 MUST 使用纯文本并受长度限制,不允许 HTML 富文本。更新已有记录时 MUST 使用乐观锁版本;版本冲突返回 409 `ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE`。
#### Scenario: 首次创建
- **WHEN** COMPANY 首次提交合法的知识内容
- **THEN** 系统创建绑定该企业的知识记录
- **AND** 返回 `configured=true` 和版本 `0`
#### Scenario: 使用当前版本更新
- **WHEN** COMPANY 使用当前版本提交新的文化、制度或入职指南
- **THEN** 系统更新记录并递增版本
- **AND** 返回最新内容
#### Scenario: 使用过期版本更新
- **WHEN** COMPANY 使用过期版本提交知识内容
- **THEN** 系统返回 409 `ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE`
- **AND** 不覆盖数据库中的新内容
#### Scenario: 非法内容
- **WHEN** 请求字段超出最大长度或包含不受支持的格式
- **THEN** 系统返回 400 `BAD_REQUEST`
- **AND** 不写入知识记录
### Requirement: 公司知识权限与所有权隔离
学生读取接口 MUST 仅允许 STUDENT;企业配置接口 MUST 仅允许 COMPANY 且只操作当前用户拥有的企业。任何跨企业读取配置或修改操作 MUST 被拒绝。
#### Scenario: 企业访问本企业配置
- **WHEN** COMPANY 读取或更新自己的知识记录
- **THEN** 系统允许操作
#### Scenario: 非企业访问企业配置
- **WHEN** STUDENT、ADMIN 或未登录用户访问企业知识配置接口
- **THEN** 系统返回 401 或 403
- **AND** 不读取或修改企业配置
#### Scenario: 不存在的企业
- **WHEN** STUDENT 请求不存在的公司知识
- **THEN** 系统返回 404 `COMPANY_NOT_FOUND`
### Requirement: 不改变既有评价与企业接口语义
本变更 MUST 只读复用既有企业、岗位、评价和维度评分数据,不得修改既有表结构、响应字段或权限语义。
#### Scenario: 既有评价接口回归
- **WHEN** 执行已有评价与企业模块测试
- **THEN** 所有既有测试保持通过
- **AND** OpenAPI 中既有路径的请求与响应语义不变

151
changes/n04-onboard/specs/onboarding-checklist/spec.md

@ -0,0 +1,151 @@
# Onboarding Checklist
## ADDED Requirements
### Requirement: 学生读取有效入职清单
系统 SHALL 允许已登录 STUDENT 按 `companyId` 读取由通用清单与企业活动自定义项组成的有效入职清单。响应 MUST 包含企业、当前学生完成状态、完成数量、总数量、完成率、是否是企业自定义配置,以及按 `sortOrder`、`id` 稳定排序的条目。
#### Scenario: 企业未配置自定义项
- **WHEN** STUDENT 请求存在企业的清单,且该企业没有活动自定义项
- **THEN** 系统返回全部活动通用清单项
- **AND** `customized` 为 `false`
- **AND** 每个条目返回标准来源、分类、标题、说明、必填标记和当前学生的完成状态
#### Scenario: 企业已配置自定义项
- **WHEN** STUDENT 请求存在企业的清单,且该企业存在活动自定义项
- **THEN** 系统返回活动通用项与企业自定义项
- **AND** 通用项与企业项使用稳定排序,响应 `customized` 为 `true`
- **AND** 每个条目的 `itemId` 和 `source` 足以用于后续完成状态更新
#### Scenario: 企业不存在
- **WHEN** STUDENT 请求不存在的 `companyId`
- **THEN** 系统返回 404 与既有 `COMPANY_NOT_FOUND` 错误码
- **AND** 不创建完成状态行
### Requirement: 学生更新个人清单完成状态
系统 SHALL 允许 STUDENT 通过 `PUT /onboarding/companies/{companyId}/checklist/items/{itemId}/completion` 将本人对指定有效条目的状态设置为完成或未完成。请求 MUST 使用绝对状态语义,重复提交同一状态 MUST 不创建重复记录。响应 MUST 返回条目状态、版本和更新后的完成率。
#### Scenario: 首次标记完成
- **WHEN** STUDENT 对本人可访问的条目提交 `completed=true`
- **THEN** 系统创建该学生与该条目的完成记录
- **AND** 设置 `completedAt`
- **AND** 返回更新后的完成数量、总数量和完成率
#### Scenario: 重复设置相同状态
- **WHEN** STUDENT 对同一条目重复提交相同 `completed` 值
- **THEN** 系统返回幂等成功结果
- **AND** 数据库仍只有一条学生与条目完成记录
- **AND** 不改变首次完成后的业务结果
#### Scenario: 取消完成
- **WHEN** STUDENT 对已完成条目的提交 `completed=false`
- **THEN** 系统将记录置为未完成
- **AND** 清除 `completedAt`
- **AND** 返回更新后的完成率
#### Scenario: 条目不属于该企业
- **WHEN** STUDENT 使用属于另一企业自定义项的 `itemId` 调用更新接口
- **THEN** 系统返回 404 `ONBOARDING_CHECKLIST_ITEM_NOT_FOUND`
- **AND** 不允许创建或修改完成记录
### Requirement: 企业创建自定义清单项
系统 SHALL 允许 COMPANY 为本企业创建自定义清单项。请求 MUST 校验标题、说明、分类、排序值和必填标记。请求携带 `idempotencyKey` 时,同一企业、同一 key 且请求体相同 MUST 返回原条目;请求体不同 MUST 返回 409 `ONBOARDING_IDEMPOTENCY_CONFLICT`。
#### Scenario: 创建成功
- **WHEN** COMPANY 提交合法的标题、分类和可选说明
- **THEN** 系统创建仅属于该企业的活动条目
- **AND** 返回条目 ID、来源 `COMPANY`、版本、排序值和必填标记
#### Scenario: 幂等重放
- **WHEN** COMPANY 使用相同 `idempotencyKey` 和相同请求体重复创建
- **THEN** 系统返回第一次创建的条目
- **AND** 数据库不产生第二条记录
#### Scenario: 幂等键内容冲突
- **WHEN** COMPANY 重复使用同一 `idempotencyKey`,但标题、分类、说明、排序或必填标记不同
- **THEN** 系统返回 409 `ONBOARDING_IDEMPOTENCY_CONFLICT`
#### Scenario: 超出企业条目上限
- **WHEN** COMPANY 已拥有 50 条活动自定义项并继续创建
- **THEN** 系统返回 409 `ONBOARDING_ITEM_LIMIT_EXCEEDED`
- **AND** 不写入第 51 条活动条目
### Requirement: 企业查询和更新自定义清单项
系统 SHALL 允许 COMPANY 查询本企业全部自定义项,并通过带版本号的更新请求修改标题、说明、分类、排序、必填标记和活动状态。版本冲突 MUST 返回 409 `ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE`,不得静默覆盖。
#### Scenario: 查询本企业条目
- **WHEN** COMPANY 请求本企业自定义条目列表
- **THEN** 系统返回该企业条目,包括活动与软删除条目及当前版本
- **AND** 不返回其他企业的条目
#### Scenario: 正常更新
- **WHEN** COMPANY 使用当前 `version` 更新本企业条目
- **THEN** 系统保存修改并递增版本
- **AND** 返回更新后的条目
#### Scenario: 版本过期
- **WHEN** COMPANY 使用过期 `version` 更新条目
- **THEN** 系统返回 409 `ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE`
- **AND** 不覆盖数据库中较新的值
### Requirement: 企业软删除自定义清单项
系统 SHALL 允许 COMPANY 通过带当前 `version` 的删除请求软删除本企业条目。软删除 MUST 只将条目标记为非活动,不物理删除条目或学生历史完成记录。重复删除已非活动条目 MUST 返回幂等成功结果。
#### Scenario: 首次删除
- **WHEN** COMPANY 使用当前版本删除本企业活动条目
- **THEN** 系统将条目 `active` 置为 `false`
- **AND** 该条目不再出现在学生有效清单中
- **AND** 既有完成记录保留
#### Scenario: 重复删除
- **WHEN** COMPANY 再次删除已软删除条目
- **THEN** 系统返回成功
- **AND** 不产生第二次数据变更
#### Scenario: 删除其他企业条目
- **WHEN** COMPANY 删除不属于本企业的条目
- **THEN** 系统返回 404 `ONBOARDING_CHECKLIST_ITEM_NOT_FOUND`
- **AND** 目标条目保持原状态
### Requirement: 清单权限和数据最小化
学生接口 MUST 仅允许 STUDENT;企业配置接口 MUST 仅允许 COMPANY 且操作绑定当前用户拥有的企业。所有响应 MUST 使用白名单 DTO,不返回用户密码、Token、IP、User-Agent、评价审核原因或其他企业内部的非必要字段。
#### Scenario: 非学生访问学生清单
- **WHEN** COMPANY、ADMIN 或未登录用户访问学生清单接口
- **THEN** 系统返回 401 或 403
- **AND** 不返回清单或完成状态
#### Scenario: 非企业访问企业配置
- **WHEN** STUDENT、ADMIN 或未登录用户访问企业配置接口
- **THEN** 系统返回 401 或 403
- **AND** 不创建或修改企业条目
#### Scenario: 企业无绑定资料
- **WHEN** COMPANY 用户尚未创建企业资料却调用企业配置接口
- **THEN** 系统返回 404 `COMPANY_NOT_FOUND`

47
changes/n04-onboard/tasks.md

@ -0,0 +1,47 @@
# n04-onboard 实施任务
## Delivery / Proof Map
| Requirement Area | Tasks | Evidence |
|---|---|---|
| 通用/企业清单数据模型与种子 | 1, 2 | 迁移测试、Repository 集成测试 |
| 学生有效清单与完成状态 | 2, 3, 6 | Service 单元测试、MockMvc、持久化测试 |
| 企业自定义项 CRUD、幂等、并发 | 2, 4, 6 | Service 单元测试、MockMvc、Repository 集成测试 |
| 公司知识配置与基础信息回退 | 1, 2, 5 | Service 单元测试、MockMvc、持久化测试 |
| 公开评价摘要与小样本保护 | 2, 5 | 聚合 Service 测试、评价边界测试 |
| 权限、白名单响应和错误码 | 6, 7 | 未登录、角色、跨企业、脱敏断言 |
| OpenAPI、前端联调和文档 | 8 | `python docs/verify_openapi.py`、文档检查 |
| 全量回归和可发布证明 | 8 | `mvn clean verify`、专项测试汇总 |
## Tasks
- [ ] 1. 新增 V38 建表迁移与 V39 通用清单种子,路径为 `mydreamjob-server/src/main/resources/db/migration/`。可观察结果:MySQL/H2 均可创建 `onboarding_checklist_items`、`student_onboarding_progress`、`company_knowledge`,主外键、唯一键和索引存在;通用清单按分类和稳定排序写入。证据:`mvn -Dtest=OnboardingMigrationTest,OnboardingSeedDataIntegrationTest test`。依赖:无。
- [ ] 2. 新增 `com.mydreamjob.onboarding` 领域实体与 Repository,路径为 `mydreamjob-server/src/main/java/com/mydreamjob/onboarding/`。可观察结果:可查询某企业的活动通用+自定义项、批量查询当前学生完成记录、按企业查询知识记录,并为评价聚合提供跨岗位只读查询。证据:`mvn -Dtest=OnboardingRepositoryIntegrationTest test`。依赖:1。
- [ ] 3. 实现学生有效清单查询与完成状态更新服务及接口,路径为 `OnboardingChecklistService.java`、`OnboardingChecklistController.java` 和对应 DTO。可观察结果:无企业自定义项时返回通用清单,有自定义项时合并排序;PUT 支持完成/取消完成、重复同状态幂等、完成率与完成时间正确。证据:`mvn -Dtest=OnboardingChecklistServiceTest,OnboardingChecklistControllerTest test`。依赖:2。
- [ ] 4. 实现企业自定义清单项查询、创建、更新和软删除,路径为 `OnboardingChecklistService.java`、`OnboardingChecklistController.java` 和对应 DTO。可观察结果:仅企业本人可操作;创建支持 `idempotencyKey` 重放与请求体哈希冲突;更新/删除使用版本号;软删除后条目从学生有效清单消失但历史完成记录保留。证据:`mvn -Dtest=OnboardingCompanyChecklistServiceTest,OnboardingCompanyChecklistControllerTest test`。依赖:1, 2。
- [ ] 5. 实现企业知识内容 Upsert 和学生公司知识聚合,路径为 `CompanyKnowledgeService.java`、`CompanyKnowledgeController.java` 和对应 DTO。可观察结果:企业读取无记录时返回 `configured=false`;学生读取无知识时返回 `BASIC_ONLY`;公开评价少于 3 条不返回均值;达到 3 条时返回平均分、分布、标签和八维摘要。证据:`mvn -Dtest=CompanyKnowledgeServiceTest,CompanyKnowledgeControllerTest test`。依赖:2。
- [ ] 6. 接入角色、所有权、错误码、幂等和乐观锁,路径为 `SecurityConfig.java`、`ErrorCode.java`、`OnboardingChecklistService.java`、`CompanyKnowledgeService.java`。可观察结果:`/onboarding/**` 仅认证访问;STUDENT/COMPANY 角色和公司所有权严格隔离;9701-9706 在目标冲突场景返回 409/404;响应 DTO 不包含内部身份或审核字段。证据:`mvn -Dtest=OnboardingAuthorizationIntegrationTest,OnboardingConcurrencyIntegrationTest,OnboardingPrivacyTest test`。依赖:3, 4, 5。
- [ ] 7. 补齐模块测试,路径为 `mydreamjob-server/src/test/java/com/mydreamjob/onboarding/`。可观察结果:Service、MockMvc、持久化、迁移、种子、权限、幂等、并发、回退、小样本和端到端场景全部覆盖;测试断言响应状态、错误码、数据库行数和排序。证据:`mvn -Dtest=Onboarding*Test test`。依赖:3, 4, 5, 6。
- [ ] 8. 更新 OpenAPI、接口文档、前端联调说明和路线图,路径为 `docs/openapi-rc.json`、`docs/onboarding-api.md`、`docs/frontend-integration.md`、`docs/backend-development-roadmap.md`、`README.md`。可观察结果:9 个路径及 DTO 与运行时一致;前端文档给出请求示例、错误码、版本和幂等说明;路线图将本模块标记为实现完成/待验收。证据:`python docs/verify_openapi.py`,随后执行 `mvn clean verify`。依赖:3, 4, 5, 6, 7。
## Requirement-to-Task Coverage
| Spec Requirement | Covered By |
|---|---|
| 学生读取有效入职清单 | 1, 2, 3 |
| 学生更新个人清单完成状态 | 2, 3, 6, 7 |
| 企业创建自定义清单项 | 1, 2, 4, 6, 7 |
| 企业查询和更新自定义清单项 | 2, 4, 6, 7 |
| 企业软删除自定义清单项 | 2, 4, 6, 7 |
| 清单权限和数据最小化 | 3, 4, 6, 7 |
| 学生读取公司知识介绍 | 1, 2, 5, 7 |
| 公司知识评价摘要与小样本保护 | 2, 5, 7 |
| 企业读取本企业知识配置 | 1, 2, 5, 7 |
| 企业 Upsert 公司知识内容 | 1, 2, 5, 6, 7 |
| 公司知识权限与所有权隔离 | 5, 6, 7 |
| 不改变既有评价与企业接口语义 | 7, 8 |
## Completion Gate
- 所有任务完成且专项测试、全量测试、OpenAPI 校验通过后才能进入 release-archivist。
- 不允许降低既有接口语义、删除业务数据或跳过迁移验证。

25
changes/n04-onboard/verification-report.md

@ -0,0 +1,25 @@
# N04 Verification Report
验证时间:2026-09-17T01:53:38Z
## Outcome
通过并带正式豁免。N04 范围内验证通过;仓库全量测试中的既有非 N04 失败已由用户正式豁免;真实 MySQL/Docker 验收保留为发布前门禁。
## Passed
- N04 专项测试:96/96。
- 并发边界测试:PASS。
- 随机顺序端到端测试:7/7。
- OpenAPI 静态校验:PASS,135 paths / 172 operations,其中 N04 6 paths / 9 operations。
- 可执行 JAR 打包:PASS。
## Blocked / Conditional
- mvn clean verify:1492 tests,2 failures,9 errors;失败均在 N04 范围外。
- 真实 MySQL 8.4 / Docker 黑盒验收:未执行。
- 正式豁免已记录;归档确认仍待 DP-7。
## Required Approval
用户已批准正式豁免。允许进入 delta spec 合并和 DP-7;真实 MySQL/Docker 验收仍需在发布前完成。

30
docs/backend-development-roadmap.md

@ -26,11 +26,11 @@
| 项目 | 当前状态 | | 项目 | 当前状态 |
|---|---| |---|---|
| OpenAPI | **126 paths、160 operations** | | OpenAPI | **135 paths、172 operations** |
| Flyway | **V1–V33** 已存在(V34 为纯接口扩展,不含 Flyway 迁移) | | Flyway | **V1–V39** 已存在(V34 为纯接口扩展;N04 新增 V38–V39) |
| 全量测试 | **1275/1275 通过**(本次 `mvn clean verify` 实际数量) | | 全量测试 | **1492 个执行:1481 通过、2 失败、9 错误**;失败集中在既有 CareerProgress、JobReviewDimension、LocalResumeStorage 测试,与 N04 文档变更无关 |
| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 | | 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合,以及 N04 入职清单与公司知识介绍;另含认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
| 当前结论 | 已达到已实现范围的 Release Candidate;V33 Report/Block 已完成真实 MySQL/Docker 黑盒验收并正式冻结;V34 统一求职进度聚合已完成实现并通过真实 MySQL/Docker 黑盒验收(PASS=99 / FAIL=0 / SKIP=0),待建立正式 Git 检查点 | | 当前结论 | N04 入职清单与公司知识介绍已完成代码、N04 专项 96/96 测试和 OpenAPI/文档更新;全量 `mvn clean verify` 仍受既有非 N04 测试失败阻塞,待真实 MySQL/Docker 黑盒验收 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 | | 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 2.0 模块状态分类 ### 2.0 模块状态分类
@ -38,9 +38,9 @@
| 类别 | 模块 | | 类别 | 模块 |
|---|---| |---|---|
| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合 | | 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合 |
| 已完成、待真实 MySQL/Docker 验收 | — | | 已完成、待真实 MySQL/Docker 验收 | N04 入职清单与公司知识介绍(V38/V39,9 个接口;专项测试 96/96,全量回归待清除既有失败) |
| 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐 | | 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | | 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、薪资计算器、诚信积分、消息撤回 |
### 2.1 已确认的产品决策 ### 2.1 已确认的产品决策
@ -76,7 +76,9 @@
- 简历版本管理和简历导出; - 简历版本管理和简历导出;
- 求职者评分卡; - 求职者评分卡;
- Candidate Scoring Engine; - Candidate Scoring Engine;
- 评分申诉、审核和回滚。 - 评分申诉、审核和回滚;
- 入职清单:通用项与企业自定义项合并、个人完成状态和完成率;
- 公司知识介绍:企业资料、企业知识内容和公开评价摘要。
### 3.2 企业端 ### 3.2 企业端
@ -89,7 +91,9 @@
- Offer 管理; - Offer 管理;
- 面试管理; - 面试管理;
- 评价审核基础能力; - 评价审核基础能力;
- 双向匹配、通知和直聊。 - 双向匹配、通知和直聊;
- 本企业入职清单自定义项管理;
- 本企业公司知识内容维护。
## 4. Figma 与后端覆盖结论 ## 4. Figma 与后端覆盖结论
@ -114,6 +118,7 @@
| 企业候选人看板 | `/company/candidates`、`/company/candidates/{id}` | 已完成 | | 企业候选人看板 | `/company/candidates`、`/company/candidates/{id}` | 已完成 |
| 企业评分看板 | `/company/scoring/dashboard/*` | 已完成 | | 企业评分看板 | `/company/scoring/dashboard/*` | 已完成 |
| 企业认证 | `/companies/me/verification`、`/admin/companies/verifications*` | 已完成 | | 企业认证 | `/companies/me/verification`、`/admin/companies/verifications*` | 已完成 |
| 入职清单与公司知识介绍 | `/onboarding/**`(9 个接口) | **实现完成,待真实 MySQL/Docker 验收** |
### 4.2 部分完成或口径不一致 ### 4.2 部分完成或口径不一致
@ -136,13 +141,12 @@
- AI 简历诊断和优化; - AI 简历诊断和优化;
- AI 模拟面试和面试题预测; - AI 模拟面试和面试题预测;
- 行业技能词云; - 行业技能词云;
- 入职准备清单和公司知识介绍;
- 薪资计算器和薪资参考; - 薪资计算器和薪资参考;
- 公平性检测; - 公平性检测;
- SHAP/规则解释; - SHAP/规则解释;
- ADMIN 防刷审计和规则配置; - ADMIN 防刷审计和规则配置;
- 诚信积分和激励。 - 诚信积分和激励。
- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)。 - 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)、入职清单和公司知识介绍(N04)。
## 5. 已有代码的优先修复项 ## 5. 已有代码的优先修复项
@ -440,8 +444,6 @@ scoring_risk_actions
- AI 模拟面试; - AI 模拟面试;
- 面试题预测; - 面试题预测;
- 行业技能词云; - 行业技能词云;
- 入职准备清单;
- 公司知识介绍;
- 简历查重; - 简历查重;
- 诚信积分; - 诚信积分;
- 消息撤回。 - 消息撤回。
@ -464,7 +466,7 @@ scoring_risk_actions
| P1 | ADMIN 防刷审计 | | P1 | ADMIN 防刷审计 |
| P2 | SHAP 高级解释 | | P2 | SHAP 高级解释 |
| P2 | AI 简历、AI 模拟面试 | | P2 | AI 简历、AI 模拟面试 |
| P2 | 技能词云、入职清单、诚信积分 | | P2 | 技能词云、入职清单(N04 实现完成,待验收)、诚信积分 |
## 8. 每个模块的统一交付要求 ## 8. 每个模块的统一交付要求

127
docs/frontend-integration.md

@ -125,8 +125,8 @@ Authorization: Bearer <accessToken>
| 401 | 401(安全过滤器) | 未登录 / 无 token | | 401 | 401(安全过滤器) | 未登录 / 无 token |
| 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) | | 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) |
| 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 | | 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 |
| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501 | 资源不存在 | | 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501, 9701 | 资源不存在 |
| 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103 | 重复 / 非法状态流转 | | 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103, 9702-9706 | 重复 / 非法状态流转 / 版本或幂等冲突 |
| 500 | 1002, 9001 | 服务器内部错误 | | 500 | 1002, 9001 | 服务器内部错误 |
**前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。 **前端策略**:优先判断 HTTP 状态码,再读取 `body.code` 显示业务信息。
@ -734,3 +734,126 @@ GET /profile/me/career-progress?page=0&size=20&status=INTERVIEW # 仅 STUDENT
- TOP10 不是独立接口字段;统一使用 `data.items.slice(0, min(10, data.total))`。 - TOP10 不是独立接口字段;统一使用 `data.items.slice(0, min(10, data.total))`。
- 匹配度公式为 `round(matchedCount * 100 / min(10, total))`;范围 15 条、命中 5 条时为 `33`。 - 匹配度公式为 `round(matchedCount * 100 / min(10, total))`;范围 15 条、命中 5 条时为 `33`。
- 学习路径不返回课程链接、AI 文本、学习时长或进度记录。 - 学习路径不返回课程链接、AI 文本、学习时长或进度记录。
## 22. 入职清单与公司知识介绍联调
完整接口、字段和错误示例见 [onboarding-api.md](onboarding-api.md)。Base URL 与认证方式沿用本文第 1、5 节:
```text
http://127.0.0.1:8080/api/v1
Authorization: Bearer <accessToken>
```
### 22.1 接口与角色
| Method | Path | 角色 | 关键交互 |
|---|---|---|---|
| `GET` | `/onboarding/companies/{companyId}/checklist` | STUDENT | 返回通用项与企业活动自定义项;无自定义项时回退通用清单。 |
| `PUT` | `/onboarding/companies/{companyId}/checklist/items/{itemId}/completion` | STUDENT | 绝对状态设置完成/未完成;状态变化时使用 `progressVersion`。 |
| `GET` | `/onboarding/companies/{companyId}/knowledge` | STUDENT | 企业基础资料 + 企业知识 + 公开评价摘要。 |
| `GET` | `/onboarding/company/checklist-items` | COMPANY | 查询本企业活动与软删除自定义项。 |
| `POST` | `/onboarding/company/checklist-items` | COMPANY | 创建本企业自定义项,支持 `idempotencyKey`。 |
| `PUT` | `/onboarding/company/checklist-items/{itemId}` | COMPANY | 使用 `version` 乐观锁更新。 |
| `DELETE` | `/onboarding/company/checklist-items/{itemId}?version={version}` | COMPANY | 软删除;重复删除幂等。 |
| `GET` | `/onboarding/company/knowledge` | COMPANY | 查询本企业知识配置;未配置返回 `configured=false`。 |
| `PUT` | `/onboarding/company/knowledge` | COMPANY | 创建或更新本企业知识配置。 |
### 22.2 请求示例
学生更新完成状态:
```http
PUT /api/v1/onboarding/companies/10/checklist/items/501/completion
Authorization: Bearer <studentAccessToken>
Content-Type: application/json
{"completed": true, "version": 0}
```
企业创建自定义项:
```http
POST /api/v1/onboarding/company/checklist-items
Authorization: Bearer <companyAccessToken>
Content-Type: application/json
{
"title": "领取门禁卡",
"description": "到行政前台领取门禁卡并完成权限激活。",
"category": "ADMIN",
"sortOrder": 1000,
"required": false,
"idempotencyKey": "company-10-access-card-20260917"
}
```
企业更新知识:
```http
PUT /api/v1/onboarding/company/knowledge
Authorization: Bearer <companyAccessToken>
Content-Type: application/json
{
"culture": "以用户价值为中心,保持透明和长期主义。",
"workPolicies": "弹性上班;每周一、三线下办公。",
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。",
"version": 1
}
```
### 22.3 响应与前端状态处理
成功响应统一读取 `data`。学生有效清单关键字段:
```json
{
"companyId": 10,
"companyName": "示例科技",
"customized": true,
"totalItems": 13,
"completedItems": 1,
"completionRate": 7.7,
"items": [
{
"itemId": 101,
"source": "STANDARD",
"title": "准备身份证件",
"category": "DOCUMENT",
"required": true,
"sortOrder": 100,
"completed": true,
"completedAt": "2026-09-17T09:00:00",
"progressVersion": 1
}
]
}
```
公司知识回退:
- `knowledgeState=CONFIGURED`:展示 `knowledge`。
- `knowledgeState=BASIC_ONLY`:只展示 `company` 基础资料,不展示知识字段。
- `reviewSummary.state=EMPTY/INSUFFICIENT`:只展示评价数量;不要展示均值、分布和八维汇总。
- `reviewSummary.state=SUFFICIENT`:可以展示平均分、评分分布、热门标签和八维汇总。
错误处理:
| HTTP/code | 前端动作 |
|---|---|
| `401/401` | 清理 Token,跳转登录;不要自动重试。 |
| `403/2002` | 当前角色或企业不匹配,不重试并提示切换正确账号。 |
| `404/5001` | 企业不存在或企业账号未绑定企业,停止当前页面数据加载。 |
| `404/9701` | 条目已删除、跨企业或已不在有效清单;重新加载清单。 |
| `409/9702` | 企业条目版本过期;重新查询 `/company/checklist-items` 后让用户确认再提交。 |
| `409/9703` | 活动自定义项达到 50 条;禁用新增,提示先软删除旧项。 |
| `409/9704` | 幂等键已用于不同请求体;更换 key 或展示原条目,不可盲目重试旧请求。 |
| `409/9705` | 学生进度版本过期;重新查询有效清单,使用最新 `progressVersion`。 |
| `409/9706` | 企业知识版本过期;重新查询配置,使用最新 `version`。 |
幂等与软删除注意:
- 创建重试必须复用原 `idempotencyKey` 和完全相同的标题、说明、分类、排序、必填标记。
- DELETE 只在首次删除时要求当前版本;重复删除虽幂等,但客户端仍应携带 `version` 查询参数。
- 更新/删除成功后必须用响应中的新 `version` 覆盖本地状态,不能继续使用旧值。

434
docs/onboarding-api.md

@ -0,0 +1,434 @@
# 入职清单与公司知识介绍 API(N04)
> 状态:后端实现完成,待真实 MySQL 8.4 / Docker 黑盒验收。
>
> 数据库迁移:`V38__onboarding_checklist_and_company_knowledge.sql`、`V39__onboarding_standard_checklist_seed.sql`。
## 1. Base URL 与认证
本地 Base URL:
```text
http://127.0.0.1:8080/api/v1
```
所有 `/onboarding/**` 接口都要求登录,并携带:
```http
Authorization: Bearer <accessToken>
Content-Type: application/json
X-Request-Id: <optional-trace-id>
```
细粒度角色和所有权在 Service 层校验。除 401 外,接口统一返回 `ApiResponse`:
```json
{
"code": 200,
"message": "ok",
"data": {},
"requestId": "req-...",
"timestamp": "2026-09-17T01:30:00Z"
}
```
## 2. 接口与权限总览
| # | Method | Path | 角色 | 说明 |
|---|---|---|---|---|
| 1 | GET | `/onboarding/companies/{companyId}/checklist` | STUDENT | 读取有效清单、完成状态和完成率 |
| 2 | PUT | `/onboarding/companies/{companyId}/checklist/items/{itemId}/completion` | STUDENT | 设置本人完成或未完成 |
| 3 | GET | `/onboarding/companies/{companyId}/knowledge` | STUDENT | 读取企业资料、知识内容和公开评价摘要 |
| 4 | GET | `/onboarding/company/checklist-items` | COMPANY | 查询本企业活动与软删除自定义项 |
| 5 | POST | `/onboarding/company/checklist-items` | COMPANY | 创建本企业自定义项 |
| 6 | PUT | `/onboarding/company/checklist-items/{itemId}` | COMPANY | 按版本更新本企业条目 |
| 7 | DELETE | `/onboarding/company/checklist-items/{itemId}?version={version}` | COMPANY | 按版本软删除本企业条目 |
| 8 | GET | `/onboarding/company/knowledge` | COMPANY | 查询本企业知识配置 |
| 9 | PUT | `/onboarding/company/knowledge` | COMPANY | 创建或更新本企业知识配置 |
权限规则:
- STUDENT 接口只允许拥有 STUDENT 角色的账号访问。
- COMPANY 接口只允许拥有 COMPANY 角色的账号访问。
- COMPANY 接口不接受 `companyId`;服务端从 JWT 用户解析其所属企业,避免跨企业写入。
- ADMIN 没有 onboarding 专属管理接口;调用 STUDENT/COMPANY 接口时按非目标角色返回 `403/2002`。
- 学生访问企业清单或知识不要求先有 Offer 或投递记录,但目标企业必须存在。
- 企业条目不存在或属于其他企业时统一返回 `404/9701`,不泄露其他企业数据。
## 3. 入职清单语义
### 3.1 通用项与自定义项合并
有效清单由以下两部分组成:
1. 所有活动通用项 `source=STANDARD`;V39 固定写入 12 条。
2. 当前企业所有活动自定义项 `source=COMPANY`。
合并结果按 `sortOrder ASC, id ASC` 稳定排序。企业没有活动自定义项时,`customized=false`,仍返回全部通用项。企业存在活动自定义项时,`customized=true`,返回通用项与企业项。
`category` 固定为:
```text
DOCUMENT | ADMIN | EQUIPMENT | TRAINING | LOGISTICS | OTHER
证件材料 | 行政手续 | 办公设备 | 培训学习 | 到岗安排 | 其他
```
### 3.2 学生读取有效清单
```http
GET /api/v1/onboarding/companies/10/checklist
Authorization: Bearer <studentAccessToken>
```
响应:
```json
{
"code": 200,
"message": "ok",
"data": {
"companyId": 10,
"companyName": "示例科技",
"customized": true,
"totalItems": 13,
"completedItems": 1,
"completionRate": 7.7,
"items": [
{
"itemId": 101,
"source": "STANDARD",
"itemKey": "standard-id-card",
"title": "准备身份证件",
"description": "准备身份证原件或有效身份证明,用于入职登记。",
"category": "DOCUMENT",
"required": true,
"sortOrder": 100,
"active": true,
"completed": true,
"completedAt": "2026-09-17T09:00:00",
"progressVersion": 1
},
{
"itemId": 501,
"source": "COMPANY",
"itemKey": "company-10-8f1c",
"title": "领取门禁卡",
"description": "到行政前台领取门禁卡并完成权限激活。",
"category": "ADMIN",
"required": false,
"sortOrder": 1000,
"active": true,
"completed": false,
"completedAt": null,
"progressVersion": 0
}
]
},
"requestId": "req-onboarding-checklist",
"timestamp": "2026-09-17T01:30:00Z"
}
```
`completionRate = round(completedItems * 100 / totalItems, 1)`;总数为 0 时返回 `0.0`。完成状态只属于当前 STUDENT。
### 3.3 更新完成状态
请求使用绝对状态,而不是切换:
```http
PUT /api/v1/onboarding/companies/10/checklist/items/501/completion
Authorization: Bearer <studentAccessToken>
{"completed": true, "version": 0}
```
响应:
```json
{
"code": 200,
"message": "ok",
"data": {
"itemId": 501,
"completed": true,
"completedAt": "2026-09-17T01:31:00Z",
"progressVersion": 1,
"totalItems": 13,
"completedItems": 2,
"completionRate": 15.4
},
"requestId": "req-onboarding-progress",
"timestamp": "2026-09-17T01:31:00Z"
}
```
版本规则:
- 首次保存某条目时可省略 `version` 或传 `0`。
- 已完成状态变化时必须传当前 `progressVersion`;过期返回 `409/9705`。
- 重复提交相同 `completed` 值保持幂等,不新增记录、不递增版本。
- 首次提交 `completed=false` 不创建记录。
- `completed=false` 会清除 `completedAt`。
- 一名学生与一个清单项最多一条进度记录。
### 3.4 企业创建自定义项
```http
POST /api/v1/onboarding/company/checklist-items
Authorization: Bearer <companyAccessToken>
{
"title": "领取门禁卡",
"description": "到行政前台领取门禁卡并完成权限激活。",
"category": "ADMIN",
"sortOrder": 1000,
"required": false,
"idempotencyKey": "company-10-access-card-20260917"
}
```
响应 `data`:
```json
{
"itemId": 501,
"source": "COMPANY",
"itemKey": "company-10-8f1c",
"title": "领取门禁卡",
"description": "到行政前台领取门禁卡并完成权限激活。",
"category": "ADMIN",
"required": false,
"sortOrder": 1000,
"active": true,
"version": 0
}
```
约束:
- `title`:必填,最多 120 字符。
- `description`:可选,最多 1000 字符。
- `category`:必填,仅允许固定枚举。
- `sortOrder`:可选,默认 `1000`,范围 `0..9999`。
- `required`:可选,默认 `false`。
- `idempotencyKey`:可选,最多 64 字符;服务端会 trim,空串按未提供处理。
- 活动自定义项上限为 50 条;达到上限返回 `409/9703`。
幂等重试:
- 同一企业、同一 `idempotencyKey`、同标题/说明/分类/排序/必填值:返回首次创建的条目。
- 同一企业、同一 key、请求语义不同:返回 `409/9704`。
- 不同企业可使用相同 key,互不冲突。
- 未传 key 时,每次请求创建新条目。
### 3.5 企业查询、更新与软删除
查询本企业全部条目:
```http
GET /api/v1/onboarding/company/checklist-items
Authorization: Bearer <companyAccessToken>
```
响应包含活动与软删除条目,不返回通用项和其他企业项。更新请求:
```http
PUT /api/v1/onboarding/company/checklist-items/501
Authorization: Bearer <companyAccessToken>
{
"title": "领取门禁卡并激活",
"description": "到行政前台领取门禁卡并完成权限激活。",
"category": "ADMIN",
"sortOrder": 900,
"required": true,
"active": true,
"version": 0
}
```
更新使用 `version` 乐观锁,成功递增版本;过期返回 `409/9702`。软删除:
```http
DELETE /api/v1/onboarding/company/checklist-items/501?version=1
Authorization: Bearer <companyAccessToken>
```
首次删除必须使用当前版本,系统只将 `active=false`,不物理删除。重复删除已非活动条目保持幂等成功;接口仍要求携带 `version` 查询参数。
## 4. 公司知识介绍语义
### 4.1 学生读取与回退
```http
GET /api/v1/onboarding/companies/10/knowledge
Authorization: Bearer <studentAccessToken>
```
响应:
```json
{
"code": 200,
"message": "ok",
"data": {
"company": {
"id": 10,
"name": "示例科技",
"logoUrl": "https://cdn.example.com/company-10.png",
"description": "面向企业提供数据基础设施产品。",
"industry": "互联网",
"website": "https://example.com",
"address": "杭州市余杭区",
"verified": true
},
"knowledgeState": "CONFIGURED",
"knowledge": {
"culture": "以用户价值为中心,保持透明和长期主义。",
"workPolicies": "弹性上班;每周一、三线下办公。",
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。",
"updatedAt": "2026-09-17T01:33:00Z"
},
"reviewSummary": {
"state": "SUFFICIENT",
"totalReviews": 4,
"averageRating": 4.5,
"ratingDistribution": {
"rating1": 0,
"rating2": 0,
"rating3": 1,
"rating4": 1,
"rating5": 2
},
"topTags": ["发展空间", "同事友好"],
"dimensionSummary": {
"AUTHENTICITY": {"averageScore": 4.5, "sampleCount": 4, "dataState": "SUFFICIENT"},
"BENEFITS": {"averageScore": 4.3, "sampleCount": 4, "dataState": "SUFFICIENT"},
"SALARY_TRANSPARENCY": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"},
"JD_CLARITY": {"averageScore": 4.6, "sampleCount": 4, "dataState": "SUFFICIENT"},
"GROWTH": {"averageScore": 4.4, "sampleCount": 4, "dataState": "SUFFICIENT"},
"TEAM_ATMOSPHERE": {"averageScore": 4.7, "sampleCount": 4, "dataState": "SUFFICIENT"},
"PROMOTION": {"averageScore": 4.1, "sampleCount": 4, "dataState": "SUFFICIENT"},
"WORK_LIFE_BALANCE": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"}
}
}
},
"requestId": "req-company-overview",
"timestamp": "2026-09-17T01:34:00Z"
}
```
回退与状态:
- 企业无知识记录:仍返回企业基础资料,`knowledgeState=BASIC_ONLY`,`knowledge` 省略。
- 企业有知识记录:`knowledgeState=CONFIGURED`,返回非空知识字段和 `updatedAt`;学生公开读取响应不返回知识配置版本。
- 企业不存在:`404/5001`。
- 响应白名单不包含 `ownerUserId`、用户 ID、手机号、邮箱、评价哈希或审核原因。
评价摘要只聚合企业全部岗位中 `PUBLISHED` 且审核状态为 `AUTO_APPROVED/APPROVED` 的评价:
| `reviewSummary.state` | 条件 | 返回内容 |
|---|---|---|
| `EMPTY` | 0 条 | `state`、`totalReviews=0` |
| `INSUFFICIENT` | 1–2 条 | `state`、真实 `totalReviews`;不返回均值、分布或维度汇总 |
| `SUFFICIENT` | 至少 3 条 | 平均评分、评分分布、热门标签、八维汇总 |
热门标签最多 10 个;八个维度分别返回 `sampleCount` 和 `dataState`。维度的 `dataState` 使用同一 3 条阈值。
### 4.2 企业查询知识配置
```http
GET /api/v1/onboarding/company/knowledge
Authorization: Bearer <companyAccessToken>
```
未配置时:
```json
{
"code": 200,
"message": "ok",
"data": {
"configured": false,
"version": 0
},
"requestId": "req-company-knowledge",
"timestamp": "2026-09-17T01:33:00Z"
}
```
已配置时返回 `configured=true`、非空内容、`updatedAt` 和当前 `version`。空字段按 `@JsonInclude(NON_NULL)` 省略。
### 4.3 企业创建或更新知识配置
```http
PUT /api/v1/onboarding/company/knowledge
Authorization: Bearer <companyAccessToken>
{
"culture": "以用户价值为中心,保持透明和长期主义。",
"workPolicies": "弹性上班;每周一、三线下办公。",
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。",
"version": 1
}
```
规则:
- 至少一个内容字段必须非空。
- `culture` 最多 2000 字符,`workPolicies` 和 `onboardingGuide` 各最多 4000 字符。
- 字段按纯文本处理,会 trim;包含 HTML 标签时返回 `400/1001`。
- 首次创建可以省略 `version`,创建后版本为 `0`。
- 更新已有记录必须提交当前版本;版本过期返回 `409/9706`。
- 已有内容与提交内容相同且未传版本时按幂等读取处理;内容不同但未传版本返回 `400/1001`。
## 5. 错误码
| HTTP | code | 含义 | 前端处理 |
|---|---:|---|---|
| 400 | 1001 | 参数、枚举、长度、HTML、缺少必填字段 | 展示字段错误,不自动重试 |
| 401 | 401 | 未登录或 Token 无效 | 清理登录态并重新登录 |
| 403 | 2002 | 角色不匹配、无所属企业或无权限 | 不重试,切换到正确角色 |
| 404 | 5001 | 企业不存在或 COMPANY 无绑定企业 | 检查 `companyId` 或企业绑定状态 |
| 404 | 9701 | 清单项不存在、跨企业或不属于有效清单 | 刷新清单后重新选择条目 |
| 409 | 9702 | 企业清单项版本过期 | 重新 `GET /company/checklist-items`,使用新版本重试 |
| 409 | 9703 | 活动自定义项达到 50 条上限 | 禁用新增,提示先软删除或复用现有项 |
| 409 | 9704 | 幂等键已用于不同请求体 | 更换幂等键,或确认原请求后读取原条目 |
| 409 | 9705 | 学生完成状态版本过期 | 重新读取有效清单并使用新 `progressVersion` |
| 409 | 9706 | 公司知识版本过期 | 重新读取企业知识配置并使用新 `version` |
401 由安全过滤器返回裸 JSON,没有统一 `ApiResponse.requestId`:
```json
{"code": 401, "message": "not authenticated", "timestamp": ""}
```
其他错误示例:
```json
{
"code": 9704,
"message": "idempotency key conflict: request body differs",
"data": null,
"requestId": "req-9704",
"timestamp": "2026-09-17T01:30:00Z"
}
```
## 6. 前端推荐流程
学生端:
1. 登录后调用 `GET /onboarding/companies/{companyId}/checklist`。
2. 使用响应的 `itemId` 和 `progressVersion` 调用完成状态接口。
3. 收到 `9705` 时重新读取清单,避免覆盖其他设备的新状态。
4. 调用 `GET /onboarding/companies/{companyId}/knowledge` 展示企业介绍与评价摘要。
5. 当 `reviewSummary.state=EMPTY/INSUFFICIENT` 时,不展示平均值、分布和维度均值。
企业端:
1. 登录后调用 `GET /onboarding/company/checklist-items` 获取当前版本。
2. 新增时生成稳定 `idempotencyKey` 并保存到重试请求中。
3. 更新或删除前使用最新 `version`,冲突后重新拉取列表。
4. 调用 `GET /onboarding/company/knowledge` 获取配置版本,再使用 `PUT` 保存。
5. 收到 `9703` 时停止自动新增;收到 `9704` 时不要复用旧请求体。

5722
docs/openapi-rc.json

File diff suppressed because it is too large

119
docs/verify_openapi.py

@ -6,9 +6,55 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 129, f"Expected 129 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
onboarding_paths = [
'/onboarding/companies/{companyId}/checklist',
'/onboarding/companies/{companyId}/checklist/items/{itemId}/completion',
'/onboarding/companies/{companyId}/knowledge',
'/onboarding/company/checklist-items',
'/onboarding/company/checklist-items/{itemId}',
'/onboarding/company/knowledge',
]
for path in onboarding_paths:
assert path in paths, f'Missing onboarding path: {path}'
onboarding_operations = {
('/onboarding/companies/{companyId}/checklist', 'get'): 'getEffectiveChecklist',
('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put'): 'updateCompletion',
('/onboarding/companies/{companyId}/knowledge', 'get'): 'getCompanyKnowledgeOverview',
('/onboarding/company/checklist-items', 'get'): 'listCompanyChecklistItems',
('/onboarding/company/checklist-items', 'post'): 'createCompanyChecklistItem',
('/onboarding/company/checklist-items/{itemId}', 'put'): 'updateCompanyChecklistItem',
('/onboarding/company/checklist-items/{itemId}', 'delete'): 'deleteCompanyChecklistItem',
('/onboarding/company/knowledge', 'get'): 'getCompanyKnowledge',
('/onboarding/company/knowledge', 'put'): 'upsertCompanyKnowledge',
}
http_methods = {'get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'}
for (path, method), operation_id in onboarding_operations.items():
assert method in paths[path], f'Missing {method.upper()} {path}'
assert paths[path][method].get('operationId') == operation_id, f'Wrong operationId for {method.upper()} {path}'
matching_operations = [
(candidate_method, candidate_path)
for candidate_path, path_item in paths.items()
for candidate_method, operation in path_item.items()
if candidate_method in http_methods
and operation.get('operationId') == operation_id
]
assert matching_operations == [(method, path)], (
f'{operation_id} must be globally unique: expected {method.upper()} {path}, got {matching_operations}'
)
assert paths[path][method].get('security') == [{'bearerAuth': []}], (
f'{method.upper()} {path} must require bearerAuth'
)
assert sum(
1
for path_item in paths.values()
for method in path_item
if method in http_methods
) == 172, 'Expected 172 operations after N04 onboarding'
assert len(d['paths']) == 135, f"Expected 135 paths, got {len(d['paths'])}"
required = [ required = [
'/review-weight-templates', '/review-weight-templates',
@ -205,6 +251,77 @@ print(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") print(" Account deletion endpoints present (3 operations)")
onboarding_success_refs = {
('/onboarding/companies/{companyId}/checklist', 'get'): 'ApiResponseEffectiveChecklistResponse',
('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put'): 'ApiResponseProgressUpdateResponse',
('/onboarding/companies/{companyId}/knowledge', 'get'): 'ApiResponseCompanyKnowledgeOverviewResponse',
('/onboarding/company/checklist-items', 'get'): 'ApiResponseListCompanyChecklistItemView',
('/onboarding/company/checklist-items', 'post'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'put'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/checklist-items/{itemId}', 'delete'): 'ApiResponseCompanyChecklistItemView',
('/onboarding/company/knowledge', 'get'): 'ApiResponseCompanyKnowledgeView',
('/onboarding/company/knowledge', 'put'): 'ApiResponseCompanyKnowledgeView',
}
for (path, method), schema_name in onboarding_success_refs.items():
response = paths[path][method]['responses']['200']
ref = response['content']['*/*']['schema']['$ref']
assert ref == f'#/components/schemas/{schema_name}', f'Wrong success schema for {method.upper()} {path}: {ref}'
for status_code in ('401', '403'):
assert status_code in paths[path][method]['responses'], f'Missing {status_code} for {method.upper()} {path}'
unauthorized = paths[path][method]['responses']['401']
assert unauthorized['content']['application/json']['schema']['$ref'] == '#/components/schemas/UnauthorizedResponse'
forbidden = paths[path][method]['responses']['403']
assert forbidden['content']['application/json']['schema']['$ref'] == '#/components/schemas/ApiResponse'
assert forbidden['content']['application/json']['example']['code'] == 2002
onboarding_schema_fields = {
'CompanyChecklistItemCreateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'idempotencyKey'},
'CompanyChecklistItemUpdateRequest': {'title', 'description', 'category', 'sortOrder', 'required', 'active', 'version'},
'CompanyChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'version'},
'EffectiveChecklistResponse': {'companyId', 'companyName', 'customized', 'totalItems', 'completedItems', 'completionRate', 'items'},
'ChecklistItemView': {'itemId', 'source', 'itemKey', 'title', 'description', 'category', 'required', 'sortOrder', 'active', 'completed', 'completedAt', 'progressVersion'},
'ProgressUpdateRequest': {'completed', 'version'},
'ProgressUpdateResponse': {'itemId', 'completed', 'completedAt', 'progressVersion', 'totalItems', 'completedItems', 'completionRate'},
'CompanyKnowledgeUpdateRequest': {'culture', 'workPolicies', 'onboardingGuide', 'version'},
'CompanyKnowledgeView': {'configured', 'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'CompanyKnowledgeOverviewResponse': {'company', 'knowledgeState', 'knowledge', 'reviewSummary'},
'CompanyView': {'id', 'name', 'logoUrl', 'description', 'industry', 'website', 'address', 'verified'},
'KnowledgeView': {'culture', 'workPolicies', 'onboardingGuide', 'updatedAt', 'version'},
'ReviewSummaryView': {'state', 'totalReviews', 'averageRating', 'ratingDistribution', 'topTags', 'dimensionSummary'},
'DimensionSummaryView': {'averageScore', 'sampleCount', 'dataState'},
}
for schema_name, expected_fields in onboarding_schema_fields.items():
schema = schemas.get(schema_name)
assert schema is not None, f'Missing onboarding schema: {schema_name}'
assert expected_fields <= set(schema.get('properties', {})), (
f'Onboarding schema fields missing: {schema_name}'
)
create_request = schemas['CompanyChecklistItemCreateRequest']['properties']
assert create_request['sortOrder'].get('default') == 1000, (
'CompanyChecklistItemCreateRequest.sortOrder default must be 1000'
)
assert create_request['required'].get('default') is False, (
'CompanyChecklistItemCreateRequest.required default must be false'
)
knowledge_success_example = paths['/onboarding/company/knowledge']['put']['responses']['200']['content']['*/*']['example']
assert knowledge_success_example['data'].get('version') == 2, (
'PUT /onboarding/company/knowledge 200 example data.version must be 2'
)
onboarding_error_paths = {
9701: ('/onboarding/company/checklist-items/{itemId}', 'put', '404'),
9702: ('/onboarding/company/checklist-items/{itemId}', 'put', '409'),
9703: ('/onboarding/company/checklist-items', 'post', '409'),
9704: ('/onboarding/company/checklist-items', 'post', '409'),
9705: ('/onboarding/companies/{companyId}/checklist/items/{itemId}/completion', 'put', '409'),
9706: ('/onboarding/company/knowledge', 'put', '409'),
}
for error_code, (path, method, status_code) in onboarding_error_paths.items():
response_text = json.dumps(paths[path][method]['responses'][status_code], ensure_ascii=False)
assert str(error_code) in response_text, f'Missing onboarding error code {error_code} in {method.upper()} {path}'
print(' N04 onboarding endpoints and schemas present (9 operations / 6 paths)')
data = json.dumps(d) data = json.dumps(d)
assert '"" : "#/components/schemas/' not in data, "Found empty ref key" assert '"" : "#/components/schemas/' not in data, "Found empty ref key"
print("No empty ref keys found") print("No empty ref keys found")

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

@ -170,7 +170,15 @@ public enum ErrorCode {
REPORT_REASON_REQUIRED(9607, "report reason is required"), REPORT_REASON_REQUIRED(9607, "report reason is required"),
CANNOT_BLOCK_SELF(9608, "cannot block yourself"), CANNOT_BLOCK_SELF(9608, "cannot block yourself"),
REPORT_RATE_LIMITED(9609, "report rate limited"), REPORT_RATE_LIMITED(9609, "report rate limited"),
BLOCKED_SEND_PROHIBITED(9610, "message cannot be sent"); BLOCKED_SEND_PROHIBITED(9610, "message cannot be sent"),
// 入职清单 97xx
ONBOARDING_CHECKLIST_ITEM_NOT_FOUND(9701, "onboarding checklist item not found"),
ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE(9702, "onboarding checklist item was modified concurrently"),
ONBOARDING_ITEM_LIMIT_EXCEEDED(9703, "onboarding checklist item limit exceeded"),
ONBOARDING_IDEMPOTENCY_CONFLICT(9704, "idempotency key conflict: request body differs"),
ONBOARDING_PROGRESS_CONCURRENT_UPDATE(9705, "onboarding progress was modified concurrently"),
ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE(9706, "company knowledge was modified concurrently");
private final int code; private final int code;
private final String message; private final String message;

10
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java

@ -76,7 +76,8 @@ public class GlobalExceptionHandler {
|| errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode() || errorCode == ErrorCode.FEEDBACK_NOT_FOUND.getCode()
|| errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode() || errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode()
|| errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode() || errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode()
|| errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; || errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode()
|| errorCode == ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND;
// 冲突 → 409 // 冲突 → 409
if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode()
|| errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode()
@ -163,6 +164,13 @@ public class GlobalExceptionHandler {
|| errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode() || errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode()
|| errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode()) || errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode())
return HttpStatus.CONFLICT; return HttpStatus.CONFLICT;
// 入职清单并发、上限与幂等冲突 → 409
if (errorCode == ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE.getCode()
|| errorCode == ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode()
|| errorCode == ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode()
|| errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()
|| errorCode == ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode())
return HttpStatus.CONFLICT;
// 举报限流 → 429 // 举报限流 → 429
if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode())
return HttpStatus.TOO_MANY_REQUESTS; return HttpStatus.TOO_MANY_REQUESTS;

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

@ -126,6 +126,8 @@ public class SecurityConfig {
.requestMatchers("/company/scoring/dashboard/**").authenticated() .requestMatchers("/company/scoring/dashboard/**").authenticated()
// 面试(需登录) // 面试(需登录)
.requestMatchers("/interviews/**").authenticated() .requestMatchers("/interviews/**").authenticated()
// 入职清单与公司知识(需登录;细粒度角色/所有权在 Service 校验)
.requestMatchers("/onboarding/**").authenticated()
// API 信息入口 // API 信息入口
.requestMatchers("/").permitAll() .requestMatchers("/").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()

63
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java

@ -0,0 +1,63 @@
package com.mydreamjob.onboarding;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(
name = "company_knowledge",
uniqueConstraints = @UniqueConstraint(
name = "uk_company_knowledge_company",
columnNames = "company_id"))
@EntityListeners(AuditingEntityListener.class)
public class CompanyKnowledge {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "company_id", nullable = false)
private Long companyId;
@Column(length = 2000)
private String culture;
@Column(name = "work_policies", length = 4000)
private String workPolicies;
@Column(name = "onboarding_guide", length = 4000)
private String onboardingGuide;
@Version
@Column(nullable = false)
@Builder.Default
private Long version = 0L;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

74
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java

@ -0,0 +1,74 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "公司知识介绍", description = "企业维护知识内容,学生读取公司知识聚合")
@RestController
@RequestMapping("/onboarding")
@RequiredArgsConstructor
public class CompanyKnowledgeController {
private final CompanyKnowledgeService companyKnowledgeService;
@Operation(summary = "读取本企业知识配置")
@GetMapping("/company/knowledge")
public ResponseEntity<ApiResponse<CompanyKnowledgeView>> getCompanyKnowledge(
HttpServletRequest request) {
Long userId = resolveUserId();
CompanyKnowledgeView data = companyKnowledgeService.getCompanyKnowledge(userId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "创建或更新本企业知识配置")
@PutMapping("/company/knowledge")
public ResponseEntity<ApiResponse<CompanyKnowledgeView>> upsertCompanyKnowledge(
@Valid @RequestBody CompanyKnowledgeUpdateRequest body,
HttpServletRequest request) {
Long userId = resolveUserId();
CompanyKnowledgeView data =
companyKnowledgeService.upsertCompanyKnowledge(userId, body);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "读取公司知识介绍")
@GetMapping("/companies/{companyId}/knowledge")
public ResponseEntity<ApiResponse<CompanyKnowledgeOverviewResponse>> getCompanyKnowledgeOverview(
@PathVariable Long companyId,
HttpServletRequest request) {
Long userId = resolveUserId();
CompanyKnowledgeOverviewResponse data =
companyKnowledgeService.getCompanyKnowledgeOverview(userId, companyId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
private Long resolveUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(authentication.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

12
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java

@ -0,0 +1,12 @@
package com.mydreamjob.onboarding;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface CompanyKnowledgeRepository extends JpaRepository<CompanyKnowledge, Long> {
Optional<CompanyKnowledge> findByCompanyId(Long companyId);
}

411
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java

@ -0,0 +1,411 @@
package com.mydreamjob.onboarding;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.PersistenceContext;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView;
import com.mydreamjob.onboarding.dto.ReviewSummaryView;
import com.mydreamjob.review.JobReviewDimensionScoreRepository;
import com.mydreamjob.review.JobReviewRepository;
import com.mydreamjob.review.ReviewDimension;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class CompanyKnowledgeService {
private static final String CONFIGURED = "CONFIGURED";
private static final String BASIC_ONLY = "BASIC_ONLY";
private static final String EMPTY = "EMPTY";
private static final String INSUFFICIENT = "INSUFFICIENT";
private static final String SUFFICIENT = "SUFFICIENT";
private static final int MINIMUM_PUBLIC_REVIEWS = 3;
private static final int MAX_TOP_TAGS = 10;
private static final int MAX_CULTURE_LENGTH = 2000;
private static final int MAX_WORK_POLICIES_LENGTH = 4000;
private static final int MAX_ONBOARDING_GUIDE_LENGTH = 4000;
private static final Pattern HTML_TAG_PATTERN =
Pattern.compile("<\\s*/?\\s*[A-Za-z!][^>]*>");
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final CompanyKnowledgeRepository companyKnowledgeRepository;
private final JobReviewRepository jobReviewRepository;
private final JobReviewDimensionScoreRepository dimensionScoreRepository;
@PersistenceContext
private EntityManager entityManager;
@Transactional(readOnly = true)
public CompanyKnowledgeView getCompanyKnowledge(Long userId) {
Company company = requireOwnedCompany(userId);
return companyKnowledgeRepository.findByCompanyId(company.getId())
.map(this::toConfiguredView)
.orElseGet(() -> CompanyKnowledgeView.builder()
.configured(false)
.version(0L)
.build());
}
@Transactional
public CompanyKnowledgeView upsertCompanyKnowledge(
Long userId,
CompanyKnowledgeUpdateRequest request) {
Company company = requireOwnedCompany(userId);
KnowledgeContent content = validateContent(request);
lockCompany(company);
CompanyKnowledge existing = companyKnowledgeRepository.findByCompanyId(company.getId())
.orElse(null);
if (existing == null) {
CompanyKnowledge created = CompanyKnowledge.builder()
.companyId(company.getId())
.culture(content.culture())
.workPolicies(content.workPolicies())
.onboardingGuide(content.onboardingGuide())
.build();
return toConfiguredView(saveKnowledge(created));
}
if (request.getVersion() == null && matchesContent(existing, content)) {
return toConfiguredView(existing);
}
if (request.getVersion() == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (!Objects.equals(request.getVersion(), existing.getVersion())) {
throw new BusinessException(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE);
}
existing.setCulture(content.culture());
existing.setWorkPolicies(content.workPolicies());
existing.setOnboardingGuide(content.onboardingGuide());
return toConfiguredView(saveKnowledge(existing));
}
@Transactional(readOnly = true)
public CompanyKnowledgeOverviewResponse getCompanyKnowledgeOverview(
Long userId,
Long companyId) {
assertStudentRole(userId);
Company company = companyRepository.findById(companyId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
CompanyKnowledge knowledge = companyKnowledgeRepository.findByCompanyId(companyId)
.orElse(null);
return CompanyKnowledgeOverviewResponse.builder()
.company(toCompanyView(company))
.knowledgeState(knowledge == null ? BASIC_ONLY : CONFIGURED)
.knowledge(knowledge == null ? null : toKnowledgeView(knowledge))
.reviewSummary(buildReviewSummary(companyId))
.build();
}
private CompanyKnowledge saveKnowledge(CompanyKnowledge knowledge) {
try {
return companyKnowledgeRepository.saveAndFlush(knowledge);
} catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) {
throw new BusinessException(
ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE, ex);
}
}
private boolean matchesContent(CompanyKnowledge knowledge, KnowledgeContent content) {
return Objects.equals(knowledge.getCulture(), content.culture())
&& Objects.equals(knowledge.getWorkPolicies(), content.workPolicies())
&& Objects.equals(knowledge.getOnboardingGuide(), content.onboardingGuide());
}
private void lockCompany(Company company) {
if (entityManager == null || company == null || company.getId() == null) {
return;
}
entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE);
}
private KnowledgeContent validateContent(CompanyKnowledgeUpdateRequest request) {
if (request == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (request.getVersion() != null && request.getVersion() < 0) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String culture = normalizeText(request.getCulture(), MAX_CULTURE_LENGTH);
String workPolicies = normalizeText(
request.getWorkPolicies(), MAX_WORK_POLICIES_LENGTH);
String onboardingGuide = normalizeText(
request.getOnboardingGuide(), MAX_ONBOARDING_GUIDE_LENGTH);
if (culture == null && workPolicies == null && onboardingGuide == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
return new KnowledgeContent(culture, workPolicies, onboardingGuide);
}
private String normalizeText(String raw, int maxLength) {
if (!StringUtils.hasText(raw)) {
return null;
}
String value = raw.trim();
if (value.length() > maxLength) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (HTML_TAG_PATTERN.matcher(value).find()) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
return value;
}
private ReviewSummaryView buildReviewSummary(Long companyId) {
List<Object[]> countRows =
jobReviewRepository.countAndAveragePublishedRatingByCompanyId(companyId);
long totalReviews = extractCount(countRows);
if (totalReviews == 0) {
return ReviewSummaryView.builder()
.state(EMPTY)
.totalReviews(0)
.build();
}
if (totalReviews < MINIMUM_PUBLIC_REVIEWS) {
return ReviewSummaryView.builder()
.state(INSUFFICIENT)
.totalReviews(Math.toIntExact(totalReviews))
.build();
}
return ReviewSummaryView.builder()
.state(SUFFICIENT)
.totalReviews(Math.toIntExact(totalReviews))
.averageRating(extractAverageRating(countRows))
.ratingDistribution(buildRatingDistribution(companyId))
.topTags(buildTopTags(companyId))
.dimensionSummary(buildDimensionSummary(companyId))
.build();
}
private long extractCount(List<Object[]> rows) {
if (rows == null || rows.isEmpty() || rows.get(0) == null
|| rows.get(0).length == 0 || !(rows.get(0)[0] instanceof Number count)) {
return 0L;
}
return count.longValue();
}
private Double extractAverageRating(List<Object[]> rows) {
if (rows == null || rows.isEmpty() || rows.get(0) == null
|| rows.get(0).length < 2 || !(rows.get(0)[1] instanceof Number average)) {
return 0.0;
}
return BigDecimal.valueOf(average.doubleValue())
.setScale(1, RoundingMode.HALF_UP)
.doubleValue();
}
private Map<String, Integer> buildRatingDistribution(Long companyId) {
Map<String, Integer> distribution = new LinkedHashMap<>();
for (int rating = 1; rating <= 5; rating++) {
distribution.put("rating" + rating, 0);
}
List<Object[]> rows =
jobReviewRepository.findPublishedRatingDistributionByCompanyId(companyId);
if (rows == null) {
return distribution;
}
for (Object[] row : rows) {
if (row == null || row.length < 2
|| !(row[0] instanceof Number rating)
|| !(row[1] instanceof Number count)) {
continue;
}
int ratingValue = rating.intValue();
if (ratingValue >= 1 && ratingValue <= 5) {
distribution.put("rating" + ratingValue, count.intValue());
}
}
return distribution;
}
private List<String> buildTopTags(Long companyId) {
Map<String, Long> counts = new HashMap<>();
List<String> rawTags = jobReviewRepository.findPublishedTagsByCompanyId(companyId);
if (rawTags != null) {
for (String rawTag : rawTags) {
for (String tag : parseTags(rawTag)) {
counts.merge(tag, 1L, Long::sum);
}
}
}
return counts.entrySet().stream()
.sorted((left, right) -> {
int byCount = Long.compare(right.getValue(), left.getValue());
return byCount != 0 ? byCount : left.getKey().compareTo(right.getKey());
})
.limit(MAX_TOP_TAGS)
.map(Map.Entry::getKey)
.toList();
}
private List<String> parseTags(String raw) {
if (!StringUtils.hasText(raw)) {
return List.of();
}
Object parsed = parseJson(raw);
for (int depth = 0; depth < 2 && parsed instanceof String nested; depth++) {
parsed = parseJson(nested);
}
if (!(parsed instanceof Collection<?> values)) {
return List.of();
}
List<String> tags = new ArrayList<>();
for (Object value : values) {
if (value instanceof String tag && StringUtils.hasText(tag)) {
tags.add(tag.trim());
}
}
return tags;
}
private Object parseJson(String raw) {
try {
return OBJECT_MAPPER.readValue(raw, Object.class);
} catch (JsonProcessingException ex) {
return null;
}
}
private Map<String, ReviewSummaryView.DimensionSummaryView> buildDimensionSummary(
Long companyId) {
Map<String, Object[]> rowsByCode = new HashMap<>();
List<Object[]> rows =
dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(companyId);
if (rows != null) {
for (Object[] row : rows) {
if (row != null && row.length >= 3 && row[0] instanceof String code) {
rowsByCode.put(code, row);
}
}
}
Map<String, ReviewSummaryView.DimensionSummaryView> result = new LinkedHashMap<>();
for (ReviewDimension dimension : ReviewDimension.values()) {
Object[] row = rowsByCode.get(dimension.name());
long sampleCount = 0L;
Double averageScore = null;
if (row != null && row[2] instanceof Number count) {
sampleCount = count.longValue();
}
if (row != null && row[1] instanceof Number average) {
averageScore = BigDecimal.valueOf(average.doubleValue())
.setScale(1, RoundingMode.HALF_UP)
.doubleValue();
}
result.put(dimension.name(), ReviewSummaryView.DimensionSummaryView.builder()
.averageScore(averageScore)
.sampleCount(sampleCount)
.dataState(sampleCount >= MINIMUM_PUBLIC_REVIEWS
? SUFFICIENT : INSUFFICIENT)
.build());
}
return result;
}
private CompanyKnowledgeView toConfiguredView(CompanyKnowledge knowledge) {
return CompanyKnowledgeView.builder()
.configured(true)
.culture(knowledge.getCulture())
.workPolicies(knowledge.getWorkPolicies())
.onboardingGuide(knowledge.getOnboardingGuide())
.updatedAt(knowledge.getUpdatedAt())
.version(knowledge.getVersion() == null ? 0L : knowledge.getVersion())
.build();
}
private CompanyKnowledgeOverviewResponse.KnowledgeView toKnowledgeView(
CompanyKnowledge knowledge) {
return CompanyKnowledgeOverviewResponse.KnowledgeView.builder()
.culture(knowledge.getCulture())
.workPolicies(knowledge.getWorkPolicies())
.onboardingGuide(knowledge.getOnboardingGuide())
.updatedAt(knowledge.getUpdatedAt())
.build();
}
private CompanyKnowledgeOverviewResponse.CompanyView toCompanyView(Company company) {
return CompanyKnowledgeOverviewResponse.CompanyView.builder()
.id(company.getId())
.name(company.getName())
.logoUrl(company.getLogoUrl())
.description(company.getDescription())
.industry(company.getIndustry())
.website(company.getWebsite())
.address(company.getAddress())
.verified(company.getVerified())
.build();
}
private Company requireOwnedCompany(Long userId) {
assertCompanyRole(userId);
return companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
}
private void assertCompanyRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles() == null
|| user.getRoles().stream().noneMatch(role -> role == User.Role.COMPANY)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles() == null
|| user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private record KnowledgeContent(
String culture,
String workPolicies,
String onboardingGuide) {
}
}

120
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java

@ -0,0 +1,120 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemView;
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Tag(name = "入职清单", description = "学生读取有效入职清单并更新本人完成状态")
@RestController
@RequestMapping("/onboarding")
@RequiredArgsConstructor
public class OnboardingChecklistController {
private final OnboardingChecklistService onboardingChecklistService;
@Operation(summary = "读取有效入职清单", description = "返回通用项与企业活动自定义项的合并结果")
@GetMapping("/companies/{companyId}/checklist")
public ResponseEntity<ApiResponse<EffectiveChecklistResponse>> getEffectiveChecklist(
@PathVariable Long companyId,
HttpServletRequest request) {
Long userId = resolveUserId();
EffectiveChecklistResponse data =
onboardingChecklistService.getEffectiveChecklist(userId, companyId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "更新清单条目完成状态", description = "使用绝对状态语义设置当前学生的完成状态")
@PutMapping("/companies/{companyId}/checklist/items/{itemId}/completion")
public ResponseEntity<ApiResponse<ProgressUpdateResponse>> updateCompletion(
@PathVariable Long companyId,
@PathVariable Long itemId,
@Valid @RequestBody ProgressUpdateRequest body,
HttpServletRequest request) {
Long userId = resolveUserId();
ProgressUpdateResponse data = onboardingChecklistService.updateCompletion(
userId, companyId, itemId, body);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "查询本企业自定义清单项", description = "返回活动与软删除项及当前版本")
@GetMapping("/company/checklist-items")
public ResponseEntity<ApiResponse<List<CompanyChecklistItemView>>> listCompanyChecklistItems(
HttpServletRequest request) {
Long userId = resolveUserId();
List<CompanyChecklistItemView> data =
onboardingChecklistService.listCompanyChecklistItems(userId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "创建本企业自定义清单项", description = "支持 idempotencyKey 重放与冲突检测")
@PostMapping("/company/checklist-items")
public ResponseEntity<ApiResponse<CompanyChecklistItemView>> createCompanyChecklistItem(
@Valid @RequestBody CompanyChecklistItemCreateRequest body,
HttpServletRequest request) {
Long userId = resolveUserId();
CompanyChecklistItemView data =
onboardingChecklistService.createCompanyChecklistItem(userId, body);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "更新本企业自定义清单项", description = "使用 version 执行乐观锁控制")
@PutMapping("/company/checklist-items/{itemId}")
public ResponseEntity<ApiResponse<CompanyChecklistItemView>> updateCompanyChecklistItem(
@PathVariable Long itemId,
@Valid @RequestBody CompanyChecklistItemUpdateRequest body,
HttpServletRequest request) {
Long userId = resolveUserId();
CompanyChecklistItemView data =
onboardingChecklistService.updateCompanyChecklistItem(userId, itemId, body);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(summary = "软删除本企业自定义清单项", description = "重复删除保持幂等成功")
@DeleteMapping("/company/checklist-items/{itemId}")
public ResponseEntity<ApiResponse<CompanyChecklistItemView>> deleteCompanyChecklistItem(
@PathVariable Long itemId,
@RequestParam Long version,
HttpServletRequest request) {
Long userId = resolveUserId();
CompanyChecklistItemView data =
onboardingChecklistService.deleteCompanyChecklistItem(userId, itemId, version);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
private Long resolveUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(authentication.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

95
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java

@ -0,0 +1,95 @@
package com.mydreamjob.onboarding;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(
name = "onboarding_checklist_items",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_onboarding_checklist_scope_item",
columnNames = {"scope_type", "scope_id", "item_key"}),
@UniqueConstraint(
name = "uk_onboarding_checklist_company_idempotency",
columnNames = {"company_id", "idempotency_key"})
})
@EntityListeners(AuditingEntityListener.class)
public class OnboardingChecklistItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "scope_type", nullable = false, length = 20)
private String scopeType;
@Column(name = "scope_id", nullable = false)
private Long scopeId;
@Column(name = "company_id")
private Long companyId;
@Column(name = "item_key", nullable = false, length = 80)
private String itemKey;
@Column(nullable = false, length = 120)
private String title;
@Column(length = 1000)
private String description;
@Column(nullable = false, length = 30)
private String category;
@Column(name = "sort_order", nullable = false)
@Builder.Default
private Integer sortOrder = 1000;
@Column(nullable = false)
@Builder.Default
private Boolean required = false;
@Column(nullable = false)
@Builder.Default
private Boolean active = true;
@Column(name = "idempotency_key", length = 64)
private String idempotencyKey;
@Column(name = "request_hash", columnDefinition = "CHAR(64)")
private String requestHash;
@Version
@Column(nullable = false)
@Builder.Default
private Long version = 0L;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

39
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java

@ -0,0 +1,39 @@
package com.mydreamjob.onboarding;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface OnboardingChecklistItemRepository extends JpaRepository<OnboardingChecklistItem, Long> {
@Query("""
SELECT item
FROM OnboardingChecklistItem item
WHERE item.active = true
AND (
item.scopeType = 'STANDARD'
OR (item.scopeType = 'COMPANY'
AND item.scopeId = :companyId
AND item.companyId = :companyId)
)
ORDER BY item.sortOrder ASC, item.id ASC
""")
List<OnboardingChecklistItem> findEffectiveChecklistItems(@Param("companyId") Long companyId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
SELECT item
FROM OnboardingChecklistItem item
WHERE item.scopeType = 'COMPANY'
AND item.scopeId = :companyId
AND item.companyId = :companyId
ORDER BY item.id ASC
""")
List<OnboardingChecklistItem> findCompanyItemsForUpdate(@Param("companyId") Long companyId);
}

560
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java

@ -0,0 +1,560 @@
package com.mydreamjob.onboarding;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.PersistenceContext;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.ChecklistItemView;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemView;
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.HexFormat;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class OnboardingChecklistService {
private static final String STANDARD_SOURCE = "STANDARD";
private static final String COMPANY_SOURCE = "COMPANY";
private static final int DEFAULT_SORT_ORDER = 1000;
private static final int MAX_ACTIVE_COMPANY_ITEMS = 50;
private static final int MAX_TITLE_LENGTH = 120;
private static final int MAX_DESCRIPTION_LENGTH = 1000;
private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 64;
private static final Set<String> ALLOWED_CATEGORIES = Set.of(
"DOCUMENT", "ADMIN", "EQUIPMENT", "TRAINING", "LOGISTICS", "OTHER");
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final OnboardingChecklistItemRepository checklistItemRepository;
private final StudentOnboardingProgressRepository progressRepository;
@PersistenceContext
private EntityManager entityManager;
@Transactional(readOnly = true)
public EffectiveChecklistResponse getEffectiveChecklist(Long userId, Long companyId) {
assertStudentRole(userId);
Company company = companyRepository.findById(companyId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
List<OnboardingChecklistItem> items = sortedItems(
checklistItemRepository.findEffectiveChecklistItems(companyId));
List<StudentOnboardingProgress> progressRows = loadProgress(userId, items);
return toEffectiveChecklistResponse(company, items, progressRows);
}
@Transactional
public ProgressUpdateResponse updateCompletion(
Long userId,
Long companyId,
Long itemId,
ProgressUpdateRequest request) {
assertStudentRole(userId);
lockUser(userId);
if (request == null || request.getCompleted() == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
companyRepository.findById(companyId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
List<OnboardingChecklistItem> items = sortedItems(
checklistItemRepository.findEffectiveChecklistItems(companyId));
OnboardingChecklistItem item = items.stream()
.filter(candidate -> Objects.equals(candidate.getId(), itemId))
.findFirst()
.orElseThrow(() -> new BusinessException(
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND));
List<StudentOnboardingProgress> progressRows =
new ArrayList<>(loadProgress(userId, items));
StudentOnboardingProgress current =
progressRepository.findByStudentUserIdAndItemId(userId, itemId).orElse(null);
boolean desired = Boolean.TRUE.equals(request.getCompleted());
if (current == null
&& request.getVersion() != null
&& request.getVersion() != 0L) {
throw new BusinessException(
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE);
}
if (current != null) {
boolean actual = Boolean.TRUE.equals(current.getCompleted());
if (actual != desired) {
if (!Objects.equals(request.getVersion(), current.getVersion())) {
throw new BusinessException(
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE);
}
current.setCompleted(desired);
current.setCompletedAt(desired ? LocalDateTime.now() : null);
saveProgress(current);
}
} else if (desired) {
current = StudentOnboardingProgress.builder()
.studentUserId(userId)
.itemId(itemId)
.completed(true)
.completedAt(LocalDateTime.now())
.build();
progressRows.add(current);
saveProgress(current);
}
return toProgressUpdateResponse(item, current, items, progressRows);
}
@Transactional(readOnly = true)
public List<CompanyChecklistItemView> listCompanyChecklistItems(Long userId) {
Company company = requireCompanyOwner(userId);
return ownedCompanyItems(checklistItemRepository.findAll(), company.getId()).stream()
.sorted(Comparator
.comparing(OnboardingChecklistItem::getSortOrder,
Comparator.nullsLast(Integer::compareTo))
.thenComparing(OnboardingChecklistItem::getId,
Comparator.nullsLast(Long::compareTo)))
.map(this::toCompanyChecklistItemView)
.toList();
}
@Transactional(isolation = Isolation.READ_COMMITTED)
public CompanyChecklistItemView createCompanyChecklistItem(
Long userId,
CompanyChecklistItemCreateRequest request) {
Company company = requireCompanyOwner(userId);
validateCompanyItemCreateRequest(request);
lockCompany(company);
String title = normalizeOptional(request.getTitle());
String description = normalizeOptional(request.getDescription());
String category = request.getCategory();
int sortOrder = request.getSortOrder() == null
? DEFAULT_SORT_ORDER : request.getSortOrder();
boolean required = Boolean.TRUE.equals(request.getRequired());
String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey());
String requestHash = hashCompanyItemRequest(
title, description, category, sortOrder, required);
List<OnboardingChecklistItem> companyItems =
checklistItemRepository.findCompanyItemsForUpdate(company.getId());
if (idempotencyKey != null) {
OnboardingChecklistItem existing = companyItems.stream()
.filter(item -> Objects.equals(item.getIdempotencyKey(), idempotencyKey))
.findFirst()
.orElse(null);
if (existing != null) {
if (Objects.equals(existing.getRequestHash(), requestHash)) {
return toCompanyChecklistItemView(existing);
}
throw new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT);
}
}
long activeCount = companyItems.stream()
.filter(item -> Boolean.TRUE.equals(item.getActive()))
.count();
if (activeCount >= MAX_ACTIVE_COMPANY_ITEMS) {
throw new BusinessException(ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED);
}
OnboardingChecklistItem item = OnboardingChecklistItem.builder()
.scopeType(COMPANY_SOURCE)
.scopeId(company.getId())
.companyId(company.getId())
.itemKey("company-" + company.getId() + "-" + UUID.randomUUID())
.title(title)
.description(description)
.category(category)
.sortOrder(sortOrder)
.required(required)
.active(true)
.idempotencyKey(idempotencyKey)
.requestHash(requestHash)
.build();
return toCompanyChecklistItemView(saveNewCompanyItem(item));
}
@Transactional
public CompanyChecklistItemView updateCompanyChecklistItem(
Long userId,
Long itemId,
CompanyChecklistItemUpdateRequest request) {
Company company = requireCompanyOwner(userId);
validateCompanyItemUpdateRequest(request);
lockCompany(company);
OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId());
if (!Objects.equals(item.getVersion(), request.getVersion())) {
throw new BusinessException(
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE);
}
item.setTitle(normalizeOptional(request.getTitle()));
item.setDescription(normalizeOptional(request.getDescription()));
item.setCategory(request.getCategory());
item.setSortOrder(request.getSortOrder());
item.setRequired(request.getRequired());
item.setActive(request.getActive());
return toCompanyChecklistItemView(saveCompanyItem(item));
}
@Transactional
public CompanyChecklistItemView deleteCompanyChecklistItem(
Long userId,
Long itemId,
Long version) {
Company company = requireCompanyOwner(userId);
lockCompany(company);
OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId());
if (Boolean.TRUE.equals(item.getActive())) {
if (!Objects.equals(item.getVersion(), version)) {
throw new BusinessException(
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE);
}
item.setActive(false);
item = saveCompanyItem(item);
}
return toCompanyChecklistItemView(item);
}
private Company requireCompanyOwner(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isCompany = user.getRoles() != null
&& user.getRoles().stream().anyMatch(role -> role == User.Role.COMPANY);
if (!isCompany) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return companyRepository.findByOwnerUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
}
private List<OnboardingChecklistItem> ownedCompanyItems(
List<OnboardingChecklistItem> items,
Long companyId) {
if (items == null || items.isEmpty()) {
return List.of();
}
return items.stream()
.filter(item -> belongsToCompany(item, companyId))
.toList();
}
private boolean belongsToCompany(OnboardingChecklistItem item, Long companyId) {
return item != null
&& COMPANY_SOURCE.equals(item.getScopeType())
&& Objects.equals(item.getScopeId(), companyId)
&& Objects.equals(item.getCompanyId(), companyId);
}
private OnboardingChecklistItem requireOwnedCompanyItem(Long itemId, Long companyId) {
return checklistItemRepository.findById(itemId)
.filter(item -> belongsToCompany(item, companyId))
.orElseThrow(() -> new BusinessException(
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND));
}
private void validateCompanyItemCreateRequest(CompanyChecklistItemCreateRequest request) {
if (request == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String title = normalizeOptional(request.getTitle());
if (title == null || title.length() > MAX_TITLE_LENGTH) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String description = normalizeOptional(request.getDescription());
if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (!ALLOWED_CATEGORIES.contains(request.getCategory())) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
Integer sortOrder = request.getSortOrder();
if (sortOrder != null && (sortOrder < 0 || sortOrder > 9999)) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey());
if (idempotencyKey != null && idempotencyKey.length() > MAX_IDEMPOTENCY_KEY_LENGTH) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
private void validateCompanyItemUpdateRequest(CompanyChecklistItemUpdateRequest request) {
if (request == null) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String title = normalizeOptional(request.getTitle());
if (title == null || title.length() > MAX_TITLE_LENGTH) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String description = normalizeOptional(request.getDescription());
if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (!ALLOWED_CATEGORIES.contains(request.getCategory())
|| request.getSortOrder() == null
|| request.getSortOrder() < 0
|| request.getSortOrder() > 9999
|| request.getRequired() == null
|| request.getActive() == null
|| request.getVersion() == null
|| request.getVersion() < 0) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
private String normalizeOptional(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private String normalizeIdempotencyKey(String value) {
return normalizeOptional(value);
}
private String hashCompanyItemRequest(
String title,
String description,
String category,
int sortOrder,
boolean required) {
String canonical = "title=" + title.length() + ":" + title
+ "|description=" + (description == null
? "null" : description.length() + ":" + description)
+ "|category=" + category
+ "|sortOrder=" + sortOrder
+ "|required=" + required;
return sha256(canonical);
}
private String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 not available", ex);
}
}
private CompanyChecklistItemView toCompanyChecklistItemView(OnboardingChecklistItem item) {
return CompanyChecklistItemView.builder()
.itemId(item.getId())
.source(COMPANY_SOURCE)
.itemKey(item.getItemKey())
.title(item.getTitle())
.description(item.getDescription())
.category(item.getCategory())
.required(item.getRequired())
.sortOrder(item.getSortOrder())
.active(item.getActive())
.version(item.getVersion() == null ? 0L : item.getVersion())
.build();
}
private OnboardingChecklistItem saveNewCompanyItem(OnboardingChecklistItem item) {
try {
return checklistItemRepository.saveAndFlush(item);
} catch (DataIntegrityViolationException ex) {
throw new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT, ex);
}
}
private void lockCompany(Company company) {
if (entityManager == null || company == null || company.getId() == null) {
return;
}
entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE);
}
private void lockUser(Long userId) {
if (entityManager == null) {
return;
}
User user = entityManager.find(User.class, userId, LockModeType.PESSIMISTIC_WRITE);
if (user == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
}
private OnboardingChecklistItem saveCompanyItem(OnboardingChecklistItem item) {
try {
return checklistItemRepository.saveAndFlush(item);
} catch (ObjectOptimisticLockingFailureException ex) {
throw new BusinessException(
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE, ex);
}
}
private void assertStudentRole(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = user.getRoles() != null
&& user.getRoles().stream().anyMatch(role -> role == User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private List<OnboardingChecklistItem> sortedItems(List<OnboardingChecklistItem> items) {
if (items == null || items.isEmpty()) {
return List.of();
}
List<OnboardingChecklistItem> sorted = new ArrayList<>(items);
sorted.sort(Comparator
.comparing(
OnboardingChecklistItem::getSortOrder,
Comparator.nullsLast(Integer::compareTo))
.thenComparing(
OnboardingChecklistItem::getId,
Comparator.nullsLast(Long::compareTo)));
return sorted;
}
private List<StudentOnboardingProgress> loadProgress(
Long userId, List<OnboardingChecklistItem> items) {
if (items.isEmpty()) {
return List.of();
}
List<Long> itemIds = items.stream()
.map(OnboardingChecklistItem::getId)
.filter(Objects::nonNull)
.toList();
if (itemIds.isEmpty()) {
return List.of();
}
return progressRepository.findByStudentUserIdAndItemIdIn(userId, itemIds);
}
private EffectiveChecklistResponse toEffectiveChecklistResponse(
Company company,
List<OnboardingChecklistItem> items,
List<StudentOnboardingProgress> progressRows) {
Map<Long, StudentOnboardingProgress> progressByItemId = progressRows.stream()
.collect(Collectors.toMap(
StudentOnboardingProgress::getItemId,
Function.identity(),
(first, ignored) -> first));
List<ChecklistItemView> views = items.stream()
.map(item -> toChecklistItemView(item, progressByItemId.get(item.getId())))
.toList();
int totalItems = views.size();
int completedItems = completedCount(items, progressRows);
return EffectiveChecklistResponse.builder()
.companyId(company.getId())
.companyName(company.getName())
.customized(items.stream()
.anyMatch(item -> COMPANY_SOURCE.equals(item.getScopeType())))
.totalItems(totalItems)
.completedItems(completedItems)
.completionRate(completionRate(totalItems, completedItems))
.items(views)
.build();
}
private ProgressUpdateResponse toProgressUpdateResponse(
OnboardingChecklistItem item,
StudentOnboardingProgress progress,
List<OnboardingChecklistItem> items,
List<StudentOnboardingProgress> progressRows) {
int totalItems = items.size();
int completedItems = completedCount(items, progressRows);
boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted());
return ProgressUpdateResponse.builder()
.itemId(item.getId())
.completed(completed)
.completedAt(completed && progress != null ? progress.getCompletedAt() : null)
.progressVersion(progress == null || progress.getVersion() == null
? 0L : progress.getVersion())
.totalItems(totalItems)
.completedItems(completedItems)
.completionRate(completionRate(totalItems, completedItems))
.build();
}
private ChecklistItemView toChecklistItemView(
OnboardingChecklistItem item,
StudentOnboardingProgress progress) {
boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted());
return ChecklistItemView.builder()
.itemId(item.getId())
.source(STANDARD_SOURCE.equals(item.getScopeType())
? STANDARD_SOURCE : COMPANY_SOURCE)
.itemKey(item.getItemKey())
.title(item.getTitle())
.description(item.getDescription())
.category(item.getCategory())
.required(item.getRequired())
.sortOrder(item.getSortOrder())
.active(item.getActive())
.completed(completed)
.completedAt(completed && progress != null ? progress.getCompletedAt() : null)
.progressVersion(progress == null || progress.getVersion() == null
? 0L : progress.getVersion())
.build();
}
private int completedCount(
List<OnboardingChecklistItem> items,
List<StudentOnboardingProgress> progressRows) {
var effectiveItemIds = items.stream()
.map(OnboardingChecklistItem::getId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
return (int) progressRows.stream()
.filter(progress -> effectiveItemIds.contains(progress.getItemId()))
.filter(progress -> Boolean.TRUE.equals(progress.getCompleted()))
.count();
}
private double completionRate(int totalItems, int completedItems) {
if (totalItems == 0) {
return 0.0;
}
double rawRate = completedItems * 100.0 / totalItems;
return Math.min(100.0, Math.round(rawRate * 10.0) / 10.0);
}
private void saveProgress(StudentOnboardingProgress progress) {
try {
progressRepository.saveAndFlush(progress);
} catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) {
throw new BusinessException(
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE, ex);
}
}
}

64
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java

@ -0,0 +1,64 @@
package com.mydreamjob.onboarding;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(
name = "student_onboarding_progress",
uniqueConstraints = @UniqueConstraint(
name = "uk_student_onboarding_progress_student_item",
columnNames = {"student_user_id", "item_id"}))
@EntityListeners(AuditingEntityListener.class)
public class StudentOnboardingProgress {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "student_user_id", nullable = false)
private Long studentUserId;
@Column(name = "item_id", nullable = false)
private Long itemId;
@Column(nullable = false)
@Builder.Default
private Boolean completed = false;
@Column(name = "completed_at")
private LocalDateTime completedAt;
@Version
@Column(nullable = false)
@Builder.Default
private Long version = 0L;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

19
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java

@ -0,0 +1,19 @@
package com.mydreamjob.onboarding;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@Repository
public interface StudentOnboardingProgressRepository
extends JpaRepository<StudentOnboardingProgress, Long> {
List<StudentOnboardingProgress> findByStudentUserIdAndItemIdIn(
Long studentUserId, Collection<Long> itemIds);
Optional<StudentOnboardingProgress> findByStudentUserIdAndItemId(
Long studentUserId, Long itemId);
}

28
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java

@ -0,0 +1,28 @@
package com.mydreamjob.onboarding.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChecklistItemView {
private Long itemId;
private String source;
private String itemKey;
private String title;
private String description;
private String category;
private Boolean required;
private Integer sortOrder;
private Boolean active;
private Boolean completed;
private LocalDateTime completedAt;
private Long progressVersion;
}

34
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java

@ -0,0 +1,34 @@
package com.mydreamjob.onboarding.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class CompanyChecklistItemCreateRequest {
@NotBlank(message = "title is required")
@Size(max = 120, message = "title must be at most 120 chars")
private String title;
@Size(max = 1000, message = "description must be at most 1000 chars")
private String description;
@NotBlank(message = "category is required")
@Pattern(
regexp = "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER",
message = "category is invalid")
private String category;
@Min(value = 0, message = "sortOrder must not be negative")
@Max(value = 9999, message = "sortOrder must be at most 9999")
private Integer sortOrder = 1000;
private Boolean required = false;
@Size(max = 64, message = "idempotencyKey must be at most 64 chars")
private String idempotencyKey;
}

41
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java

@ -0,0 +1,41 @@
package com.mydreamjob.onboarding.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class CompanyChecklistItemUpdateRequest {
@NotBlank(message = "title is required")
@Size(max = 120, message = "title must be at most 120 chars")
private String title;
@Size(max = 1000, message = "description must be at most 1000 chars")
private String description;
@NotBlank(message = "category is required")
@Pattern(
regexp = "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER",
message = "category is invalid")
private String category;
@NotNull(message = "sortOrder is required")
@Min(value = 0, message = "sortOrder must not be negative")
@Max(value = 9999, message = "sortOrder must be at most 9999")
private Integer sortOrder;
@NotNull(message = "required is required")
private Boolean required;
@NotNull(message = "active is required")
private Boolean active;
@NotNull(message = "version is required")
@Min(value = 0, message = "version must not be negative")
private Long version;
}

24
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java

@ -0,0 +1,24 @@
package com.mydreamjob.onboarding.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CompanyChecklistItemView {
private Long itemId;
private String source;
private String itemKey;
private String title;
private String description;
private String category;
private Boolean required;
private Integer sortOrder;
private Boolean active;
private Long version;
}

53
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java

@ -0,0 +1,53 @@
package com.mydreamjob.onboarding.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CompanyKnowledgeOverviewResponse {
private CompanyView company;
private String knowledgeState;
private KnowledgeView knowledge;
private ReviewSummaryView reviewSummary;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class CompanyView {
private Long id;
private String name;
private String logoUrl;
private String description;
private String industry;
private String website;
private String address;
private Boolean verified;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class KnowledgeView {
private String culture;
private String workPolicies;
private String onboardingGuide;
private LocalDateTime updatedAt;
private Long version;
}
}

21
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java

@ -0,0 +1,21 @@
package com.mydreamjob.onboarding.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class CompanyKnowledgeUpdateRequest {
@Size(max = 2000, message = "culture must be at most 2000 chars")
private String culture;
@Size(max = 4000, message = "workPolicies must be at most 4000 chars")
private String workPolicies;
@Size(max = 4000, message = "onboardingGuide must be at most 4000 chars")
private String onboardingGuide;
@Min(value = 0, message = "version must not be negative")
private Long version;
}

24
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java

@ -0,0 +1,24 @@
package com.mydreamjob.onboarding.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CompanyKnowledgeView {
private Boolean configured;
private String culture;
private String workPolicies;
private String onboardingGuide;
private LocalDateTime updatedAt;
private Long version;
}

23
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java

@ -0,0 +1,23 @@
package com.mydreamjob.onboarding.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EffectiveChecklistResponse {
private Long companyId;
private String companyName;
private Boolean customized;
private Integer totalItems;
private Integer completedItems;
private Double completionRate;
private List<ChecklistItemView> items;
}

15
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java

@ -0,0 +1,15 @@
package com.mydreamjob.onboarding.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class ProgressUpdateRequest {
@NotNull(message = "completed is required")
private Boolean completed;
@Min(value = 0, message = "version must not be negative")
private Long version;
}

23
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java

@ -0,0 +1,23 @@
package com.mydreamjob.onboarding.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProgressUpdateResponse {
private Long itemId;
private Boolean completed;
private LocalDateTime completedAt;
private Long progressVersion;
private Integer totalItems;
private Integer completedItems;
private Double completionRate;
}

37
mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java

@ -0,0 +1,37 @@
package com.mydreamjob.onboarding.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ReviewSummaryView {
private String state;
private int totalReviews;
private Double averageRating;
private Map<String, Integer> ratingDistribution;
private List<String> topTags;
private Map<String, DimensionSummaryView> dimensionSummary;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class DimensionSummaryView {
private Double averageScore;
private long sampleCount;
private String dataState;
}
}

19
mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java

@ -42,4 +42,23 @@ public interface JobReviewDimensionScoreRepository extends JpaRepository<JobRevi
GROUP BY d.dimensionCode GROUP BY d.dimensionCode
""") """)
List<Object[]> aggregatePublishedByJobId(@Param("jobId") Long jobId); List<Object[]> aggregatePublishedByJobId(@Param("jobId") Long jobId);
/**
* 按公司聚合公开评价八维评分:一次数据库聚合,不加载 JobReview 实体,
* 不先查询 reviewId 列表。返回 [dimensionCode, AVG(score), COUNT(id)]。
*/
@Query("""
SELECT d.dimensionCode, AVG(d.score), COUNT(d.id)
FROM JobReviewDimensionScore d
WHERE d.reviewId IN (
SELECT r.id
FROM JobReview r
WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId)
AND r.status = 'PUBLISHED'
AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED')
)
GROUP BY d.dimensionCode
ORDER BY d.dimensionCode
""")
List<Object[]> aggregatePublishedDimensionsByCompanyId(@Param("companyId") Long companyId);
} }

35
mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java

@ -31,6 +31,41 @@ public interface JobReviewRepository extends JpaRepository<JobReview, Long> {
List<JobReview> findByJobIdAndStatusAndModerationStatusIn( List<JobReview> findByJobIdAndStatusAndModerationStatusIn(
Long jobId, String status, Collection<String> moderationStatuses); Long jobId, String status, Collection<String> moderationStatuses);
/** 企业公开评价汇总:一次查询统计全部岗位的 PUBLISHED + 审核通过评价。 */
@Query("""
SELECT COUNT(r), COALESCE(AVG(r.rating), 0)
FROM JobReview r
WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId)
AND r.status = 'PUBLISHED'
AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED')
""")
List<Object[]> countAndAveragePublishedRatingByCompanyId(@Param("companyId") Long companyId);
/** 企业公开评价评分分布,按评分升序返回 [rating, count]。 */
@Query("""
SELECT r.rating, COUNT(r)
FROM JobReview r
WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId)
AND r.status = 'PUBLISHED'
AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED')
GROUP BY r.rating
ORDER BY r.rating
""")
List<Object[]> findPublishedRatingDistributionByCompanyId(@Param("companyId") Long companyId);
/** 企业公开评价的原始 tags 列,避免逐条读取评价。 */
@Query("""
SELECT r.tags
FROM JobReview r
WHERE r.jobId IN (SELECT j.id FROM Job j WHERE j.companyId = :companyId)
AND r.status = 'PUBLISHED'
AND r.moderationStatus IN ('AUTO_APPROVED', 'APPROVED')
AND r.tags IS NOT NULL
ORDER BY r.id ASC
""")
List<String> findPublishedTagsByCompanyId(@Param("companyId") Long companyId);
// ── 防刷规则查询 ────────────────────────────────────────── // ── 防刷规则查询 ──────────────────────────────────────────
long countByStudentUserIdAndCreatedAtAfter(Long studentUserId, LocalDateTime after); long countByStudentUserIdAndCreatedAtAfter(Long studentUserId, LocalDateTime after);

57
mydreamjob-server/src/main/resources/db/migration/V38__onboarding_checklist_and_company_knowledge.sql

@ -0,0 +1,57 @@
-- Flyway V38: 入职清单与企业知识表结构
-- 本迁移只建立三张业务表;通用清单内容由 V39 导入。
CREATE TABLE onboarding_checklist_items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
scope_type VARCHAR(20) NOT NULL COMMENT 'STANDARD 或 COMPANY',
scope_id BIGINT NOT NULL COMMENT '通用项为 0,企业项为 company_id',
company_id BIGINT NULL COMMENT '企业项所属企业;通用项为空',
item_key VARCHAR(80) NOT NULL COMMENT '清单项稳定业务键',
title VARCHAR(120) NOT NULL COMMENT '标题',
description VARCHAR(1000) NULL COMMENT '说明',
category VARCHAR(30) NOT NULL COMMENT 'DOCUMENT/ADMIN/EQUIPMENT/TRAINING/LOGISTICS/OTHER',
sort_order INT NOT NULL DEFAULT 1000 COMMENT '排序值',
required BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否必填',
active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否生效',
idempotency_key VARCHAR(64) NULL COMMENT '企业创建幂等键',
request_hash CHAR(64) NULL COMMENT '规范化创建请求 SHA-256',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT ck_onboarding_checklist_scope_type CHECK (scope_type IN ('STANDARD', 'COMPANY')),
CONSTRAINT ck_onboarding_checklist_category CHECK (
category IN ('DOCUMENT', 'ADMIN', 'EQUIPMENT', 'TRAINING', 'LOGISTICS', 'OTHER')
),
CONSTRAINT ck_onboarding_checklist_sort_order CHECK (sort_order BETWEEN 0 AND 9999),
CONSTRAINT uk_onboarding_checklist_scope_item UNIQUE (scope_type, scope_id, item_key),
CONSTRAINT uk_onboarding_checklist_company_idempotency UNIQUE (company_id, idempotency_key),
CONSTRAINT fk_onboarding_checklist_company FOREIGN KEY (company_id) REFERENCES companies (id),
INDEX idx_onboarding_checklist_scope_active_sort (scope_type, scope_id, active, sort_order, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职清单项';
CREATE TABLE student_onboarding_progress (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
student_user_id BIGINT NOT NULL COMMENT '学生用户',
item_id BIGINT NOT NULL COMMENT '清单项',
completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成',
completed_at DATETIME NULL COMMENT '完成时间;取消完成时为空',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_student_onboarding_progress_student_item UNIQUE (student_user_id, item_id),
CONSTRAINT fk_student_onboarding_progress_student FOREIGN KEY (student_user_id) REFERENCES users (id),
CONSTRAINT fk_student_onboarding_progress_item FOREIGN KEY (item_id) REFERENCES onboarding_checklist_items (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学生入职清单完成状态';
CREATE TABLE company_knowledge (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
company_id BIGINT NOT NULL COMMENT '企业',
culture VARCHAR(2000) NULL COMMENT '企业文化',
work_policies VARCHAR(4000) NULL COMMENT '工作制度',
onboarding_guide VARCHAR(4000) NULL COMMENT '入职指南',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_company_knowledge_company UNIQUE (company_id),
CONSTRAINT fk_company_knowledge_company FOREIGN KEY (company_id) REFERENCES companies (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='企业知识介绍';

31
mydreamjob-server/src/main/resources/db/migration/V39__onboarding_standard_checklist_seed.sql

@ -0,0 +1,31 @@
-- Flyway V39: 通用入职准备清单种子
-- 通用项使用 scope_id=0、company_id=NULL,内容、分类、顺序和必填标记均为固定值。
INSERT INTO onboarding_checklist_items
(scope_type, scope_id, company_id, item_key, title, description, category, sort_order,
required, active, idempotency_key, request_hash, version)
VALUES
('STANDARD', 0, NULL, 'standard-id-card', '准备身份证件',
'准备身份证原件或有效身份证明,用于入职登记。', 'DOCUMENT', 100, TRUE, TRUE, NULL, NULL, 0),
('STANDARD', 0, NULL, 'standard-education-proof', '准备学历学位证明',
'按公司要求准备学历、学位证书或学信网证明。', 'DOCUMENT', 110, TRUE, TRUE, NULL, NULL, 0),
('STANDARD', 0, NULL, 'standard-id-photo', '准备证件照',
'准备符合公司要求的近期证件照。', 'DOCUMENT', 120, FALSE, TRUE, NULL, NULL, 0),
('STANDARD', 0, NULL, 'standard-bank-account', '提供工资卡信息',
'按公司要求提供本人工资卡账户信息。', 'ADMIN', 200, TRUE, TRUE, NULL, NULL, 0),
('STANDARD', 0, NULL, 'standard-emergency-contact', '填写紧急联系人',
'填写必要的紧急联系人和联系方式。', 'ADMIN', 210, TRUE, TRUE, NULL, NULL, 0),
('STANDARD', 0, NULL, 'standard-background-check', '完成背景核验',
'按公司通知配合完成背景信息核验。', 'ADMIN', 220, FALSE, TRUE, NULL, NULL, 0),
('STANDARD', 0, NULL, 'standard-medical-check', '确认入职体检要求',
'确认岗位是否需要体检及指定机构要求。', 'ADMIN', 230, FALSE, TRUE, NULL, NULL, 0),
('STANDARD', 0, NULL, 'standard-equipment-request', '确认办公设备需求',
'提前确认工位、电脑或其他办公设备需求。', 'EQUIPMENT', 300, FALSE, TRUE, NULL, NULL, 0),
('STANDARD', 0, NULL, 'standard-report-location', '确认报到地址和时间',
'与 HR 确认入职日期、报到地址和接待人。', 'LOGISTICS', 400, TRUE, TRUE, NULL, NULL, 0),
('STANDARD', 0, NULL, 'standard-commute-plan', '规划通勤路线',
'提前规划到岗路线和交通时间。', 'LOGISTICS', 410, FALSE, TRUE, NULL, NULL, 0),
('STANDARD', 0, NULL, 'standard-compliance-training', '完成合规与保密培训',
'完成公司要求的合规、安全和保密培训。', 'TRAINING', 500, TRUE, TRUE, NULL, NULL, 0),
('STANDARD', 0, NULL, 'standard-onboarding-guide', '阅读入职指南',
'阅读公司入职指南及相关准备说明。', 'TRAINING', 510, FALSE, TRUE, NULL, NULL, 0);

228
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java

@ -0,0 +1,228 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView;
import com.mydreamjob.onboarding.dto.ReviewSummaryView;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class CompanyKnowledgeControllerTest {
private static final Long COMPANY_USER_ID = 900L;
private static final Long STUDENT_USER_ID = 901L;
private static final Long COMPANY_ID = 7L;
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider;
@MockBean private CompanyKnowledgeService companyKnowledgeService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@Test
void getCompanyKnowledge_shouldReturnUnconfiguredContract() throws Exception {
when(companyKnowledgeService.getCompanyKnowledge(COMPANY_USER_ID))
.thenReturn(view(false, null, null, null, null, 0L));
mockMvc.perform(get("/onboarding/company/knowledge")
.header("Authorization", companyToken())
.header("X-Request-Id", "req-t5-get"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.requestId").value("req-t5-get"))
.andExpect(jsonPath("$.data.configured").value(false))
.andExpect(jsonPath("$.data.version").value(0))
.andExpect(jsonPath("$.data.culture").doesNotExist());
verify(companyKnowledgeService).getCompanyKnowledge(COMPANY_USER_ID);
}
@Test
void putCompanyKnowledge_shouldReturnConfiguredContract() throws Exception {
when(companyKnowledgeService.upsertCompanyKnowledge(
eq(COMPANY_USER_ID), any(CompanyKnowledgeUpdateRequest.class)))
.thenReturn(view(true, "Culture", "Policies", "Guide",
LocalDateTime.of(2026, 9, 16, 7, 0), 0L));
mockMvc.perform(put("/onboarding/company/knowledge")
.header("Authorization", companyToken())
.header("X-Request-Id", "req-t5-put")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"culture": "Culture",
"workPolicies": "Policies",
"onboardingGuide": "Guide"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.requestId").value("req-t5-put"))
.andExpect(jsonPath("$.data.configured").value(true))
.andExpect(jsonPath("$.data.culture").value("Culture"))
.andExpect(jsonPath("$.data.version").value(0));
verify(companyKnowledgeService).upsertCompanyKnowledge(
eq(COMPANY_USER_ID), argThat(request ->
"Culture".equals(request.getCulture())
&& "Policies".equals(request.getWorkPolicies())
&& "Guide".equals(request.getOnboardingGuide())
&& request.getVersion() == null));
}
@Test
void putCompanyKnowledge_withInvalidLength_shouldReturn400BeforeService() throws Exception {
String tooLong = "x".repeat(2001);
mockMvc.perform(put("/onboarding/company/knowledge")
.header("Authorization", companyToken())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"culture":"%s"}
""".formatted(tooLong)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(companyKnowledgeService, never()).upsertCompanyKnowledge(any(), any());
}
@Test
void putCompanyKnowledge_staleVersion_shouldReturn409() throws Exception {
when(companyKnowledgeService.upsertCompanyKnowledge(
eq(COMPANY_USER_ID), any(CompanyKnowledgeUpdateRequest.class)))
.thenThrow(new BusinessException(
ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE));
mockMvc.perform(put("/onboarding/company/knowledge")
.header("Authorization", companyToken())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"culture":"New","version":1}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code")
.value(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode()));
}
@Test
void getCompanyKnowledge_asStudent_shouldReturn403() throws Exception {
when(companyKnowledgeService.getCompanyKnowledge(STUDENT_USER_ID))
.thenThrow(new BusinessException(ErrorCode.FORBIDDEN));
mockMvc.perform(get("/onboarding/company/knowledge")
.header("Authorization", studentToken()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode()));
}
@Test
void studentOverview_shouldReturnWhitelistedCompanyAndReviewSummary() throws Exception {
when(companyKnowledgeService.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID))
.thenReturn(overview());
mockMvc.perform(get("/onboarding/companies/{companyId}/knowledge", COMPANY_ID)
.header("Authorization", studentToken())
.header("X-Request-Id", "req-t5-overview"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.requestId").value("req-t5-overview"))
.andExpect(jsonPath("$.data.knowledgeState").value("BASIC_ONLY"))
.andExpect(jsonPath("$.data.company.id").value(COMPANY_ID))
.andExpect(jsonPath("$.data.company.name").value("Example Corp"))
.andExpect(jsonPath("$.data.company.website").value("https://example.com"))
.andExpect(jsonPath("$.data.company.address").value("Shanghai"))
.andExpect(jsonPath("$.data.company.ownerUserId").doesNotExist())
.andExpect(jsonPath("$.data.reviewSummary.state").value("EMPTY"))
.andExpect(jsonPath("$.data.reviewSummary.totalReviews").value(0));
verify(companyKnowledgeService).getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID);
}
@Test
void studentOverview_companyNotFound_shouldReturn404() throws Exception {
when(companyKnowledgeService.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID))
.thenThrow(new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
mockMvc.perform(get("/onboarding/companies/{companyId}/knowledge", COMPANY_ID)
.header("Authorization", studentToken()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND.getCode()));
}
@Test
void getCompanyKnowledge_withoutToken_shouldReturn401() throws Exception {
mockMvc.perform(get("/onboarding/company/knowledge"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
private CompanyKnowledgeView view(
boolean configured,
String culture,
String workPolicies,
String onboardingGuide,
LocalDateTime updatedAt,
Long version) {
return CompanyKnowledgeView.builder()
.configured(configured)
.culture(culture)
.workPolicies(workPolicies)
.onboardingGuide(onboardingGuide)
.updatedAt(updatedAt)
.version(version)
.build();
}
private CompanyKnowledgeOverviewResponse overview() {
return CompanyKnowledgeOverviewResponse.builder()
.company(CompanyKnowledgeOverviewResponse.CompanyView.builder()
.id(COMPANY_ID)
.name("Example Corp")
.logoUrl("https://example.com/logo.png")
.description("Description")
.industry("Software")
.website("https://example.com")
.address("Shanghai")
.verified(true)
.build())
.knowledgeState("BASIC_ONLY")
.reviewSummary(ReviewSummaryView.builder()
.state("EMPTY")
.totalReviews(0)
.build())
.build();
}
private String companyToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(
COMPANY_USER_ID, "1", Map.of("roles", "COMPANY"));
}
private String studentToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(
STUDENT_USER_ID, "1", Map.of("roles", "STUDENT"));
}
}

448
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java

@ -0,0 +1,448 @@
package com.mydreamjob.onboarding;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView;
import com.mydreamjob.onboarding.dto.ReviewSummaryView;
import com.mydreamjob.review.JobReviewDimensionScoreRepository;
import com.mydreamjob.review.JobReviewRepository;
import com.mydreamjob.review.ReviewDimension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CompanyKnowledgeServiceTest {
private static final Long COMPANY_USER_ID = 900L;
private static final Long STUDENT_USER_ID = 901L;
private static final Long COMPANY_ID = 7L;
private static final LocalDateTime UPDATED_AT = LocalDateTime.of(2026, 9, 16, 7, 0);
@Mock private UserRepository userRepository;
@Mock private CompanyRepository companyRepository;
@Mock private CompanyKnowledgeRepository knowledgeRepository;
@Mock private JobReviewRepository reviewRepository;
@Mock private JobReviewDimensionScoreRepository dimensionScoreRepository;
@InjectMocks private CompanyKnowledgeService service;
@Test
void getCompanyKnowledge_withoutRecord_shouldReturnUnconfiguredEmptyView() {
stubCompanyOwner();
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty());
CompanyKnowledgeView result = service.getCompanyKnowledge(COMPANY_USER_ID);
assertThat(result.getConfigured()).isFalse();
assertThat(result.getCulture()).isNull();
assertThat(result.getWorkPolicies()).isNull();
assertThat(result.getOnboardingGuide()).isNull();
assertThat(result.getUpdatedAt()).isNull();
assertThat(result.getVersion()).isZero();
}
@Test
void getCompanyKnowledge_withRecord_shouldReturnConfiguredView() {
stubCompanyOwner();
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of(
knowledge(1L, "Culture", "Policies", "Guide", 3L, UPDATED_AT)));
CompanyKnowledgeView result = service.getCompanyKnowledge(COMPANY_USER_ID);
assertThat(result.getConfigured()).isTrue();
assertThat(result.getCulture()).isEqualTo("Culture");
assertThat(result.getWorkPolicies()).isEqualTo("Policies");
assertThat(result.getOnboardingGuide()).isEqualTo("Guide");
assertThat(result.getUpdatedAt()).isEqualTo(UPDATED_AT);
assertThat(result.getVersion()).isEqualTo(3L);
}
@Test
void upsertCompanyKnowledge_firstCreate_shouldTrimAndPersist() {
stubCompanyOwner();
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty());
ArgumentCaptor<CompanyKnowledge> captor = ArgumentCaptor.forClass(CompanyKnowledge.class);
when(knowledgeRepository.saveAndFlush(any(CompanyKnowledge.class))).thenAnswer(invocation -> {
CompanyKnowledge saved = invocation.getArgument(0);
saved.setId(11L);
saved.setVersion(0L);
saved.setUpdatedAt(UPDATED_AT);
return saved;
});
CompanyKnowledgeView result = service.upsertCompanyKnowledge(
COMPANY_USER_ID,
request(" Culture ", " Policies ", " Guide ", null));
verify(knowledgeRepository).saveAndFlush(captor.capture());
CompanyKnowledge saved = captor.getValue();
assertThat(saved.getCompanyId()).isEqualTo(COMPANY_ID);
assertThat(saved.getCulture()).isEqualTo("Culture");
assertThat(saved.getWorkPolicies()).isEqualTo("Policies");
assertThat(saved.getOnboardingGuide()).isEqualTo("Guide");
assertThat(result.getConfigured()).isTrue();
assertThat(result.getVersion()).isZero();
}
@Test
void upsertCompanyKnowledge_updateWithCurrentVersion_shouldIncrementVersion() {
stubCompanyOwner();
CompanyKnowledge existing = knowledge(12L, "Old culture", null, null, 2L, UPDATED_AT);
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of(existing));
when(knowledgeRepository.saveAndFlush(existing)).thenAnswer(invocation -> {
existing.setVersion(3L);
existing.setUpdatedAt(UPDATED_AT.plusHours(1));
return existing;
});
CompanyKnowledgeView result = service.upsertCompanyKnowledge(
COMPANY_USER_ID, request("New culture", null, "New guide", 2L));
assertThat(existing.getCulture()).isEqualTo("New culture");
assertThat(existing.getWorkPolicies()).isNull();
assertThat(existing.getOnboardingGuide()).isEqualTo("New guide");
assertThat(result.getVersion()).isEqualTo(3L);
}
@Test
void upsertCompanyKnowledge_existingWithoutVersion_shouldReturnBadRequest() {
stubCompanyOwner();
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of(
knowledge(1L, "culture", null, null, 1L, UPDATED_AT)));
assertThatThrownBy(() -> service.upsertCompanyKnowledge(
COMPANY_USER_ID, request("new", null, null, null)))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode());
verify(knowledgeRepository, never()).saveAndFlush(any());
}
@Test
void upsertCompanyKnowledge_staleVersion_shouldReturnKnowledgeConflict() {
stubCompanyOwner();
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of(
knowledge(1L, "culture", null, null, 4L, UPDATED_AT)));
assertThatThrownBy(() -> service.upsertCompanyKnowledge(
COMPANY_USER_ID, request("new", null, null, 3L)))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode());
verify(knowledgeRepository, never()).saveAndFlush(any());
}
@Test
void upsertCompanyKnowledge_optimisticLockFailure_shouldReturnKnowledgeConflict() {
stubCompanyOwner();
CompanyKnowledge existing = knowledge(1L, "culture", null, null, 1L, UPDATED_AT);
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of(existing));
when(knowledgeRepository.saveAndFlush(existing))
.thenThrow(new ObjectOptimisticLockingFailureException(CompanyKnowledge.class, 1L));
assertThatThrownBy(() -> service.upsertCompanyKnowledge(
COMPANY_USER_ID, request("new", null, null, 1L)))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode());
}
@Test
void upsertCompanyKnowledge_invalidPlainTextFields_shouldReturnBadRequest() {
stubCompanyOwner();
List<CompanyKnowledgeUpdateRequest> invalid = List.of(
request(null, null, null, null),
request(" ", " ", " ", null),
request("x".repeat(2001), null, null, null),
request(null, "x".repeat(4001), null, null),
request(null, null, "x".repeat(4001), null),
request("<script>alert(1)</script>", null, null, null));
for (CompanyKnowledgeUpdateRequest value : invalid) {
assertThatThrownBy(() -> service.upsertCompanyKnowledge(COMPANY_USER_ID, value))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode());
}
verifyNoInteractions(knowledgeRepository);
}
@Test
void overview_withoutKnowledge_shouldReturnBasicOnlyAndEmptyReviewSummary() {
stubStudent();
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty());
when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID))
.thenReturn(List.<Object[]>of(new Object[]{0L, BigDecimal.ZERO}));
CompanyKnowledgeOverviewResponse result =
service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID);
assertThat(result.getKnowledgeState()).isEqualTo("BASIC_ONLY");
assertThat(result.getKnowledge()).isNull();
assertThat(result.getCompany().getId()).isEqualTo(COMPANY_ID);
assertThat(result.getCompany().getWebsite()).isEqualTo("https://example.com");
assertThat(result.getCompany().getAddress()).isEqualTo("Shanghai");
assertThat(result.getReviewSummary().getState()).isEqualTo("EMPTY");
assertThat(result.getReviewSummary().getTotalReviews()).isZero();
assertThat(result.getReviewSummary().getAverageRating()).isNull();
assertThat(result.getReviewSummary().getDimensionSummary()).isNull();
}
@Test
void overview_withKnowledge_shouldReturnConfiguredKnowledge() {
stubStudent();
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of(
knowledge(5L, "Culture", "Policies", "Guide", 2L, UPDATED_AT)));
when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID))
.thenReturn(List.<Object[]>of(new Object[]{1L, 4.0}));
CompanyKnowledgeOverviewResponse result =
service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID);
assertThat(result.getKnowledgeState()).isEqualTo("CONFIGURED");
assertThat(result.getKnowledge().getCulture()).isEqualTo("Culture");
assertThat(result.getKnowledge().getWorkPolicies()).isEqualTo("Policies");
assertThat(result.getKnowledge().getOnboardingGuide()).isEqualTo("Guide");
assertThat(result.getKnowledge().getUpdatedAt()).isEqualTo(UPDATED_AT);
assertThat(result.getKnowledge().getVersion()).isNull();
assertThat(result.getReviewSummary().getState()).isEqualTo("INSUFFICIENT");
assertThat(result.getReviewSummary().getTotalReviews()).isEqualTo(1);
verify(reviewRepository, never()).findPublishedRatingDistributionByCompanyId(any());
}
@Test
void overview_withThreePublicReviews_shouldReturnAggregatesAndAllEightDimensions() {
stubStudent();
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty());
when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID))
.thenReturn(List.<Object[]>of(new Object[]{3L, 4.333}));
when(reviewRepository.findPublishedRatingDistributionByCompanyId(COMPANY_ID))
.thenReturn(List.of(
new Object[]{3, 1L},
new Object[]{4, 1L},
new Object[]{5, 1L}));
when(reviewRepository.findPublishedTagsByCompanyId(COMPANY_ID))
.thenReturn(List.of(
"[\"beta\",\"team\"]",
"[\"alpha\",\"beta\"]"));
when(dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(COMPANY_ID))
.thenReturn(List.of(
new Object[]{"TEAM_ATMOSPHERE", 4.0, 3L},
new Object[]{"GROWTH", 4.5, 2L}));
CompanyKnowledgeOverviewResponse result =
service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID);
ReviewSummaryView summary = result.getReviewSummary();
assertThat(summary.getState()).isEqualTo("SUFFICIENT");
assertThat(summary.getTotalReviews()).isEqualTo(3);
assertThat(summary.getAverageRating()).isEqualTo(4.3);
assertThat(summary.getRatingDistribution())
.containsEntry("rating1", 0)
.containsEntry("rating2", 0)
.containsEntry("rating3", 1)
.containsEntry("rating4", 1)
.containsEntry("rating5", 1);
assertThat(summary.getTopTags()).containsExactly("beta", "alpha", "team");
assertThat(summary.getDimensionSummary()).hasSize(ReviewDimension.values().length);
assertThat(summary.getDimensionSummary().get("TEAM_ATMOSPHERE").getAverageScore())
.isEqualTo(4.0);
assertThat(summary.getDimensionSummary().get("TEAM_ATMOSPHERE").getSampleCount())
.isEqualTo(3L);
assertThat(summary.getDimensionSummary().get("TEAM_ATMOSPHERE").getDataState())
.isEqualTo("SUFFICIENT");
assertThat(summary.getDimensionSummary().get("GROWTH").getDataState())
.isEqualTo("INSUFFICIENT");
assertThat(summary.getDimensionSummary().get("AUTHENTICITY").getSampleCount()).isZero();
assertThat(summary.getDimensionSummary().get("AUTHENTICITY").getAverageScore()).isNull();
assertThat(summary.getDimensionSummary().get("AUTHENTICITY").getDataState())
.isEqualTo("INSUFFICIENT");
}
@Test
void overview_withExactlyTwoPublicReviews_shouldReturnInsufficientWithoutAggregates() {
stubStudent();
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty());
when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID))
.thenReturn(List.<Object[]>of(new Object[]{2L, BigDecimal.valueOf(4.0)}));
ReviewSummaryView summary = service.getCompanyKnowledgeOverview(
STUDENT_USER_ID, COMPANY_ID).getReviewSummary();
assertThat(summary.getState()).isEqualTo("INSUFFICIENT");
assertThat(summary.getTotalReviews()).isEqualTo(2);
assertThat(summary.getAverageRating()).isNull();
assertThat(summary.getRatingDistribution()).isNull();
assertThat(summary.getTopTags()).isNull();
assertThat(summary.getDimensionSummary()).isNull();
verify(reviewRepository, never()).findPublishedRatingDistributionByCompanyId(any());
verify(reviewRepository, never()).findPublishedTagsByCompanyId(any());
verifyNoInteractions(dimensionScoreRepository);
}
@Test
void overview_withMoreThanTenDistinctTags_shouldReturnOnlyTopTenDeterministically() {
stubStudent();
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty());
when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID))
.thenReturn(List.<Object[]>of(new Object[]{3L, BigDecimal.valueOf(4.0)}));
when(reviewRepository.findPublishedRatingDistributionByCompanyId(COMPANY_ID))
.thenReturn(List.of());
List<String> rawTags = new ArrayList<>();
for (int index = 1; index <= 11; index++) {
rawTags.add("[\"tag-" + String.format("%02d", index) + "\"]");
}
when(reviewRepository.findPublishedTagsByCompanyId(COMPANY_ID)).thenReturn(rawTags);
when(dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(COMPANY_ID))
.thenReturn(List.of());
ReviewSummaryView summary = service.getCompanyKnowledgeOverview(
STUDENT_USER_ID, COMPANY_ID).getReviewSummary();
assertThat(summary.getTopTags()).containsExactly(
"tag-01", "tag-02", "tag-03", "tag-04", "tag-05",
"tag-06", "tag-07", "tag-08", "tag-09", "tag-10");
}
@Test
void overview_withRecursivelyQuotedJsonTags_shouldParseTheTags() throws Exception {
stubStudent();
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty());
when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID))
.thenReturn(List.<Object[]>of(new Object[]{3L, BigDecimal.valueOf(4.0)}));
when(reviewRepository.findPublishedRatingDistributionByCompanyId(COMPANY_ID))
.thenReturn(List.of());
ObjectMapper mapper = new ObjectMapper();
String onceQuoted = mapper.writeValueAsString("[\"nested-tag\"]");
String twiceQuoted = mapper.writeValueAsString(onceQuoted);
when(reviewRepository.findPublishedTagsByCompanyId(COMPANY_ID))
.thenReturn(List.of(twiceQuoted));
when(dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(COMPANY_ID))
.thenReturn(List.of());
ReviewSummaryView summary = service.getCompanyKnowledgeOverview(
STUDENT_USER_ID, COMPANY_ID).getReviewSummary();
assertThat(summary.getTopTags()).containsExactly("nested-tag");
}
@Test
void overview_companyNotFound_shouldReturnCompanyNotFound() {
stubStudent();
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode());
}
@Test
void overview_withNonStudentRole_shouldReturnForbidden() {
when(userRepository.findById(STUDENT_USER_ID)).thenReturn(Optional.of(
User.builder().id(STUDENT_USER_ID).roles(Set.of(User.Role.COMPANY)).build()));
assertThatThrownBy(() -> service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.FORBIDDEN.getCode());
verifyNoInteractions(companyRepository, knowledgeRepository);
}
@Test
void companyKnowledge_withNonCompanyRole_shouldReturnForbidden() {
when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(
User.builder().id(COMPANY_USER_ID).roles(Set.of(User.Role.STUDENT)).build()));
assertThatThrownBy(() -> service.getCompanyKnowledge(COMPANY_USER_ID))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.FORBIDDEN.getCode());
verifyNoInteractions(companyRepository, knowledgeRepository);
}
private void stubCompanyOwner() {
when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of(
User.builder().id(COMPANY_USER_ID).roles(Set.of(User.Role.COMPANY)).build()));
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of(company()));
}
private void stubStudent() {
when(userRepository.findById(STUDENT_USER_ID)).thenReturn(Optional.of(
User.builder().id(STUDENT_USER_ID).roles(Set.of(User.Role.STUDENT)).build()));
}
private Company company() {
return Company.builder()
.id(COMPANY_ID)
.ownerUserId(COMPANY_USER_ID)
.name("Example Corp")
.logoUrl("https://example.com/logo.png")
.description("Description")
.industry("Software")
.website("https://example.com")
.address("Shanghai")
.verified(true)
.build();
}
private CompanyKnowledge knowledge(
Long id,
String culture,
String workPolicies,
String onboardingGuide,
Long version,
LocalDateTime updatedAt) {
return CompanyKnowledge.builder()
.id(id)
.companyId(COMPANY_ID)
.culture(culture)
.workPolicies(workPolicies)
.onboardingGuide(onboardingGuide)
.version(version)
.updatedAt(updatedAt)
.build();
}
private CompanyKnowledgeUpdateRequest request(
String culture, String workPolicies, String onboardingGuide, Long version) {
CompanyKnowledgeUpdateRequest request = new CompanyKnowledgeUpdateRequest();
request.setCulture(culture);
request.setWorkPolicies(workPolicies);
request.setOnboardingGuide(onboardingGuide);
request.setVersion(version);
return request;
}
}

211
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java

@ -0,0 +1,211 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest;
import org.junit.jupiter.api.BeforeEach;
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.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:onboarding_authorization;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class OnboardingAuthorizationIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(971_000_000L);
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private OnboardingChecklistItemRepository checklistItemRepository;
@Autowired private StudentOnboardingProgressRepository progressRepository;
@Autowired private CompanyKnowledgeRepository knowledgeRepository;
@Autowired private OnboardingChecklistService checklistService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
void clearData() {
progressRepository.deleteAll();
knowledgeRepository.deleteAll();
checklistItemRepository.deleteAll();
companyRepository.deleteAll();
userRepository.deleteAll();
}
@Test
void anonymousOnboardingRequestsAreUnauthorized() throws Exception {
mockMvc.perform(get("/onboarding/companies/1/checklist"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
mockMvc.perform(get("/onboarding/company/checklist-items"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
mockMvc.perform(get("/onboarding/company/knowledge"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void onboardingEndpointsRejectWrongRolesWithForbidden() throws Exception {
Long companyUser = createUser(User.Role.COMPANY);
createCompany(companyUser, "权限企业");
Long student = createUser(User.Role.STUDENT);
Long admin = createUser(User.Role.ADMIN);
mockMvc.perform(get("/onboarding/companies/1/checklist")
.header("Authorization", bearer(companyUser)))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode()));
for (Long userId : java.util.List.of(student, admin)) {
mockMvc.perform(get("/onboarding/company/checklist-items")
.header("Authorization", bearer(userId)))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode()));
}
}
@Test
void companyCannotUpdateOrDeleteAnotherCompanysItem() {
Long companyAUser = createUser(User.Role.COMPANY);
Long companyA = createCompany(companyAUser, "企业A");
Long companyBUser = createUser(User.Role.COMPANY);
createCompany(companyBUser, "企业B");
OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder()
.scopeType("COMPANY")
.scopeId(companyA)
.companyId(companyA)
.itemKey("company-a-secret")
.title("A 企业专属标题")
.category("ADMIN")
.sortOrder(100)
.required(false)
.active(true)
.build());
CompanyChecklistItemUpdateRequest update = new CompanyChecklistItemUpdateRequest();
update.setTitle("越权修改");
update.setCategory("ADMIN");
update.setSortOrder(100);
update.setRequired(false);
update.setActive(true);
update.setVersion(0L);
assertThatThrownBy(() -> checklistService.updateCompanyChecklistItem(
companyBUser, item.getId(), update))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode());
assertThatThrownBy(() -> checklistService.deleteCompanyChecklistItem(
companyBUser, item.getId(), 0L))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode());
OnboardingChecklistItem unchanged = checklistItemRepository.findById(item.getId()).orElseThrow();
assertThat(unchanged.getTitle()).isEqualTo("A 企业专属标题");
assertThat(unchanged.getActive()).isTrue();
}
@Test
void httpCrossCompanyUpdateReturnsNotFoundWithoutChangingTarget() throws Exception {
Long companyAUser = createUser(User.Role.COMPANY);
Long companyA = createCompany(companyAUser, "HTTP 企业A");
Long companyBUser = createUser(User.Role.COMPANY);
createCompany(companyBUser, "HTTP 企业B");
OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder()
.scopeType("COMPANY")
.scopeId(companyA)
.companyId(companyA)
.itemKey("http-company-a")
.title("HTTP 企业专属")
.category("ADMIN")
.sortOrder(100)
.required(false)
.active(true)
.build());
mockMvc.perform(put("/onboarding/company/checklist-items/{itemId}", item.getId())
.header("Authorization", bearer(companyBUser))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "越权修改",
"category": "ADMIN",
"sortOrder": 100,
"required": false,
"active": true,
"version": 0
}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code")
.value(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()));
assertThat(checklistItemRepository.findById(item.getId()).orElseThrow().getTitle())
.isEqualTo("HTTP 企业专属");
}
@Test
void companyWithoutProfileReturnsCompanyNotFound() throws Exception {
Long companyUser = createUser(User.Role.COMPANY);
mockMvc.perform(get("/onboarding/company/checklist-items")
.header("Authorization", bearer(companyUser)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND.getCode()));
}
private Long createCompany(Long ownerUserId, String name) {
return companyRepository.save(Company.builder()
.ownerUserId(ownerUserId)
.name(name)
.verified(true)
.build()).getId();
}
private Long createUser(User.Role role) {
User user = userRepository.save(User.builder()
.phone(String.valueOf(PHONE_SEQ.getAndIncrement()))
.nickname(role.name())
.role(role)
.roles(Set.of(role))
.active(true)
.verified(true)
.build());
return user.getId();
}
private String bearer(Long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(
userId, String.valueOf(userId), Map.of("roles", "STUDENT"));
}
}

166
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java

@ -0,0 +1,166 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.onboarding.dto.ChecklistItemView;
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class OnboardingChecklistControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider;
@MockBean private OnboardingChecklistService onboardingChecklistService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@Test
void getChecklist_shouldReturn200WithStableProgressContract() throws Exception {
when(onboardingChecklistService.getEffectiveChecklist(100L, 7L))
.thenReturn(effectiveChecklistResponse());
mockMvc.perform(get("/onboarding/companies/7/checklist")
.header("Authorization", token())
.header("X-Request-Id", "req-t3-get"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.requestId").value("req-t3-get"))
.andExpect(jsonPath("$.data.companyId").value(7))
.andExpect(jsonPath("$.data.companyName").value("示例企业"))
.andExpect(jsonPath("$.data.customized").value(true))
.andExpect(jsonPath("$.data.totalItems").value(1))
.andExpect(jsonPath("$.data.completedItems").value(1))
.andExpect(jsonPath("$.data.completionRate").value(100.0))
.andExpect(jsonPath("$.data.items[0].itemId").value(11))
.andExpect(jsonPath("$.data.items[0].source").value("STANDARD"))
.andExpect(jsonPath("$.data.items[0].progressVersion").value(2));
verify(onboardingChecklistService).getEffectiveChecklist(100L, 7L);
}
@Test
void updateCompletion_shouldReturn200WithRequestIdentityAndLatestRate() throws Exception {
when(onboardingChecklistService.updateCompletion(
eq(100L), eq(7L), eq(11L), any(ProgressUpdateRequest.class)))
.thenReturn(ProgressUpdateResponse.builder()
.itemId(11L)
.completed(true)
.completedAt(LocalDateTime.of(2026, 9, 16, 10, 30))
.progressVersion(0L)
.totalItems(1)
.completedItems(1)
.completionRate(100.0)
.build());
mockMvc.perform(put("/onboarding/companies/7/checklist/items/11/completion")
.header("Authorization", token())
.header("X-Request-Id", "req-t3-put")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"completed\":true,\"version\":0}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.requestId").value("req-t3-put"))
.andExpect(jsonPath("$.data.itemId").value(11))
.andExpect(jsonPath("$.data.completed").value(true))
.andExpect(jsonPath("$.data.progressVersion").value(0))
.andExpect(jsonPath("$.data.totalItems").value(1))
.andExpect(jsonPath("$.data.completedItems").value(1))
.andExpect(jsonPath("$.data.completionRate").value(100.0));
verify(onboardingChecklistService).updateCompletion(
eq(100L),
eq(7L),
eq(11L),
argThat(request -> Boolean.TRUE.equals(request.getCompleted())
&& Long.valueOf(0L).equals(request.getVersion())));
}
@Test
void getChecklist_withoutToken_shouldReturn401() throws Exception {
mockMvc.perform(get("/onboarding/companies/7/checklist"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void getChecklist_nonStudent_shouldReturn403() throws Exception {
when(onboardingChecklistService.getEffectiveChecklist(100L, 7L))
.thenThrow(new BusinessException(ErrorCode.FORBIDDEN));
mockMvc.perform(get("/onboarding/companies/7/checklist")
.header("Authorization", token()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode()));
}
@Test
void updateCompletion_companyMissing_shouldReturn404() throws Exception {
when(onboardingChecklistService.updateCompletion(
eq(100L), eq(7L), eq(11L), any(ProgressUpdateRequest.class)))
.thenThrow(new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
mockMvc.perform(put("/onboarding/companies/7/checklist/items/11/completion")
.header("Authorization", token())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"completed\":true,\"version\":0}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND.getCode()));
}
private String token() {
return "Bearer " + jwtTokenProvider.generateAccessToken(
100L, "1", Map.of("roles", "STUDENT"));
}
private EffectiveChecklistResponse effectiveChecklistResponse() {
return EffectiveChecklistResponse.builder()
.companyId(7L)
.companyName("示例企业")
.customized(true)
.totalItems(1)
.completedItems(1)
.completionRate(100.0)
.items(List.of(ChecklistItemView.builder()
.itemId(11L)
.source("STANDARD")
.itemKey("standard-onboard")
.title("准备入职材料")
.description("身份证和银行卡")
.category("DOCUMENT")
.required(true)
.sortOrder(10)
.active(true)
.completed(true)
.completedAt(LocalDateTime.of(2026, 9, 16, 10, 30))
.progressVersion(2L)
.build()))
.build();
}
}

305
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java

@ -0,0 +1,305 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OnboardingChecklistServiceTest {
private static final Long STUDENT_ID = 100L;
private static final Long COMPANY_ID = 7L;
private static final LocalDateTime COMPLETED_AT = LocalDateTime.of(2026, 9, 16, 10, 30);
@Mock private UserRepository userRepository;
@Mock private CompanyRepository companyRepository;
@Mock private OnboardingChecklistItemRepository checklistItemRepository;
@Mock private StudentOnboardingProgressRepository progressRepository;
@InjectMocks private OnboardingChecklistService service;
@Test
void getEffectiveChecklist_withoutCompanyItems_shouldReturnStandardFallback() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
OnboardingChecklistItem standard = item(1L, "STANDARD", null, "standard-onboard", 10);
when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID))
.thenReturn(List.of(standard));
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(List.of());
EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID);
assertThat(response.getCompanyId()).isEqualTo(COMPANY_ID);
assertThat(response.getCompanyName()).isEqualTo("示例企业");
assertThat(response.getCustomized()).isFalse();
assertThat(response.getTotalItems()).isEqualTo(1);
assertThat(response.getCompletedItems()).isZero();
assertThat(response.getCompletionRate()).isEqualTo(0.0);
assertThat(response.getItems()).singleElement().satisfies(view -> {
assertThat(view.getItemId()).isEqualTo(1L);
assertThat(view.getSource()).isEqualTo("STANDARD");
assertThat(view.getItemKey()).isEqualTo("standard-onboard");
assertThat(view.getCompleted()).isFalse();
assertThat(view.getCompletedAt()).isNull();
assertThat(view.getProgressVersion()).isZero();
});
}
@Test
void getEffectiveChecklist_withCompanyItems_shouldMergeSortAndComputeProgress() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
OnboardingChecklistItem standardSecond = item(2L, "STANDARD", null, "standard-second", 10);
OnboardingChecklistItem companyFirst = item(3L, "COMPANY", COMPANY_ID, "company-first", 10);
OnboardingChecklistItem standardThird = item(1L, "STANDARD", null, "standard-third", 20);
when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID))
.thenReturn(List.of(standardThird, companyFirst, standardSecond));
StudentOnboardingProgress completed = progress(3L, true, COMPLETED_AT, 4L);
StudentOnboardingProgress orphaned = progress(99L, true, COMPLETED_AT, 8L);
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(List.of(completed, orphaned));
EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID);
assertThat(response.getCustomized()).isTrue();
assertThat(response.getTotalItems()).isEqualTo(3);
assertThat(response.getCompletedItems()).isEqualTo(1);
assertThat(response.getCompletionRate()).isEqualTo(33.3);
assertThat(response.getItems()).extracting(view -> view.getItemId())
.containsExactly(2L, 3L, 1L);
assertThat(response.getItems().get(1).getSource()).isEqualTo("COMPANY");
assertThat(response.getItems().get(1).getCompleted()).isTrue();
assertThat(response.getItems().get(1).getCompletedAt()).isEqualTo(COMPLETED_AT);
assertThat(response.getItems().get(1).getProgressVersion()).isEqualTo(4L);
}
@Test
void getEffectiveChecklist_empty_shouldReturnZeroRate() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID)).thenReturn(List.of());
EffectiveChecklistResponse response = service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID);
assertThat(response.getItems()).isEmpty();
assertThat(response.getTotalItems()).isZero();
assertThat(response.getCompletedItems()).isZero();
assertThat(response.getCompletionRate()).isEqualTo(0.0);
assertThat(response.getCustomized()).isFalse();
}
@Test
void getEffectiveChecklist_companyMissing_shouldThrowCompanyNotFound() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode());
verifyNoInteractions(checklistItemRepository, progressRepository);
}
@Test
void getEffectiveChecklist_nonStudent_shouldThrowForbidden() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(
User.builder().id(STUDENT_ID).roles(Set.of(User.Role.COMPANY)).build()));
assertThatThrownBy(() -> service.getEffectiveChecklist(STUDENT_ID, COMPANY_ID))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.FORBIDDEN.getCode());
verifyNoInteractions(companyRepository, checklistItemRepository, progressRepository);
}
@Test
void updateCompletion_firstCompletion_shouldCreateProgressAndReturnRate() {
stubAccessAndOneItem();
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(new ArrayList<>());
when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L))
.thenReturn(Optional.empty());
when(progressRepository.saveAndFlush(any(StudentOnboardingProgress.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
ProgressUpdateResponse response = service.updateCompletion(
STUDENT_ID, COMPANY_ID, 11L, request(true, 0L));
assertThat(response.getItemId()).isEqualTo(11L);
assertThat(response.getCompleted()).isTrue();
assertThat(response.getCompletedAt()).isNotNull();
assertThat(response.getProgressVersion()).isZero();
assertThat(response.getCompletedItems()).isEqualTo(1);
assertThat(response.getTotalItems()).isEqualTo(1);
assertThat(response.getCompletionRate()).isEqualTo(100.0);
ArgumentCaptor<StudentOnboardingProgress> captor =
ArgumentCaptor.forClass(StudentOnboardingProgress.class);
verify(progressRepository).saveAndFlush(captor.capture());
assertThat(captor.getValue().getStudentUserId()).isEqualTo(STUDENT_ID);
assertThat(captor.getValue().getItemId()).isEqualTo(11L);
assertThat(captor.getValue().getCompleted()).isTrue();
assertThat(captor.getValue().getCompletedAt()).isNotNull();
}
@Test
void updateCompletion_sameStateWithStaleVersion_shouldBeIdempotent() {
stubAccessAndOneItem();
StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L);
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(List.of(existing));
when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L))
.thenReturn(Optional.of(existing));
ProgressUpdateResponse response = service.updateCompletion(
STUDENT_ID, COMPANY_ID, 11L, request(true, 2L));
assertThat(response.getCompleted()).isTrue();
assertThat(response.getCompletedAt()).isEqualTo(COMPLETED_AT);
assertThat(response.getProgressVersion()).isEqualTo(5L);
assertThat(response.getCompletionRate()).isEqualTo(100.0);
verify(progressRepository, never()).saveAndFlush(any());
}
@Test
void updateCompletion_differentStateWithStaleVersion_shouldReturnConcurrentUpdate() {
stubAccessAndOneItem();
StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L);
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(List.of(existing));
when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L))
.thenReturn(Optional.of(existing));
assertThatThrownBy(() -> service.updateCompletion(
STUDENT_ID, COMPANY_ID, 11L, request(false, 4L)))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode());
assertThat(existing.getCompleted()).isTrue();
assertThat(existing.getCompletedAt()).isEqualTo(COMPLETED_AT);
verify(progressRepository, never()).saveAndFlush(any());
}
@Test
void updateCompletion_cancel_shouldClearCompletedAt() {
stubAccessAndOneItem();
StudentOnboardingProgress existing = progress(11L, true, COMPLETED_AT, 5L);
when(progressRepository.findByStudentUserIdAndItemIdIn(eq(STUDENT_ID), anyCollection()))
.thenReturn(List.of(existing));
when(progressRepository.findByStudentUserIdAndItemId(STUDENT_ID, 11L))
.thenReturn(Optional.of(existing));
when(progressRepository.saveAndFlush(any(StudentOnboardingProgress.class)))
.thenAnswer(invocation -> {
StudentOnboardingProgress saved = invocation.getArgument(0);
saved.setVersion(6L);
return saved;
});
ProgressUpdateResponse response = service.updateCompletion(
STUDENT_ID, COMPANY_ID, 11L, request(false, 5L));
assertThat(response.getCompleted()).isFalse();
assertThat(response.getCompletedAt()).isNull();
assertThat(response.getProgressVersion()).isEqualTo(6L);
assertThat(response.getCompletedItems()).isZero();
assertThat(response.getCompletionRate()).isEqualTo(0.0);
assertThat(existing.getCompleted()).isFalse();
assertThat(existing.getCompletedAt()).isNull();
verify(progressRepository).saveAndFlush(existing);
}
@Test
void updateCompletion_itemOutsideCompanyEffectiveChecklist_shouldReturnNotFound() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID))
.thenReturn(List.of(item(11L, "COMPANY", COMPANY_ID, "company-item", 10)));
assertThatThrownBy(() -> service.updateCompletion(
STUDENT_ID, COMPANY_ID, 999L, request(true, 0L)))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode());
verifyNoInteractions(progressRepository);
}
private void stubAccessAndOneItem() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(student()));
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company()));
when(checklistItemRepository.findEffectiveChecklistItems(COMPANY_ID))
.thenReturn(List.of(item(11L, "STANDARD", null, "standard-onboard", 10)));
}
private User student() {
return User.builder().id(STUDENT_ID).roles(Set.of(User.Role.STUDENT)).build();
}
private Company company() {
return Company.builder().id(COMPANY_ID).name("示例企业").ownerUserId(900L).build();
}
private OnboardingChecklistItem item(
Long id, String scopeType, Long companyId, String itemKey, int sortOrder) {
return OnboardingChecklistItem.builder()
.id(id)
.scopeType(scopeType)
.scopeId(companyId == null ? 0L : companyId)
.companyId(companyId)
.itemKey(itemKey)
.title(itemKey + "-title")
.description(itemKey + "-description")
.category("OTHER")
.sortOrder(sortOrder)
.required(false)
.active(true)
.build();
}
private StudentOnboardingProgress progress(
Long itemId, boolean completed, LocalDateTime completedAt, Long version) {
return StudentOnboardingProgress.builder()
.id(itemId * 10)
.studentUserId(STUDENT_ID)
.itemId(itemId)
.completed(completed)
.completedAt(completedAt)
.version(version)
.build();
}
private ProgressUpdateRequest request(boolean completed, Long version) {
ProgressUpdateRequest request = new ProgressUpdateRequest();
request.setCompleted(completed);
request.setVersion(version);
return request;
}
}

277
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java

@ -0,0 +1,277 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemView;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class OnboardingCompanyChecklistControllerTest {
private static final Long COMPANY_USER_ID = 900L;
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider;
@MockBean private OnboardingChecklistService onboardingChecklistService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@Test
void listCompanyItems_shouldReturnCurrentCompanyItems() throws Exception {
when(onboardingChecklistService.listCompanyChecklistItems(COMPANY_USER_ID))
.thenReturn(List.of(view(11L, true, 0L)));
mockMvc.perform(get("/onboarding/company/checklist-items")
.header("Authorization", companyToken())
.header("X-Request-Id", "req-t4-list"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.requestId").value("req-t4-list"))
.andExpect(jsonPath("$.data[0].itemId").value(11))
.andExpect(jsonPath("$.data[0].source").value("COMPANY"))
.andExpect(jsonPath("$.data[0].active").value(true))
.andExpect(jsonPath("$.data[0].version").value(0));
verify(onboardingChecklistService).listCompanyChecklistItems(COMPANY_USER_ID);
}
@Test
void createCompanyItem_shouldReturnCreatedContract() throws Exception {
when(onboardingChecklistService.createCompanyChecklistItem(
eq(COMPANY_USER_ID), any(CompanyChecklistItemCreateRequest.class)))
.thenReturn(view(12L, true, 0L));
mockMvc.perform(post("/onboarding/company/checklist-items")
.header("Authorization", companyToken())
.header("X-Request-Id", "req-t4-create")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "Submit badge",
"description": "Bring a photo",
"category": "ADMIN",
"sortOrder": 200,
"required": false,
"idempotencyKey": "key-1"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.itemId").value(12))
.andExpect(jsonPath("$.data.source").value("COMPANY"))
.andExpect(jsonPath("$.data.version").value(0));
verify(onboardingChecklistService).createCompanyChecklistItem(
eq(COMPANY_USER_ID),
argThat(request -> "Submit badge".equals(request.getTitle())
&& Integer.valueOf(200).equals(request.getSortOrder())
&& "key-1".equals(request.getIdempotencyKey())));
}
@Test
void createCompanyItem_withInvalidFields_shouldReturn400BeforeService() throws Exception {
mockMvc.perform(post("/onboarding/company/checklist-items")
.header("Authorization", companyToken())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": " ",
"description": "ok",
"category": "INVALID",
"sortOrder": 10000,
"idempotencyKey": "key-1"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(onboardingChecklistService, never())
.createCompanyChecklistItem(any(), any());
}
@Test
void createCompanyItem_withIdempotencyConflict_shouldReturn409() throws Exception {
when(onboardingChecklistService.createCompanyChecklistItem(
eq(COMPANY_USER_ID), any(CompanyChecklistItemCreateRequest.class)))
.thenThrow(new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT));
mockMvc.perform(post("/onboarding/company/checklist-items")
.header("Authorization", companyToken())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Submit badge\",\"category\":\"ADMIN\",\"idempotencyKey\":\"same\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code")
.value(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode()));
}
@Test
void updateCompanyItem_shouldPassVersionAndReturnUpdatedView() throws Exception {
when(onboardingChecklistService.updateCompanyChecklistItem(
eq(COMPANY_USER_ID), eq(13L), any(CompanyChecklistItemUpdateRequest.class)))
.thenReturn(view(13L, false, 5L));
mockMvc.perform(put("/onboarding/company/checklist-items/13")
.header("Authorization", companyToken())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "Updated badge",
"description": "New details",
"category": "TRAINING",
"sortOrder": 300,
"required": true,
"active": false,
"version": 4
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.itemId").value(13))
.andExpect(jsonPath("$.data.active").value(false))
.andExpect(jsonPath("$.data.version").value(5));
verify(onboardingChecklistService).updateCompanyChecklistItem(
eq(COMPANY_USER_ID),
eq(13L),
argThat(request -> "Updated badge".equals(request.getTitle())
&& Long.valueOf(4L).equals(request.getVersion())));
}
@Test
void updateCompanyItem_withStaleVersion_shouldReturn409() throws Exception {
when(onboardingChecklistService.updateCompanyChecklistItem(
eq(COMPANY_USER_ID), eq(13L), any(CompanyChecklistItemUpdateRequest.class)))
.thenThrow(new BusinessException(
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE));
mockMvc.perform(put("/onboarding/company/checklist-items/13")
.header("Authorization", companyToken())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "Updated badge",
"category": "ADMIN",
"sortOrder": 300,
"required": true,
"active": true,
"version": 2
}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code")
.value(ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE.getCode()));
}
@Test
void deleteCompanyItem_shouldReturnSoftDeletedView() throws Exception {
when(onboardingChecklistService.deleteCompanyChecklistItem(
COMPANY_USER_ID, 14L, 3L))
.thenReturn(view(14L, false, 4L));
mockMvc.perform(delete("/onboarding/company/checklist-items/14")
.param("version", "3")
.header("Authorization", companyToken())
.header("X-Request-Id", "req-t4-delete"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.requestId").value("req-t4-delete"))
.andExpect(jsonPath("$.data.itemId").value(14))
.andExpect(jsonPath("$.data.active").value(false))
.andExpect(jsonPath("$.data.version").value(4));
verify(onboardingChecklistService)
.deleteCompanyChecklistItem(COMPANY_USER_ID, 14L, 3L);
}
@Test
void updateCompanyItem_forOtherCompanyItem_shouldReturn404() throws Exception {
when(onboardingChecklistService.updateCompanyChecklistItem(
eq(COMPANY_USER_ID), eq(99L), any(CompanyChecklistItemUpdateRequest.class)))
.thenThrow(new BusinessException(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND));
mockMvc.perform(put("/onboarding/company/checklist-items/99")
.header("Authorization", companyToken())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "Updated badge",
"category": "ADMIN",
"sortOrder": 300,
"required": true,
"active": true,
"version": 2
}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code")
.value(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()));
}
@Test
void companyChecklist_withoutToken_shouldReturn401() throws Exception {
mockMvc.perform(get("/onboarding/company/checklist-items"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void companyChecklist_withNonCompanyRole_shouldReturn403() throws Exception {
when(onboardingChecklistService.listCompanyChecklistItems(COMPANY_USER_ID))
.thenThrow(new BusinessException(ErrorCode.FORBIDDEN));
mockMvc.perform(get("/onboarding/company/checklist-items")
.header("Authorization", studentToken()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode()));
}
private String companyToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(
COMPANY_USER_ID, "1", Map.of("roles", "COMPANY"));
}
private String studentToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(
COMPANY_USER_ID, "1", Map.of("roles", "STUDENT"));
}
private CompanyChecklistItemView view(Long itemId, boolean active, Long version) {
return CompanyChecklistItemView.builder()
.itemId(itemId)
.source("COMPANY")
.itemKey("company-7-item-" + itemId)
.title("Submit badge")
.description("Bring a photo")
.category("ADMIN")
.required(false)
.sortOrder(200)
.active(active)
.version(version)
.build();
}
}

374
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java

@ -0,0 +1,374 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemView;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OnboardingCompanyChecklistServiceTest {
private static final Long COMPANY_USER_ID = 900L;
private static final Long COMPANY_ID = 7L;
private static final Long OTHER_COMPANY_ID = 8L;
@Mock private UserRepository userRepository;
@Mock private CompanyRepository companyRepository;
@Mock private OnboardingChecklistItemRepository checklistItemRepository;
@Mock private StudentOnboardingProgressRepository progressRepository;
@InjectMocks private OnboardingChecklistService service;
@Test
void listCompanyChecklistItems_shouldReturnOnlyOwnCompanyItemsIncludingInactive() {
stubCompanyOwner();
when(checklistItemRepository.findAll()).thenReturn(List.of(
item(1L, OTHER_COMPANY_ID, "other", "OTHER", 10, true, 0L, null),
item(2L, COMPANY_ID, "second", "ADMIN", 20, false, 4L, null),
item(3L, COMPANY_ID, "first", "DOCUMENT", 10, true, 2L, null),
OnboardingChecklistItem.builder()
.id(4L).scopeType("STANDARD").scopeId(0L).companyId(null)
.itemKey("standard").title("standard").category("OTHER")
.sortOrder(0).active(true).required(false).build()));
List<CompanyChecklistItemView> result = service.listCompanyChecklistItems(COMPANY_USER_ID);
assertThat(result).extracting(CompanyChecklistItemView::getItemId)
.containsExactly(3L, 2L);
assertThat(result).allSatisfy(view -> {
assertThat(view.getSource()).isEqualTo("COMPANY");
assertThat(view.getItemKey()).isNotBlank();
});
assertThat(result.get(1).getActive()).isFalse();
assertThat(result.get(1).getVersion()).isEqualTo(4L);
}
@Test
void createCompanyChecklistItem_shouldValidateAllInputConstraints() {
stubCompanyOwner();
List<CompanyChecklistItemCreateRequest> invalid = new ArrayList<>();
invalid.add(createRequest(" ", null, "ADMIN", 10, false, "key"));
invalid.add(createRequest("title", "x".repeat(1001), "ADMIN", 10, false, "key"));
invalid.add(createRequest("title", null, "INVALID", 10, false, "key"));
invalid.add(createRequest("title", null, "ADMIN", -1, false, "key"));
invalid.add(createRequest("title", null, "ADMIN", 10000, false, "key"));
invalid.add(createRequest("title", null, "ADMIN", 10, false, "x".repeat(65)));
for (CompanyChecklistItemCreateRequest request : invalid) {
assertThatThrownBy(() -> service.createCompanyChecklistItem(COMPANY_USER_ID, request))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode());
}
verifyNoInteractions(checklistItemRepository);
}
@Test
void createCompanyChecklistItem_shouldPersistNormalizedItemAndStableHash() {
stubCompanyOwner();
when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(List.of());
ArgumentCaptor<OnboardingChecklistItem> captor =
ArgumentCaptor.forClass(OnboardingChecklistItem.class);
when(checklistItemRepository.saveAndFlush(any(OnboardingChecklistItem.class)))
.thenAnswer(invocation -> {
OnboardingChecklistItem saved = invocation.getArgument(0);
saved.setId(11L);
saved.setVersion(0L);
return saved;
});
CompanyChecklistItemView result = service.createCompanyChecklistItem(
COMPANY_USER_ID,
createRequest(" Submit badge ", " Bring photo ", "ADMIN", null, null, " key-1 "));
verify(checklistItemRepository).saveAndFlush(captor.capture());
OnboardingChecklistItem saved = captor.getValue();
assertThat(saved.getScopeType()).isEqualTo("COMPANY");
assertThat(saved.getScopeId()).isEqualTo(COMPANY_ID);
assertThat(saved.getCompanyId()).isEqualTo(COMPANY_ID);
assertThat(saved.getItemKey()).startsWith("company-7-");
assertThat(saved.getTitle()).isEqualTo("Submit badge");
assertThat(saved.getDescription()).isEqualTo("Bring photo");
assertThat(saved.getSortOrder()).isEqualTo(1000);
assertThat(saved.getRequired()).isFalse();
assertThat(saved.getIdempotencyKey()).isEqualTo("key-1");
assertThat(saved.getRequestHash()).hasSize(64).doesNotContain("Submit badge");
assertThat(result.getItemId()).isEqualTo(11L);
assertThat(result.getVersion()).isZero();
}
@Test
void createCompanyChecklistItem_idempotentReplay_shouldReturnOriginalWithoutSecondSave() {
stubCompanyOwner();
AtomicReference<OnboardingChecklistItem> stored = new AtomicReference<>();
when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenAnswer(invocation -> stored.get() == null
? List.of() : List.of(stored.get()));
when(checklistItemRepository.saveAndFlush(any(OnboardingChecklistItem.class)))
.thenAnswer(invocation -> {
OnboardingChecklistItem saved = invocation.getArgument(0);
saved.setId(21L);
stored.set(saved);
return saved;
});
CompanyChecklistItemCreateRequest request =
createRequest("Submit badge", "Details", "ADMIN", 200, false, "same-key");
CompanyChecklistItemView first = service.createCompanyChecklistItem(COMPANY_USER_ID, request);
CompanyChecklistItemView replay = service.createCompanyChecklistItem(
COMPANY_USER_ID,
createRequest(" Submit badge ", " Details ", "ADMIN", 200, false, "same-key"));
assertThat(first.getItemId()).isEqualTo(21L);
assertThat(replay.getItemId()).isEqualTo(21L);
assertThat(replay.getTitle()).isEqualTo("Submit badge");
verify(checklistItemRepository).saveAndFlush(any(OnboardingChecklistItem.class));
}
@Test
void createCompanyChecklistItem_sameKeyDifferentBody_shouldReturnIdempotencyConflict() {
stubCompanyOwner();
OnboardingChecklistItem existing = item(
31L, COMPANY_ID, "existing", "ADMIN", 200, true, 0L, "same-key");
existing.setRequestHash("a".repeat(64));
when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(List.of(existing));
assertThatThrownBy(() -> service.createCompanyChecklistItem(
COMPANY_USER_ID,
createRequest("Different", "Details", "ADMIN", 200, false, "same-key")))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode());
verify(checklistItemRepository, never()).saveAndFlush(any());
}
@Test
void createCompanyChecklistItem_atFiftyActiveItems_shouldReturnLimitExceeded() {
stubCompanyOwner();
List<OnboardingChecklistItem> existing = new ArrayList<>();
for (int index = 0; index < 50; index++) {
existing.add(item((long) index + 1, COMPANY_ID, "item-" + index,
"OTHER", index, true, 0L, null));
}
when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(existing);
assertThatThrownBy(() -> service.createCompanyChecklistItem(
COMPANY_USER_ID,
createRequest("One more", null, "OTHER", 999, false, null)))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode());
verify(checklistItemRepository, never()).saveAndFlush(any());
}
@Test
void updateCompanyChecklistItem_withCurrentVersion_shouldApplyAllMutableFields() {
stubCompanyOwner();
OnboardingChecklistItem existing = item(
41L, COMPANY_ID, "update-me", "DOCUMENT", 100, true, 3L, null);
when(checklistItemRepository.findById(41L)).thenReturn(Optional.of(existing));
when(checklistItemRepository.saveAndFlush(existing)).thenAnswer(invocation -> {
existing.setVersion(4L);
return existing;
});
CompanyChecklistItemView result = service.updateCompanyChecklistItem(
COMPANY_USER_ID,
41L,
updateRequest("Updated", "New details", "TRAINING", 300, true, false, 3L));
assertThat(existing.getTitle()).isEqualTo("Updated");
assertThat(existing.getDescription()).isEqualTo("New details");
assertThat(existing.getCategory()).isEqualTo("TRAINING");
assertThat(existing.getSortOrder()).isEqualTo(300);
assertThat(existing.getRequired()).isTrue();
assertThat(existing.getActive()).isFalse();
assertThat(result.getVersion()).isEqualTo(4L);
}
@Test
void updateCompanyChecklistItem_withStaleVersion_shouldReturnConcurrentUpdate() {
stubCompanyOwner();
OnboardingChecklistItem existing = item(
42L, COMPANY_ID, "update-me", "DOCUMENT", 100, true, 3L, null);
when(checklistItemRepository.findById(42L)).thenReturn(Optional.of(existing));
assertThatThrownBy(() -> service.updateCompanyChecklistItem(
COMPANY_USER_ID,
42L,
updateRequest("Updated", null, "DOCUMENT", 100, true, true, 2L)))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE.getCode());
assertThat(existing.getTitle()).isEqualTo("update-me-title");
verify(checklistItemRepository, never()).saveAndFlush(any());
}
@Test
void deleteCompanyChecklistItem_shouldSoftDeleteAndRepeatIdempotently() {
stubCompanyOwner();
OnboardingChecklistItem existing = item(
51L, COMPANY_ID, "delete-me", "OTHER", 100, true, 2L, null);
when(checklistItemRepository.findById(51L)).thenReturn(Optional.of(existing));
when(checklistItemRepository.saveAndFlush(existing)).thenAnswer(invocation -> {
existing.setVersion(3L);
return existing;
});
CompanyChecklistItemView first =
service.deleteCompanyChecklistItem(COMPANY_USER_ID, 51L, 2L);
CompanyChecklistItemView repeat =
service.deleteCompanyChecklistItem(COMPANY_USER_ID, 51L, 2L);
assertThat(first.getActive()).isFalse();
assertThat(first.getVersion()).isEqualTo(3L);
assertThat(repeat.getActive()).isFalse();
assertThat(repeat.getVersion()).isEqualTo(3L);
verify(checklistItemRepository).saveAndFlush(existing);
}
@Test
void companyItemMutation_forOtherCompanyItem_shouldReturnNotFoundWithoutMutation() {
stubCompanyOwner();
OnboardingChecklistItem other = item(
61L, OTHER_COMPANY_ID, "other", "OTHER", 100, true, 1L, null);
when(checklistItemRepository.findById(61L)).thenReturn(Optional.of(other));
assertThatThrownBy(() -> service.deleteCompanyChecklistItem(COMPANY_USER_ID, 61L, 1L))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode());
assertThat(other.getActive()).isTrue();
verify(checklistItemRepository, never()).saveAndFlush(any());
}
@Test
void companyOperation_withNonCompanyRole_shouldReturnForbidden() {
when(userRepository.findById(COMPANY_USER_ID))
.thenReturn(Optional.of(User.builder()
.id(COMPANY_USER_ID)
.roles(Set.of(User.Role.STUDENT))
.build()));
assertThatThrownBy(() -> service.listCompanyChecklistItems(COMPANY_USER_ID))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.FORBIDDEN.getCode());
verifyNoInteractions(companyRepository, checklistItemRepository);
}
@Test
void companyOperation_withoutCompanyProfile_shouldReturnCompanyNotFound() {
when(userRepository.findById(COMPANY_USER_ID))
.thenReturn(Optional.of(User.builder()
.id(COMPANY_USER_ID)
.roles(Set.of(User.Role.COMPANY))
.build()));
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.listCompanyChecklistItems(COMPANY_USER_ID))
.isInstanceOf(BusinessException.class)
.extracting(ex -> ((BusinessException) ex).getErrorCodeInt())
.isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode());
verifyNoInteractions(checklistItemRepository);
}
private void stubCompanyOwner() {
when(userRepository.findById(COMPANY_USER_ID))
.thenReturn(Optional.of(User.builder()
.id(COMPANY_USER_ID)
.roles(Set.of(User.Role.COMPANY))
.build()));
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder()
.id(COMPANY_ID)
.ownerUserId(COMPANY_USER_ID)
.name("Example")
.build()));
}
private OnboardingChecklistItem item(
Long id,
Long companyId,
String itemKey,
String category,
int sortOrder,
boolean active,
Long version,
String idempotencyKey) {
return OnboardingChecklistItem.builder()
.id(id)
.scopeType("COMPANY")
.scopeId(companyId)
.companyId(companyId)
.itemKey(itemKey)
.title(itemKey + "-title")
.description(itemKey + "-description")
.category(category)
.sortOrder(sortOrder)
.required(false)
.active(active)
.version(version)
.idempotencyKey(idempotencyKey)
.build();
}
private CompanyChecklistItemCreateRequest createRequest(
String title,
String description,
String category,
Integer sortOrder,
Boolean required,
String idempotencyKey) {
CompanyChecklistItemCreateRequest request = new CompanyChecklistItemCreateRequest();
request.setTitle(title);
request.setDescription(description);
request.setCategory(category);
request.setSortOrder(sortOrder);
request.setRequired(required);
request.setIdempotencyKey(idempotencyKey);
return request;
}
private CompanyChecklistItemUpdateRequest updateRequest(
String title,
String description,
String category,
Integer sortOrder,
Boolean required,
Boolean active,
Long version) {
CompanyChecklistItemUpdateRequest request = new CompanyChecklistItemUpdateRequest();
request.setTitle(title);
request.setDescription(description);
request.setCategory(category);
request.setSortOrder(sortOrder);
request.setRequired(required);
request.setActive(active);
request.setVersion(version);
return request;
}
}

433
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java

@ -0,0 +1,433 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest;
import com.mydreamjob.onboarding.dto.CompanyChecklistItemView;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest;
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView;
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest;
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("integration-test")
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:onboarding_concurrency;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class OnboardingConcurrencyIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(972_000_000L);
private static final AtomicLong ITEM_SEQ = new AtomicLong(1L);
@Autowired private OnboardingChecklistService checklistService;
@Autowired private CompanyKnowledgeService knowledgeService;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private OnboardingChecklistItemRepository checklistItemRepository;
@Autowired private StudentOnboardingProgressRepository progressRepository;
@Autowired private CompanyKnowledgeRepository knowledgeRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
void clearData() {
progressRepository.deleteAll();
knowledgeRepository.deleteAll();
checklistItemRepository.deleteAll();
companyRepository.deleteAll();
userRepository.deleteAll();
}
@Test
void identicalIdempotencyKeyConcurrentCreatesReplaySameRow() throws Exception {
CompanyOwner owner = createCompanyOwner("幂等企业");
CompanyChecklistItemCreateRequest request = createRequest("重复请求", "same-key");
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently(
() -> checklistService.createCompanyChecklistItem(owner.userId(), request),
() -> checklistService.createCompanyChecklistItem(owner.userId(), request));
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull());
assertThat(attempts).extracting(attempt -> attempt.value().getItemId())
.containsOnly(attempts.get(0).value().getItemId());
assertThat(companyRows(owner.companyId()).stream()
.filter(item -> "same-key".equals(item.getIdempotencyKey())))
.hasSize(1);
}
@Test
void idempotencyKeyRaceWithDifferentBodiesReturns9704() throws Exception {
CompanyOwner owner = createCompanyOwner("幂等冲突企业");
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently(
() -> checklistService.createCompanyChecklistItem(
owner.userId(), createRequest("请求A", "conflict-key")),
() -> checklistService.createCompanyChecklistItem(
owner.userId(), createRequest("请求B", "conflict-key")));
assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1);
assertThat(attempts).filteredOn(Attempt::failedWithIdempotencyConflict).hasSize(1);
assertThat(companyRows(owner.companyId()).stream()
.filter(item -> "conflict-key".equals(item.getIdempotencyKey())))
.hasSize(1);
}
@Test
void inactiveItemsDoNotConsumeLimitAndExistingKeyStillReplays() throws Exception {
CompanyOwner owner = createCompanyOwner("停用项企业");
seedCompanyItems(owner.companyId(), 49);
CompanyChecklistItemCreateRequest fiftieth = createRequest("第50条", "replay-key");
CompanyChecklistItemView created = checklistService.createCompanyChecklistItem(
owner.userId(), fiftieth);
seedInactiveCompanyItems(owner.companyId(), 3);
CompanyChecklistItemCreateRequest newRequest = createRequest("第51条", "new-over-limit");
assertThat(failedWith(() -> checklistService.createCompanyChecklistItem(
owner.userId(), newRequest))).isEqualTo(
ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode());
CompanyChecklistItemView replay = checklistService.createCompanyChecklistItem(
owner.userId(), fiftieth);
assertThat(replay.getItemId()).isEqualTo(created.getItemId());
assertThat(companyRows(owner.companyId()).stream()
.filter(item -> Boolean.TRUE.equals(item.getActive())))
.hasSize(50);
}
@Test
void sameIdempotencyKeyIsIsolatedByCompany() throws Exception {
CompanyOwner first = createCompanyOwner("企业甲");
CompanyOwner second = createCompanyOwner("企业乙");
CompanyChecklistItemCreateRequest request = createRequest("独立企业请求", "shared-key");
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently(
() -> checklistService.createCompanyChecklistItem(first.userId(), request),
() -> checklistService.createCompanyChecklistItem(second.userId(), request));
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull());
assertThat(companyRows(first.companyId())).hasSize(1);
assertThat(companyRows(second.companyId())).hasSize(1);
}
@Test
void firstProgressCreateWithNonZeroVersionReturns9705() {
CompanyOwner owner = createCompanyOwner("进度版本企业");
Long student = createUser(User.Role.STUDENT);
OnboardingChecklistItem item = createStandardItem("进度版本项");
ProgressUpdateRequest request = progressRequest(true, 1L);
assertThat(failedWith(() -> checklistService.updateCompletion(
student, owner.companyId(), item.getId(), request)))
.isEqualTo(ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode());
assertThat(progressRepository.findByStudentUserIdAndItemId(student, item.getId()))
.isEmpty();
}
@Test
void concurrentFirstProgressCreatesProduceSingleRow() throws Exception {
CompanyOwner owner = createCompanyOwner("首次进度企业");
Long student = createUser(User.Role.STUDENT);
OnboardingChecklistItem item = createStandardItem("首次进度项");
List<Callable<ProgressUpdateResponse>> calls = new ArrayList<>();
for (int i = 0; i < 4; i++) {
calls.add(() -> checklistService.updateCompletion(
student, owner.companyId(), item.getId(), progressRequest(true, 0L)));
}
List<Attempt<ProgressUpdateResponse>> attempts = runConcurrently(calls);
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull());
assertThat(progressRepository.findByStudentUserIdAndItemId(student, item.getId()))
.isPresent();
assertThat(progressRepository.findAll().stream()
.filter(row -> row.getStudentUserId().equals(student)
&& row.getItemId().equals(item.getId())))
.hasSize(1);
}
@Test
void simultaneousFirstKnowledgeUpsertsAreDeterministic() throws Exception {
CompanyOwner owner = createCompanyOwner("知识并发企业");
CompanyKnowledgeUpdateRequest request = knowledgeRequest("共同文化", null, null, null);
List<Attempt<CompanyKnowledgeView>> attempts = runConcurrently(
() -> knowledgeService.upsertCompanyKnowledge(owner.userId(), request),
() -> knowledgeService.upsertCompanyKnowledge(owner.userId(), request));
assertThat(attempts).allSatisfy(attempt -> assertThat(attempt.error()).isNull());
assertThat(knowledgeRepository.findByCompanyId(owner.companyId())).isPresent();
assertThat(knowledgeRepository.findAll().stream()
.filter(row -> row.getCompanyId().equals(owner.companyId())))
.hasSize(1);
}
@Test
void identicalKnowledgeCreateReplayWithoutVersionIsIdempotent() {
CompanyOwner owner = createCompanyOwner("知识重放企业");
CompanyKnowledgeUpdateRequest request = knowledgeRequest("重放文化", "制度", null, null);
knowledgeService.upsertCompanyKnowledge(owner.userId(), request);
CompanyKnowledgeView replay = knowledgeService.upsertCompanyKnowledge(owner.userId(), request);
assertThat(replay.getConfigured()).isTrue();
assertThat(replay.getVersion()).isZero();
assertThat(knowledgeRepository.findAll().stream()
.filter(row -> row.getCompanyId().equals(owner.companyId())))
.hasSize(1);
}
@Test
void concurrentKnowledgeUpdatesWithSameVersionReturn9706() throws Exception {
CompanyOwner owner = createCompanyOwner("知识版本企业");
knowledgeRepository.save(CompanyKnowledge.builder()
.companyId(owner.companyId())
.culture("初始文化")
.build());
List<Attempt<CompanyKnowledgeView>> attempts = runConcurrently(
() -> knowledgeService.upsertCompanyKnowledge(
owner.userId(), knowledgeRequest("更新A", null, null, 0L)),
() -> knowledgeService.upsertCompanyKnowledge(
owner.userId(), knowledgeRequest("更新B", null, null, 0L)));
assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1);
assertThat(attempts).filteredOn(Attempt::failedWithKnowledgeConflict).hasSize(1);
assertThat(knowledgeRepository.findByCompanyId(owner.companyId()).orElseThrow().getVersion())
.isEqualTo(1L);
}
@Nested
@TestPropertySource(properties =
"spring.datasource.hikari.transaction-isolation=TRANSACTION_REPEATABLE_READ")
class RepeatableReadLimitBoundary {
@Test
void concurrentCreatesAtLimitBoundaryNeverWriteFiftyFirstItem() throws Exception {
CompanyOwner owner = createCompanyOwner("上限企业");
seedCompanyItems(owner.companyId(), 49);
List<Attempt<CompanyChecklistItemView>> attempts = runConcurrently(
() -> checklistService.createCompanyChecklistItem(
owner.userId(), createRequest("边界A", "limit-a")),
() -> checklistService.createCompanyChecklistItem(
owner.userId(), createRequest("边界B", "limit-b")));
assertThat(attempts).filteredOn(attempt -> attempt.error() == null).hasSize(1);
assertThat(attempts).filteredOn(Attempt::failedWithItemLimit).hasSize(1);
assertThat(companyRows(owner.companyId()))
.filteredOn(item -> Boolean.TRUE.equals(item.getActive()))
.hasSize(50);
}
}
private int failedWith(ThrowingSupplier<?> supplier) {
try {
supplier.get();
return -1;
} catch (BusinessException ex) {
return ex.getErrorCodeInt();
}
}
private CompanyChecklistItemCreateRequest createRequest(String title, String idempotencyKey) {
CompanyChecklistItemCreateRequest request = new CompanyChecklistItemCreateRequest();
request.setTitle(title);
request.setDescription(title + "说明");
request.setCategory("ADMIN");
request.setSortOrder(200);
request.setRequired(false);
request.setIdempotencyKey(idempotencyKey);
return request;
}
private ProgressUpdateRequest progressRequest(boolean completed, Long version) {
ProgressUpdateRequest request = new ProgressUpdateRequest();
request.setCompleted(completed);
request.setVersion(version);
return request;
}
private CompanyKnowledgeUpdateRequest knowledgeRequest(
String culture, String workPolicies, String onboardingGuide, Long version) {
CompanyKnowledgeUpdateRequest request = new CompanyKnowledgeUpdateRequest();
request.setCulture(culture);
request.setWorkPolicies(workPolicies);
request.setOnboardingGuide(onboardingGuide);
request.setVersion(version);
return request;
}
private List<OnboardingChecklistItem> seedCompanyItems(Long companyId, int count) {
List<OnboardingChecklistItem> items = new ArrayList<>();
for (int i = 0; i < count; i++) {
items.add(OnboardingChecklistItem.builder()
.scopeType("COMPANY")
.scopeId(companyId)
.companyId(companyId)
.itemKey("seed-" + companyId + "-" + ITEM_SEQ.getAndIncrement())
.title("种子项-" + i)
.description("种子说明")
.category("OTHER")
.sortOrder(1000 + i)
.required(false)
.active(true)
.idempotencyKey("seed-key-" + companyId + "-" + i)
.requestHash("a".repeat(64))
.build());
}
return checklistItemRepository.saveAllAndFlush(items);
}
private void seedInactiveCompanyItems(Long companyId, int count) {
List<OnboardingChecklistItem> items = new ArrayList<>();
for (int i = 0; i < count; i++) {
items.add(OnboardingChecklistItem.builder()
.scopeType("COMPANY")
.scopeId(companyId)
.companyId(companyId)
.itemKey("inactive-" + companyId + "-" + ITEM_SEQ.getAndIncrement())
.title("停用项-" + i)
.category("OTHER")
.sortOrder(2000 + i)
.required(false)
.active(false)
.build());
}
checklistItemRepository.saveAllAndFlush(items);
}
private OnboardingChecklistItem createStandardItem(String title) {
return checklistItemRepository.saveAndFlush(OnboardingChecklistItem.builder()
.scopeType("STANDARD")
.scopeId(0L)
.companyId(null)
.itemKey("standard-" + ITEM_SEQ.getAndIncrement())
.title(title)
.description("通用说明")
.category("DOCUMENT")
.sortOrder(10)
.required(true)
.active(true)
.build());
}
private List<OnboardingChecklistItem> companyRows(Long companyId) {
return checklistItemRepository.findAll().stream()
.filter(item -> companyId.equals(item.getCompanyId()))
.toList();
}
private CompanyOwner createCompanyOwner(String name) {
Long userId = createUser(User.Role.COMPANY);
Long companyId = companyRepository.save(Company.builder()
.ownerUserId(userId)
.name(name)
.verified(true)
.build()).getId();
return new CompanyOwner(userId, companyId);
}
private Long createUser(User.Role role) {
return userRepository.save(User.builder()
.phone(String.valueOf(PHONE_SEQ.getAndIncrement()))
.nickname(role.name())
.role(role)
.roles(Set.of(role))
.active(true)
.verified(true)
.build()).getId();
}
@SafeVarargs
private final <T> List<Attempt<T>> runConcurrently(Callable<T>... actions) throws Exception {
return runConcurrently(List.of(actions));
}
private <T> List<Attempt<T>> runConcurrently(List<Callable<T>> actions) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(actions.size());
CountDownLatch ready = new CountDownLatch(actions.size());
CountDownLatch start = new CountDownLatch(1);
List<Future<Attempt<T>>> futures = new ArrayList<>();
try {
for (Callable<T> action : actions) {
futures.add(pool.submit(() -> {
ready.countDown();
if (!start.await(10, TimeUnit.SECONDS)) {
throw new IllegalStateException("concurrent test start timed out");
}
try {
return new Attempt<>(action.call(), null);
} catch (Throwable ex) {
return new Attempt<>(null, ex);
}
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<Attempt<T>> results = new ArrayList<>();
for (Future<Attempt<T>> future : futures) {
results.add(future.get(20, TimeUnit.SECONDS));
}
return results;
} finally {
pool.shutdownNow();
}
}
private record CompanyOwner(Long userId, Long companyId) {
}
private record Attempt<T>(T value, Throwable error) {
boolean failedWithIdempotencyConflict() {
return error instanceof BusinessException ex
&& ex.getErrorCodeInt() == ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode();
}
boolean failedWithItemLimit() {
return error instanceof BusinessException ex
&& ex.getErrorCodeInt() == ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode();
}
boolean failedWithKnowledgeConflict() {
return error instanceof BusinessException ex
&& ex.getErrorCodeInt()
== ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode();
}
}
@FunctionalInterface
private interface ThrowingSupplier<T> {
T get();
}
}

755
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingEndToEndIntegrationTest.java

@ -0,0 +1,755 @@
package com.mydreamjob.onboarding;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.JwtTokenProvider;
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.review.JobReview;
import com.mydreamjob.review.JobReviewDimensionScore;
import com.mydreamjob.review.JobReviewDimensionScoreRepository;
import com.mydreamjob.review.JobReviewRepository;
import org.junit.jupiter.api.BeforeEach;
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.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:onboarding_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class OnboardingEndToEndIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(981_000_000L);
private static final AtomicLong ITEM_SEQ = new AtomicLong(1L);
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private JwtTokenProvider jwtTokenProvider;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private OnboardingChecklistItemRepository checklistItemRepository;
@Autowired private StudentOnboardingProgressRepository progressRepository;
@Autowired private CompanyKnowledgeRepository knowledgeRepository;
@Autowired private JobRepository jobRepository;
@Autowired private JobReviewRepository reviewRepository;
@Autowired private JobReviewDimensionScoreRepository dimensionScoreRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
void clearData() {
dimensionScoreRepository.deleteAll();
reviewRepository.deleteAll();
jobRepository.deleteAll();
progressRepository.deleteAll();
knowledgeRepository.deleteAll();
checklistItemRepository.deleteAll();
companyRepository.deleteAll();
userRepository.deleteAll();
}
@Test
void allNineOnboardingEndpointsWorkThroughHttpAndPersistence() throws Exception {
Long companyUser = createUser(User.Role.COMPANY);
Long companyId = createCompany(companyUser, "端到端企业");
Long student = createUser(User.Role.STUDENT);
OnboardingChecklistItem standardItem = saveStandardItem("e2e-standard", "准备证件", 100);
JsonNode companyItemsBefore = performJson(get("/onboarding/company/checklist-items")
.header(HttpHeaders.AUTHORIZATION, bearer(companyUser)), 200);
assertThat(companyItemsBefore.path("data").isArray()).isTrue();
assertThat(companyItemsBefore.path("data")).isEmpty();
JsonNode knowledgeBefore = performJson(get("/onboarding/company/knowledge")
.header(HttpHeaders.AUTHORIZATION, bearer(companyUser)), 200);
assertThat(knowledgeBefore.at("/data/configured").asBoolean()).isFalse();
assertThat(knowledgeBefore.at("/data/version").asLong()).isZero();
JsonNode knowledgeCreated = performJson(put("/onboarding/company/knowledge")
.header(HttpHeaders.AUTHORIZATION, bearer(companyUser))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"culture": "透明沟通",
"workPolicies": "弹性工作制",
"onboardingGuide": "A 座前台报到"
}
"""), 200);
assertThat(knowledgeCreated.at("/data/configured").asBoolean()).isTrue();
assertThat(knowledgeCreated.at("/data/version").asLong()).isZero();
assertThat(knowledgeCreated.at("/data/culture").asText()).isEqualTo("透明沟通");
JsonNode companyItemCreated = performJson(post("/onboarding/company/checklist-items")
.header(HttpHeaders.AUTHORIZATION, bearer(companyUser))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "提交导师联系方式",
"description": "入职前补充",
"category": "ADMIN",
"sortOrder": 200,
"required": false,
"idempotencyKey": "e2e-create-key"
}
"""), 200);
long companyItemId = companyItemCreated.at("/data/itemId").asLong();
long companyItemVersion = companyItemCreated.at("/data/version").asLong();
assertThat(companyItemCreated.at("/data/source").asText()).isEqualTo("COMPANY");
JsonNode companyItems = performJson(get("/onboarding/company/checklist-items")
.header(HttpHeaders.AUTHORIZATION, bearer(companyUser)), 200);
assertThat(companyItems.path("data")).hasSize(1);
assertThat(companyItems.at("/data/0/itemId").asLong()).isEqualTo(companyItemId);
JsonNode mergedChecklist = performJson(get(
"/onboarding/companies/{companyId}/checklist", companyId)
.header(HttpHeaders.AUTHORIZATION, bearer(student)), 200);
assertThat(mergedChecklist.at("/data/totalItems").asInt()).isEqualTo(2);
assertThat(mergedChecklist.at("/data/completedItems").asInt()).isZero();
assertThat(mergedChecklist.at("/data/customized").asBoolean()).isTrue();
assertThat(mergedChecklist.at("/data/items/0/source").asText()).isEqualTo("STANDARD");
assertThat(mergedChecklist.at("/data/items/1/source").asText()).isEqualTo("COMPANY");
JsonNode progress = performJson(put(
"/onboarding/companies/{companyId}/checklist/items/{itemId}/completion",
companyId, standardItem.getId())
.header(HttpHeaders.AUTHORIZATION, bearer(student))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"completed": true, "version": 0}
"""), 200);
assertThat(progress.at("/data/completed").asBoolean()).isTrue();
assertThat(progress.at("/data/completedItems").asInt()).isEqualTo(1);
assertThat(progress.at("/data/completionRate").asDouble()).isEqualTo(50.0);
JsonNode checklistAfterProgress = performJson(get(
"/onboarding/companies/{companyId}/checklist", companyId)
.header(HttpHeaders.AUTHORIZATION, bearer(student)), 200);
assertThat(checklistAfterProgress.at("/data/completedItems").asInt()).isEqualTo(1);
assertThat(checklistAfterProgress.at("/data/items/0/completed").asBoolean()).isTrue();
JsonNode studentKnowledge = performJson(get(
"/onboarding/companies/{companyId}/knowledge", companyId)
.header(HttpHeaders.AUTHORIZATION, bearer(student)), 200);
assertThat(studentKnowledge.at("/data/knowledgeState").asText()).isEqualTo("CONFIGURED");
assertThat(studentKnowledge.at("/data/knowledge/culture").asText()).isEqualTo("透明沟通");
assertThat(studentKnowledge.at("/data/reviewSummary/state").asText()).isEqualTo("EMPTY");
JsonNode companyItemUpdated = performJson(put(
"/onboarding/company/checklist-items/{itemId}", companyItemId)
.header(HttpHeaders.AUTHORIZATION, bearer(companyUser))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "提交导师联系方式(已更新)",
"description": "入职前由 HR 补充",
"category": "ADMIN",
"sortOrder": 210,
"required": true,
"active": true,
"version": %d
}
""".formatted(companyItemVersion)), 200);
long updatedVersion = companyItemUpdated.at("/data/version").asLong();
assertThat(companyItemUpdated.at("/data/title").asText())
.isEqualTo("提交导师联系方式(已更新)");
JsonNode deleted = performJson(delete(
"/onboarding/company/checklist-items/{itemId}", companyItemId)
.param("version", String.valueOf(updatedVersion))
.header(HttpHeaders.AUTHORIZATION, bearer(companyUser)), 200);
long deletedVersion = deleted.at("/data/version").asLong();
assertThat(deleted.at("/data/active").asBoolean()).isFalse();
JsonNode deletedAgain = performJson(delete(
"/onboarding/company/checklist-items/{itemId}", companyItemId)
.param("version", "0")
.header(HttpHeaders.AUTHORIZATION, bearer(companyUser)), 200);
assertThat(deletedAgain.at("/data/active").asBoolean()).isFalse();
assertThat(deletedAgain.at("/data/version").asLong()).isEqualTo(deletedVersion);
JsonNode checklistAfterDelete = performJson(get(
"/onboarding/companies/{companyId}/checklist", companyId)
.header(HttpHeaders.AUTHORIZATION, bearer(student)), 200);
assertThat(checklistAfterDelete.at("/data/totalItems").asInt()).isEqualTo(1);
assertThat(checklistAfterDelete.at("/data/completedItems").asInt()).isEqualTo(1);
assertThat(checklistAfterDelete.at("/data/completionRate").asDouble()).isEqualTo(100.0);
assertThat(checklistAfterDelete.at("/data/customized").asBoolean()).isFalse();
assertThat(progressRepository.count()).isEqualTo(1);
assertThat(knowledgeRepository.findByCompanyId(companyId)).isPresent();
}
@Test
void studentCompletionIsIdempotentCancellableAndVersionProtected() throws Exception {
Long companyUser = createUser(User.Role.COMPANY);
Long companyId = createCompany(companyUser, "进度企业");
Long student = createUser(User.Role.STUDENT);
OnboardingChecklistItem item = saveStandardItem("progress-standard", "准备材料", 100);
JsonNode first = performJson(completionRequest(companyId, item.getId(), student,
"{\"completed\": true, \"version\": 0}"), 200);
String completedAt = first.at("/data/completedAt").asText();
LocalDateTime persistedCompletedAt = progressRepository
.findByStudentUserIdAndItemId(student, item.getId())
.orElseThrow()
.getCompletedAt();
assertThat(first.at("/data/completed").asBoolean()).isTrue();
assertThat(first.at("/data/progressVersion").asLong()).isZero();
assertThat(completedAt).isNotBlank();
JsonNode repeated = performJson(completionRequest(companyId, item.getId(), student,
"{\"completed\": true, \"version\": 999}"), 200);
assertThat(repeated.at("/data/completed").asBoolean()).isTrue();
assertThat(repeated.at("/data/progressVersion").asLong()).isZero();
assertThat(LocalDateTime.parse(repeated.at("/data/completedAt").asText()))
.isEqualTo(persistedCompletedAt);
JsonNode cancelled = performJson(completionRequest(companyId, item.getId(), student,
"{\"completed\": false, \"version\": 0}"), 200);
assertThat(cancelled.at("/data/completed").asBoolean()).isFalse();
assertThat(cancelled.at("/data/completedAt").isMissingNode()
|| cancelled.at("/data/completedAt").isNull()).isTrue();
assertThat(cancelled.at("/data/progressVersion").asLong()).isEqualTo(1L);
JsonNode conflict = performJson(completionRequest(companyId, item.getId(), student,
"{\"completed\": true, \"version\": 0}"), 409);
assertThat(conflict.path("code").asInt())
.isEqualTo(ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode());
StudentOnboardingProgress unchanged = progressRepository
.findByStudentUserIdAndItemId(student, item.getId())
.orElseThrow();
assertThat(unchanged.getCompleted()).isFalse();
assertThat(unchanged.getCompletedAt()).isNull();
assertThat(unchanged.getVersion()).isEqualTo(1L);
JsonNode repeatedCancellation = performJson(completionRequest(
companyId, item.getId(), student,
"{\"completed\": false, \"version\": 1}"), 200);
assertThat(repeatedCancellation.at("/data/completed").asBoolean()).isFalse();
assertThat(repeatedCancellation.at("/data/progressVersion").asLong()).isEqualTo(1L);
assertThat(progressRepository.count()).isEqualTo(1);
}
@Test
void companyChecklistCrudEnforcesIdempotencyLimitAndOwnership() throws Exception {
Long companyAUser = createUser(User.Role.COMPANY);
Long companyA = createCompany(companyAUser, "清单企业A");
Long companyBUser = createUser(User.Role.COMPANY);
createCompany(companyBUser, "清单企业B");
String createBody = """
{
"title": "稳定幂等项",
"description": "第一次请求",
"category": "OTHER",
"sortOrder": 50,
"required": false,
"idempotencyKey": "stable-key"
}
""";
JsonNode first = performJson(post("/onboarding/company/checklist-items")
.header(HttpHeaders.AUTHORIZATION, bearer(companyAUser))
.contentType(MediaType.APPLICATION_JSON)
.content(createBody), 200);
long itemId = first.at("/data/itemId").asLong();
JsonNode replay = performJson(post("/onboarding/company/checklist-items")
.header(HttpHeaders.AUTHORIZATION, bearer(companyAUser))
.contentType(MediaType.APPLICATION_JSON)
.content(createBody), 200);
assertThat(replay.at("/data/itemId").asLong()).isEqualTo(itemId);
assertThat(checklistItemRepository.findAll().stream()
.filter(item -> itemId == item.getId())
.count()).isEqualTo(1);
JsonNode keyConflict = performJson(post("/onboarding/company/checklist-items")
.header(HttpHeaders.AUTHORIZATION, bearer(companyAUser))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "不同请求体",
"description": "第一次请求",
"category": "OTHER",
"sortOrder": 50,
"required": false,
"idempotencyKey": "stable-key"
}
"""), 409);
assertThat(keyConflict.path("code").asInt())
.isEqualTo(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode());
seedCompanyItems(companyA, 49, true);
assertThat(activeCompanyItems(companyA)).hasSize(50);
JsonNode limitExceeded = performJson(post("/onboarding/company/checklist-items")
.header(HttpHeaders.AUTHORIZATION, bearer(companyAUser))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "第五十一项",
"category": "OTHER",
"sortOrder": 999,
"required": false
}
"""), 409);
assertThat(limitExceeded.path("code").asInt())
.isEqualTo(ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode());
assertThat(activeCompanyItems(companyA)).hasSize(50);
JsonNode otherCompanyList = performJson(get("/onboarding/company/checklist-items")
.header(HttpHeaders.AUTHORIZATION, bearer(companyBUser)), 200);
assertThat(otherCompanyList.path("data")).isEmpty();
JsonNode crossUpdate = performJson(put(
"/onboarding/company/checklist-items/{itemId}", itemId)
.header(HttpHeaders.AUTHORIZATION, bearer(companyBUser))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "越权修改",
"category": "OTHER",
"sortOrder": 50,
"required": false,
"active": true,
"version": 0
}
"""), 404);
assertThat(crossUpdate.path("code").asInt())
.isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode());
JsonNode crossDelete = performJson(delete(
"/onboarding/company/checklist-items/{itemId}", itemId)
.param("version", "0")
.header(HttpHeaders.AUTHORIZATION, bearer(companyBUser)), 404);
assertThat(crossDelete.path("code").asInt())
.isEqualTo(ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode());
OnboardingChecklistItem unchanged = checklistItemRepository.findById(itemId).orElseThrow();
assertThat(unchanged.getTitle()).isEqualTo("稳定幂等项");
assertThat(unchanged.getActive()).isTrue();
assertThat(activeCompanyItems(companyA)).hasSize(50);
}
@Test
void companyKnowledgeOverviewMovesFromBasicOnlyEmptyToConfiguredInsufficientAndSufficient()
throws Exception {
Long companyUser = createUser(User.Role.COMPANY);
Long companyId = createCompany(companyUser, "知识企业");
Long student = createUser(User.Role.STUDENT);
Long jobId = createJob(companyId, "知识岗位");
JsonNode basicOnly = studentKnowledge(companyId, student, 200);
assertThat(basicOnly.at("/data/knowledgeState").asText()).isEqualTo("BASIC_ONLY");
assertThat(basicOnly.at("/data/knowledge").isMissingNode()).isTrue();
assertThat(basicOnly.at("/data/reviewSummary/state").asText()).isEqualTo("EMPTY");
performJson(put("/onboarding/company/knowledge")
.header(HttpHeaders.AUTHORIZATION, bearer(companyUser))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"culture": "务实",
"workPolicies": "固定工时",
"onboardingGuide": "提前十分钟到岗"
}
"""), 200);
JsonNode configuredEmpty = studentKnowledge(companyId, student, 200);
assertThat(configuredEmpty.at("/data/knowledgeState").asText()).isEqualTo("CONFIGURED");
assertThat(configuredEmpty.at("/data/knowledge/culture").asText()).isEqualTo("务实");
assertThat(configuredEmpty.at("/data/reviewSummary/state").asText()).isEqualTo("EMPTY");
JobReview visibleThree = saveReview(jobId, createUser(User.Role.STUDENT), 3,
"[\"visible-a\"]", "PUBLISHED", JobReview.MODERATION_APPROVED);
JobReview visibleFour = saveReview(jobId, createUser(User.Role.STUDENT), 4,
"[\"visible-b\"]", "PUBLISHED", JobReview.MODERATION_AUTO_APPROVED);
saveDimension(visibleThree, "TEAM_ATMOSPHERE", 3);
saveDimension(visibleFour, "TEAM_ATMOSPHERE", 4);
JsonNode insufficient = studentKnowledge(companyId, student, 200);
assertThat(insufficient.at("/data/reviewSummary/state").asText()).isEqualTo("INSUFFICIENT");
assertThat(insufficient.at("/data/reviewSummary/totalReviews").asInt()).isEqualTo(2);
assertThat(insufficient.at("/data/reviewSummary/averageRating").isMissingNode()).isTrue();
assertThat(insufficient.at("/data/reviewSummary/ratingDistribution").isMissingNode()).isTrue();
assertThat(insufficient.at("/data/reviewSummary/topTags").isMissingNode()).isTrue();
assertThat(insufficient.at("/data/reviewSummary/dimensionSummary").isMissingNode()).isTrue();
JobReview hidden = saveReview(jobId, createUser(User.Role.STUDENT), 1,
"[\"hidden-tag\"]", "HIDDEN", JobReview.MODERATION_APPROVED);
saveReview(jobId, createUser(User.Role.STUDENT), 1,
"[\"pending-tag\"]", "PUBLISHED", JobReview.MODERATION_PENDING);
saveDimension(hidden, "TEAM_ATMOSPHERE", 1);
JsonNode stillInsufficient = studentKnowledge(companyId, student, 200);
assertThat(stillInsufficient.at("/data/reviewSummary/state").asText())
.isEqualTo("INSUFFICIENT");
assertThat(stillInsufficient.at("/data/reviewSummary/totalReviews").asInt()).isEqualTo(2);
JobReview visibleFive = saveReview(jobId, createUser(User.Role.STUDENT), 5,
"[\"visible-c\"]", "PUBLISHED", JobReview.MODERATION_APPROVED);
saveDimension(visibleFive, "TEAM_ATMOSPHERE", 5);
JsonNode sufficient = studentKnowledge(companyId, student, 200);
assertThat(sufficient.at("/data/reviewSummary/state").asText()).isEqualTo("SUFFICIENT");
assertThat(sufficient.at("/data/reviewSummary/totalReviews").asInt()).isEqualTo(3);
assertThat(sufficient.at("/data/reviewSummary/averageRating").asDouble()).isEqualTo(4.0);
assertThat(sufficient.at("/data/reviewSummary/ratingDistribution/rating5").asInt())
.isEqualTo(1);
assertThat(sufficient.at("/data/reviewSummary/topTags")).hasSize(3);
assertThat(sufficient.at("/data/reviewSummary/topTags/0").asText()).isEqualTo("visible-a");
assertThat(sufficient.at("/data/reviewSummary/topTags/1").asText()).isEqualTo("visible-b");
assertThat(sufficient.at("/data/reviewSummary/topTags/2").asText()).isEqualTo("visible-c");
assertThat(sufficient.at(
"/data/reviewSummary/dimensionSummary/TEAM_ATMOSPHERE/averageScore").asDouble())
.isEqualTo(4.0);
assertThat(sufficient.at(
"/data/reviewSummary/dimensionSummary/TEAM_ATMOSPHERE/sampleCount").asLong())
.isEqualTo(3L);
}
@Test
void anonymousRequestsAreUnauthorizedForEveryOnboardingPath() throws Exception {
Long companyUser = createUser(User.Role.COMPANY);
Long companyId = createCompany(companyUser, "匿名企业");
OnboardingChecklistItem item = saveStandardItem("anonymous-standard", "通用项", 100);
for (MockHttpServletRequestBuilder request : allOnboardingRequests(companyId, item.getId())) {
JsonNode body = performJson(request, 401);
assertThat(body.path("code").asInt()).isEqualTo(401);
}
}
@Test
void wrongRolesAreForbiddenForEveryOnboardingPathWithoutStateChanges() throws Exception {
Long companyUser = createUser(User.Role.COMPANY);
Long companyId = createCompany(companyUser, "角色企业");
OnboardingChecklistItem item = saveStandardItem("role-standard", "通用项", 100);
Long student = createUser(User.Role.STUDENT);
Long admin = createUser(User.Role.ADMIN);
for (MockHttpServletRequestBuilder request : studentOnboardingRequests(companyId, item.getId())) {
JsonNode body = performJson(request.header(
HttpHeaders.AUTHORIZATION, bearer(companyUser)), 403);
assertThat(body.path("code").asInt()).isEqualTo(ErrorCode.FORBIDDEN.getCode());
}
for (MockHttpServletRequestBuilder request : companyOnboardingRequests(item.getId())) {
JsonNode body = performJson(request.header(
HttpHeaders.AUTHORIZATION, bearer(student)), 403);
assertThat(body.path("code").asInt()).isEqualTo(ErrorCode.FORBIDDEN.getCode());
}
for (MockHttpServletRequestBuilder request : allOnboardingRequests(companyId, item.getId())) {
JsonNode body = performJson(request.header(
HttpHeaders.AUTHORIZATION, bearer(admin)), 403);
assertThat(body.path("code").asInt()).isEqualTo(ErrorCode.FORBIDDEN.getCode());
}
assertThat(checklistItemRepository.count()).isEqualTo(1);
assertThat(checklistItemRepository.findById(item.getId()).orElseThrow().getActive()).isTrue();
assertThat(progressRepository.count()).isZero();
assertThat(knowledgeRepository.count()).isZero();
}
@Test
void notFoundResponsesDoNotCreateOnboardingRows() throws Exception {
Long student = createUser(User.Role.STUDENT);
Long companyUserWithoutProfile = createUser(User.Role.COMPANY);
assertBusinessError(performJson(get(
"/onboarding/companies/{companyId}/checklist", 999_999L)
.header(HttpHeaders.AUTHORIZATION, bearer(student)), 404),
ErrorCode.COMPANY_NOT_FOUND);
assertBusinessError(performJson(put(
"/onboarding/companies/{companyId}/checklist/items/{itemId}/completion",
999_999L, 999_999L)
.header(HttpHeaders.AUTHORIZATION, bearer(student))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"completed\": true}"), 404), ErrorCode.COMPANY_NOT_FOUND);
assertBusinessError(performJson(get(
"/onboarding/companies/{companyId}/knowledge", 999_999L)
.header(HttpHeaders.AUTHORIZATION, bearer(student)), 404),
ErrorCode.COMPANY_NOT_FOUND);
for (MockHttpServletRequestBuilder request : companyOnboardingRequests(999_999L)) {
JsonNode body = performJson(request.header(
HttpHeaders.AUTHORIZATION, bearer(companyUserWithoutProfile)), 404);
assertThat(body.path("code").asInt()).isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode());
}
Long companyOwner = createUser(User.Role.COMPANY);
Long companyId = createCompany(companyOwner, "缺失条目企业");
assertBusinessError(performJson(completionRequest(companyId, 999_999L, student,
"{\"completed\":true}"), 404),
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND);
assertBusinessError(performJson(put(
"/onboarding/company/checklist-items/{itemId}", 999_999L)
.header(HttpHeaders.AUTHORIZATION, bearer(companyOwner))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "不存在",
"category": "OTHER",
"sortOrder": 1,
"required": false,
"active": true,
"version": 0
}
"""), 404), ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND);
assertBusinessError(performJson(delete(
"/onboarding/company/checklist-items/{itemId}", 999_999L)
.param("version", "0")
.header(HttpHeaders.AUTHORIZATION, bearer(companyOwner)), 404),
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND);
assertThat(checklistItemRepository.count()).isZero();
assertThat(progressRepository.count()).isZero();
assertThat(knowledgeRepository.count()).isZero();
}
private JsonNode performJson(MockHttpServletRequestBuilder request, int expectedHttpStatus)
throws Exception {
MvcResult result = mockMvc.perform(request)
.andExpect(status().is(expectedHttpStatus))
.andReturn();
String body = result.getResponse().getContentAsString();
assertSensitiveFieldsAbsent(body);
return objectMapper.readTree(body);
}
private void assertBusinessError(JsonNode body, ErrorCode expectedCode) {
assertThat(body.path("code").asInt()).isEqualTo(expectedCode.getCode());
}
private void assertSensitiveFieldsAbsent(String body) {
assertThat(body).doesNotContain(
"\"ownerUserId\"",
"\"studentUserId\"",
"\"moderationReason\"",
"\"requestHash\"",
"\"contentHash\"",
"\"ipHash\"",
"\"uaHash\"",
"\"reviewHash\"",
"\"ipAddress\"",
"\"userAgent\"",
"\"passwordHash\"",
"\"idempotencyKey\"",
"\"token\":",
"\"accessToken\":",
"\"phone\":",
"\"email\":");
}
private JsonNode studentKnowledge(Long companyId, Long student, int status) throws Exception {
return performJson(get("/onboarding/companies/{companyId}/knowledge", companyId)
.header(HttpHeaders.AUTHORIZATION, bearer(student)), status);
}
private MockHttpServletRequestBuilder completionRequest(
Long companyId, Long itemId, Long student, String body) {
return put("/onboarding/companies/{companyId}/checklist/items/{itemId}/completion",
companyId, itemId)
.header(HttpHeaders.AUTHORIZATION, bearer(student))
.contentType(MediaType.APPLICATION_JSON)
.content(body);
}
private List<MockHttpServletRequestBuilder> allOnboardingRequests(Long companyId, Long itemId) {
List<MockHttpServletRequestBuilder> requests = new ArrayList<>();
requests.addAll(studentOnboardingRequests(companyId, itemId));
requests.addAll(companyOnboardingRequests(itemId));
return requests;
}
private List<MockHttpServletRequestBuilder> studentOnboardingRequests(
Long companyId, Long itemId) {
return List.of(
get("/onboarding/companies/{companyId}/checklist", companyId),
put("/onboarding/companies/{companyId}/checklist/items/{itemId}/completion",
companyId, itemId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"completed\":true,\"version\":0}"),
get("/onboarding/companies/{companyId}/knowledge", companyId));
}
private List<MockHttpServletRequestBuilder> companyOnboardingRequests(Long itemId) {
return List.of(
get("/onboarding/company/checklist-items"),
post("/onboarding/company/checklist-items")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "角色校验项",
"category": "OTHER",
"sortOrder": 1,
"required": false
}
"""),
put("/onboarding/company/checklist-items/{itemId}", itemId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "更新项",
"category": "OTHER",
"sortOrder": 1,
"required": false,
"active": true,
"version": 0
}
"""),
delete("/onboarding/company/checklist-items/{itemId}", itemId)
.param("version", "0"),
get("/onboarding/company/knowledge"),
put("/onboarding/company/knowledge")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"culture": "角色校验"
}
"""));
}
private OnboardingChecklistItem saveStandardItem(
String itemKey, String title, int sortOrder) {
return checklistItemRepository.saveAndFlush(OnboardingChecklistItem.builder()
.scopeType("STANDARD")
.scopeId(0L)
.companyId(null)
.itemKey(itemKey)
.title(title)
.description(title + "说明")
.category("DOCUMENT")
.sortOrder(sortOrder)
.required(true)
.active(true)
.build());
}
private void seedCompanyItems(Long companyId, int count, boolean active) {
List<OnboardingChecklistItem> items = new ArrayList<>();
for (int index = 0; index < count; index++) {
items.add(OnboardingChecklistItem.builder()
.scopeType("COMPANY")
.scopeId(companyId)
.companyId(companyId)
.itemKey("seed-" + companyId + "-" + ITEM_SEQ.getAndIncrement())
.title("种子项-" + index)
.description("种子说明")
.category("OTHER")
.sortOrder(1000 + index)
.required(false)
.active(active)
.build());
}
checklistItemRepository.saveAllAndFlush(items);
}
private List<OnboardingChecklistItem> activeCompanyItems(Long companyId) {
return checklistItemRepository.findAll().stream()
.filter(item -> companyId.equals(item.getCompanyId()))
.filter(item -> Boolean.TRUE.equals(item.getActive()))
.toList();
}
private Long createCompany(Long ownerUserId, String name) {
return companyRepository.save(Company.builder()
.ownerUserId(ownerUserId)
.name(name)
.logoUrl("https://example.com/logo.png")
.description("公司描述")
.industry("软件")
.website("https://example.com")
.address("上海")
.verified(true)
.build()).getId();
}
private Long createUser(User.Role role) {
return userRepository.save(User.builder()
.phone(String.valueOf(PHONE_SEQ.getAndIncrement()))
.nickname(role.name())
.role(role)
.roles(Set.of(role))
.active(true)
.verified(true)
.build()).getId();
}
private Long createJob(Long companyId, String title) {
return jobRepository.save(Job.builder()
.companyId(companyId)
.title(title)
.description("岗位描述")
.city("上海")
.employmentType("FULL_TIME")
.status("PUBLISHED")
.build()).getId();
}
private JobReview saveReview(
Long jobId,
Long studentUserId,
int rating,
String tags,
String status,
String moderationStatus) {
return reviewRepository.saveAndFlush(JobReview.builder()
.jobId(jobId)
.studentUserId(studentUserId)
.rating(rating)
.content("评价内容")
.tags(tags)
.status(status)
.moderationStatus(moderationStatus)
.build());
}
private void saveDimension(JobReview review, String dimensionCode, int score) {
dimensionScoreRepository.saveAndFlush(JobReviewDimensionScore.builder()
.reviewId(review.getId())
.dimensionCode(dimensionCode)
.score(score)
.build());
}
private String bearer(Long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(
userId, String.valueOf(userId), Map.of("roles", "STUDENT"));
}
}

157
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java

@ -0,0 +1,157 @@
package com.mydreamjob.onboarding;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:integrationdb_onboarding_migration;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
@ActiveProfiles("integration-test")
class OnboardingMigrationTest {
@Autowired
private DataSource dataSource;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Test
void migrationCreatesOnboardingTablesColumnsConstraintsAndIndexes() throws SQLException {
assertTableAndColumns("onboarding_checklist_items",
"id", "scope_type", "scope_id", "company_id", "item_key", "title",
"description", "category", "sort_order", "required", "active",
"idempotency_key", "request_hash", "version", "created_at", "updated_at");
assertTableAndColumns("student_onboarding_progress",
"id", "student_user_id", "item_id", "completed", "completed_at",
"version", "created_at", "updated_at");
assertTableAndColumns("company_knowledge",
"id", "company_id", "culture", "work_policies", "onboarding_guide",
"version", "created_at", "updated_at");
assertUniqueIndex("onboarding_checklist_items", "scope_type", "scope_id", "item_key");
assertUniqueIndex("onboarding_checklist_items", "company_id", "idempotency_key");
assertUniqueIndex("student_onboarding_progress", "student_user_id", "item_id");
assertUniqueIndex("company_knowledge", "company_id");
assertForeignKey("onboarding_checklist_items", "company_id", "companies", "id");
assertForeignKey("student_onboarding_progress", "student_user_id", "users", "id");
assertForeignKey("student_onboarding_progress", "item_id", "onboarding_checklist_items", "id");
assertForeignKey("company_knowledge", "company_id", "companies", "id");
assertIndex("onboarding_checklist_items",
"scope_type", "scope_id", "active", "sort_order", "id");
}
private void assertTableAndColumns(String table, String... columns) throws SQLException {
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metadata = connection.getMetaData();
String actualTable = findTable(metadata, table);
if (actualTable == null) {
throw new AssertionError("Missing table " + table);
}
List<String> actualColumns = new ArrayList<>();
try (ResultSet resultSet = metadata.getColumns(null, null, actualTable, null)) {
while (resultSet.next()) {
actualColumns.add(resultSet.getString("COLUMN_NAME").toLowerCase());
}
}
for (String column : columns) {
assertTrue(actualColumns.contains(column.toLowerCase()),
"Missing column " + table + "." + column + "; actual=" + actualColumns);
}
}
}
private void assertUniqueIndex(String table, String... columns) throws SQLException {
assertTrue(hasIndex(table, true, columns),
"Missing unique index on " + table + "(" + String.join(", ", columns) + ")");
}
private void assertIndex(String table, String... columns) throws SQLException {
assertTrue(hasIndex(table, false, columns),
"Missing index on " + table + "(" + String.join(", ", columns) + ")");
}
private boolean hasIndex(String table, boolean unique, String... expectedColumns)
throws SQLException {
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metadata = connection.getMetaData();
String actualTable = findTable(metadata, table);
if (actualTable == null) {
return false;
}
Map<String, TreeMap<Short, String>> indexes = new LinkedHashMap<>();
try (ResultSet resultSet = metadata.getIndexInfo(null, null, actualTable, false, false)) {
while (resultSet.next()) {
String indexName = resultSet.getString("INDEX_NAME");
if (indexName == null || resultSet.getBoolean("NON_UNIQUE") == unique) {
continue;
}
indexes.computeIfAbsent(indexName, ignored -> new TreeMap<>())
.put(resultSet.getShort("ORDINAL_POSITION"),
resultSet.getString("COLUMN_NAME").toLowerCase());
}
}
List<String> expected = new ArrayList<>();
for (String column : expectedColumns) {
expected.add(column.toLowerCase());
}
return indexes.values().stream()
.map(TreeMap::values)
.map(values -> new ArrayList<>(values))
.anyMatch(expected::equals);
}
}
private void assertForeignKey(String childTable, String childColumn,
String parentTable, String parentColumn) throws SQLException {
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metadata = connection.getMetaData();
String actualChildTable = findTable(metadata, childTable);
String actualParentTable = findTable(metadata, parentTable);
if (actualChildTable == null || actualParentTable == null) {
throw new AssertionError("Missing table for foreign key " + childTable + "." + childColumn);
}
try (ResultSet resultSet = metadata.getImportedKeys(null, null, actualChildTable)) {
while (resultSet.next()) {
if (actualParentTable.equalsIgnoreCase(resultSet.getString("PKTABLE_NAME"))
&& childColumn.equalsIgnoreCase(resultSet.getString("FKCOLUMN_NAME"))
&& parentColumn.equalsIgnoreCase(resultSet.getString("PKCOLUMN_NAME"))) {
return;
}
}
}
}
throw new AssertionError("Missing foreign key " + childTable + "." + childColumn
+ " -> " + parentTable + "." + parentColumn);
}
private String findTable(DatabaseMetaData metadata, String expected) throws SQLException {
try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) {
while (resultSet.next()) {
if (expected.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) {
return resultSet.getString("TABLE_NAME");
}
}
}
return null;
}
}

227
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java

@ -0,0 +1,227 @@
package com.mydreamjob.onboarding;
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.BeforeEach;
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.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:onboarding_privacy;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class OnboardingPrivacyTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(973_000_000L);
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private OnboardingChecklistItemRepository checklistItemRepository;
@Autowired private StudentOnboardingProgressRepository progressRepository;
@Autowired private CompanyKnowledgeRepository knowledgeRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
void clearData() {
progressRepository.deleteAll();
knowledgeRepository.deleteAll();
checklistItemRepository.deleteAll();
companyRepository.deleteAll();
userRepository.deleteAll();
}
@Test
void companyChecklistResponseDoesNotLeakPersistenceOrIdempotencyFields() throws Exception {
Long companyUser = createUser(User.Role.COMPANY);
createCompany(companyUser, "隐私企业");
String body = mockMvc.perform(post("/onboarding/company/checklist-items")
.header("Authorization", bearer(companyUser))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "隐私测试清单项",
"description": "仅返回契约字段",
"category": "ADMIN",
"sortOrder": 100,
"required": false,
"idempotencyKey": "private-key"
}
"""))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(body).contains("itemId", "source", "itemKey", "title", "category");
assertSensitiveFieldsAbsent(body);
assertThat(body).doesNotContain("\"idempotencyKey\":", "\"requestHash\":",
"\"scopeId\":", "\"scopeType\":", "\"companyId\":");
}
@Test
void studentChecklistResponseDoesNotLeakStudentOrPersistenceIdentifiers() throws Exception {
Long companyUser = createUser(User.Role.COMPANY);
Long companyId = createCompany(companyUser, "学生隐私企业");
Long student = createUser(User.Role.STUDENT);
OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder()
.scopeType("STANDARD")
.scopeId(0L)
.companyId(null)
.itemKey("privacy-standard")
.title("隐私通用项")
.description("说明")
.category("DOCUMENT")
.sortOrder(10)
.required(true)
.active(true)
.build());
progressRepository.save(StudentOnboardingProgress.builder()
.studentUserId(student)
.itemId(item.getId())
.completed(true)
.completedAt(LocalDateTime.now())
.build());
String body = mockMvc.perform(get("/onboarding/companies/{companyId}/checklist", companyId)
.header("Authorization", bearer(student)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(body).contains("companyId", "companyName", "items", "progressVersion");
assertSensitiveFieldsAbsent(body);
assertThat(body).doesNotContain("\"studentUserId\":", "\"passwordHash\":",
"\"requestHash\":", "\"idempotencyKey\":");
}
@Test
void studentKnowledgeOverviewDoesNotLeakOwnerOrModerationInternals() throws Exception {
Long companyUser = createUser(User.Role.COMPANY);
Long companyId = createCompany(companyUser, "知识隐私企业");
Long student = createUser(User.Role.STUDENT);
knowledgeRepository.save(CompanyKnowledge.builder()
.companyId(companyId)
.culture("文化")
.workPolicies("制度")
.onboardingGuide("指南")
.build());
String body = mockMvc.perform(get("/onboarding/companies/{companyId}/knowledge", companyId)
.header("Authorization", bearer(student)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(body).contains("knowledgeState", "company", "reviewSummary");
assertSensitiveFieldsAbsent(body);
assertThat(body).doesNotContain("\"companyId\":", "\"ownerUserId\":",
"\"moderationReason\":", "\"reviewHash\":");
}
@Test
void crossCompanyErrorDoesNotEchoTargetContentOrHashes() throws Exception {
Long ownerA = createUser(User.Role.COMPANY);
Long companyA = createCompany(ownerA, "内容企业A");
Long ownerB = createUser(User.Role.COMPANY);
createCompany(ownerB, "内容企业B");
OnboardingChecklistItem item = checklistItemRepository.save(OnboardingChecklistItem.builder()
.scopeType("COMPANY")
.scopeId(companyA)
.companyId(companyA)
.itemKey("private-target-item")
.title("不可泄露的目标标题")
.description("不可泄露的说明")
.category("ADMIN")
.sortOrder(1)
.required(false)
.active(true)
.idempotencyKey("private-target-key")
.requestHash("b".repeat(64))
.build());
String body = mockMvc.perform(put("/onboarding/company/checklist-items/{itemId}", item.getId())
.header("Authorization", bearer(ownerB))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"title": "越权请求",
"category": "ADMIN",
"sortOrder": 1,
"required": false,
"active": true,
"version": 0
}
"""))
.andExpect(status().isNotFound())
.andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain("不可泄露的目标标题", "不可泄露的说明",
"private-target-key", "b".repeat(64), "private-target-item");
assertSensitiveFieldsAbsent(body);
}
private void assertSensitiveFieldsAbsent(String body) {
assertThat(body).doesNotContain(
"ownerUserId",
"studentUserId",
"requestHash",
"idempotencyKey",
"moderationReason",
"passwordHash",
"ipAddress",
"userAgent",
"\"token\":",
"\"accessToken\":");
}
private Long createCompany(Long ownerUserId, String name) {
return companyRepository.save(Company.builder()
.ownerUserId(ownerUserId)
.name(name)
.description("公司描述")
.industry("软件")
.website("https://example.com")
.address("上海")
.verified(true)
.build()).getId();
}
private Long createUser(User.Role role) {
return userRepository.save(User.builder()
.phone(String.valueOf(PHONE_SEQ.getAndIncrement()))
.nickname(role.name())
.role(role)
.roles(Set.of(role))
.active(true)
.verified(true)
.build()).getId();
}
private String bearer(Long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(
userId, String.valueOf(userId), Map.of("roles", "STUDENT"));
}
}

435
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java

@ -0,0 +1,435 @@
package com.mydreamjob.onboarding;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.review.JobReview;
import com.mydreamjob.review.JobReviewDimensionScore;
import com.mydreamjob.review.JobReviewDimensionScoreRepository;
import com.mydreamjob.review.JobReviewRepository;
import org.hibernate.boot.model.relational.Namespace;
import org.hibernate.boot.model.relational.Sequence;
import org.hibernate.mapping.Table;
import org.junit.jupiter.api.BeforeEach;
import org.hibernate.tool.schema.spi.SchemaFilter;
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:integrationdb_onboarding_repository;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.onboarding.OnboardingRepositoryIntegrationTest$OnboardingSchemaFilterProvider"
})
@ActiveProfiles("integration-test")
@Transactional
class OnboardingRepositoryIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(900);
@Autowired private OnboardingChecklistItemRepository checklistItemRepository;
@Autowired private StudentOnboardingProgressRepository progressRepository;
@Autowired private CompanyKnowledgeRepository knowledgeRepository;
@Autowired private JobReviewRepository reviewRepository;
@Autowired private JobReviewDimensionScoreRepository dimensionScoreRepository;
@Autowired private UserRepository userRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private JobRepository jobRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
void clearData() {
progressRepository.deleteAll();
knowledgeRepository.deleteAll();
dimensionScoreRepository.deleteAll();
reviewRepository.deleteAll();
jobRepository.deleteAll();
checklistItemRepository.deleteAll();
companyRepository.deleteAll();
userRepository.deleteAll();
}
@Test
void effectiveChecklistReturnsActiveStandardAndCompanyItemsInStableOrder() {
Long companyAId = createCompany("清单企业A");
Long companyBId = createCompany("清单企业B");
OnboardingChecklistItem standardActive = saveChecklistItem(
"STANDARD", 0L, null, "standard-visible", "通用可见项", 100, true, null, null);
saveChecklistItem("STANDARD", 0L, null, "standard-inactive", "通用停用项", 1, false, null, null);
OnboardingChecklistItem companyFirst = saveChecklistItem(
"COMPANY", companyAId, companyAId, "company-a-first", "企业A首项", 50, true,
"company-a-first-key", "a".repeat(64));
OnboardingChecklistItem companySecond = saveChecklistItem(
"COMPANY", companyAId, companyAId, "company-a-second", "企业A次项", 50, true,
"company-a-second-key", "b".repeat(64));
saveChecklistItem("COMPANY", companyAId, companyAId, "company-a-inactive", "企业A停用项",
40, false, null, null);
saveChecklistItem("COMPANY", companyBId, companyBId, "company-b-visible", "企业B可见项",
10, true, "company-b-key", "c".repeat(64));
List<OnboardingChecklistItem> result = checklistItemRepository.findEffectiveChecklistItems(companyAId);
assertThat(result)
.extracting(OnboardingChecklistItem::getId)
.containsExactly(companyFirst.getId(), companySecond.getId(), standardActive.getId());
assertThat(result)
.extracting(OnboardingChecklistItem::getItemKey)
.containsExactly("company-a-first", "company-a-second", "standard-visible");
}
@Test
void requestHashIsMappedAsChar64() {
Long companyId = createCompany("哈希企业");
String hash = "0123456789abcdef".repeat(4);
OnboardingChecklistItem saved = saveChecklistItem(
"COMPANY", companyId, companyId, "company-hash", "哈希映射项", 200, true,
"company-hash-key", hash);
checklistItemRepository.flush();
assertThat(checklistItemRepository.findById(saved.getId()))
.get()
.extracting(OnboardingChecklistItem::getRequestHash)
.isEqualTo(hash);
String typeName = jdbcTemplate.queryForObject(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE UPPER(TABLE_NAME) = 'ONBOARDING_CHECKLIST_ITEMS' " +
"AND UPPER(COLUMN_NAME) = 'REQUEST_HASH'",
String.class);
Integer columnSize = jdbcTemplate.queryForObject(
"SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE UPPER(TABLE_NAME) = 'ONBOARDING_CHECKLIST_ITEMS' " +
"AND UPPER(COLUMN_NAME) = 'REQUEST_HASH'",
Integer.class);
assertThat(typeName).isNotNull().startsWith("CHAR").doesNotContain("VARCHAR");
assertThat(columnSize).isEqualTo(64);
}
@Test
void progressBatchAndUniqueLookupsAreScopedToStudentAndItem() {
Long studentOne = createUser(User.Role.STUDENT, "进度学生A");
Long studentTwo = createUser(User.Role.STUDENT, "进度学生B");
OnboardingChecklistItem firstItem = saveChecklistItem(
"STANDARD", 0L, null, "progress-item-1", "进度项1", 10, true, null, null);
OnboardingChecklistItem secondItem = saveChecklistItem(
"STANDARD", 0L, null, "progress-item-2", "进度项2", 20, true, null, null);
OnboardingChecklistItem thirdItem = saveChecklistItem(
"STANDARD", 0L, null, "progress-item-3", "进度项3", 30, true, null, null);
LocalDateTime completedAt = LocalDateTime.of(2026, 9, 16, 8, 0);
progressRepository.save(StudentOnboardingProgress.builder()
.studentUserId(studentOne).itemId(firstItem.getId()).completed(true)
.completedAt(completedAt).build());
progressRepository.save(StudentOnboardingProgress.builder()
.studentUserId(studentOne).itemId(secondItem.getId()).completed(false)
.completedAt(null).build());
progressRepository.save(StudentOnboardingProgress.builder()
.studentUserId(studentTwo).itemId(firstItem.getId()).completed(true)
.completedAt(LocalDateTime.of(2026, 9, 16, 9, 0)).build());
progressRepository.flush();
List<StudentOnboardingProgress> batch = progressRepository.findByStudentUserIdAndItemIdIn(
studentOne, List.of(firstItem.getId(), secondItem.getId(), thirdItem.getId()));
assertThat(batch)
.extracting(StudentOnboardingProgress::getItemId)
.containsExactlyInAnyOrder(firstItem.getId(), secondItem.getId());
assertThat(batch)
.filteredOn(progress -> progress.getItemId().equals(firstItem.getId()))
.singleElement()
.extracting(StudentOnboardingProgress::getCompletedAt)
.isEqualTo(completedAt);
Optional<StudentOnboardingProgress> unique = progressRepository.findByStudentUserIdAndItemId(
studentOne, firstItem.getId());
assertThat(unique).isPresent();
assertThat(unique.orElseThrow().getCompleted()).isTrue();
assertThat(progressRepository.findByStudentUserIdAndItemId(studentOne, thirdItem.getId())).isEmpty();
assertThat(progressRepository.findByStudentUserIdAndItemId(studentTwo, secondItem.getId())).isEmpty();
}
@Test
void companyKnowledgeLookupIsScopedToCompany() {
Long companyId = createCompany("知识企业");
Long otherCompanyId = createCompany("其他知识企业");
knowledgeRepository.save(CompanyKnowledge.builder()
.companyId(companyId)
.culture("重视透明沟通")
.workPolicies("弹性工作制")
.onboardingGuide("前台报到")
.build());
knowledgeRepository.flush();
Optional<CompanyKnowledge> found = knowledgeRepository.findByCompanyId(companyId);
assertThat(found).isPresent();
assertThat(found.orElseThrow().getCulture()).isEqualTo("重视透明沟通");
assertThat(found.orElseThrow().getWorkPolicies()).isEqualTo("弹性工作制");
assertThat(found.orElseThrow().getOnboardingGuide()).isEqualTo("前台报到");
assertThat(knowledgeRepository.findByCompanyId(otherCompanyId)).isEmpty();
}
@Test
void companyReviewAggregatesIncludeOnlyPublishedApprovedReviews() {
Long companyId = createCompany("评价企业A");
Long otherCompanyId = createCompany("评价企业B");
Long studentOne = createUser(User.Role.STUDENT, "评价学生A");
Long studentTwo = createUser(User.Role.STUDENT, "评价学生B");
Long studentThree = createUser(User.Role.STUDENT, "评价学生C");
Long hiddenStudent = createUser(User.Role.STUDENT, "评价学生D");
Long otherStudent = createUser(User.Role.STUDENT, "评价学生E");
Long firstJobId = createJob(companyId, "评价岗位A1");
Long secondJobId = createJob(companyId, "评价岗位A2");
Long otherJobId = createJob(otherCompanyId, "评价岗位B");
JobReview five = saveReview(firstJobId, studentOne, 5, "[\"成长空间\",\"团队氛围\"]",
"PUBLISHED", JobReview.MODERATION_APPROVED);
JobReview four = saveReview(secondJobId, studentTwo, 4, "[\"成长空间\"]",
"PUBLISHED", JobReview.MODERATION_AUTO_APPROVED);
JobReview three = saveReview(firstJobId, studentThree, 3, "[\"团队氛围\"]",
"PUBLISHED", JobReview.MODERATION_APPROVED);
JobReview hidden = saveReview(firstJobId, hiddenStudent, 1, "[\"薪资透明\"]",
"PUBLISHED", JobReview.MODERATION_PENDING);
saveReview(otherJobId, otherStudent, 2, "[\"其他企业标签\"]",
"PUBLISHED", JobReview.MODERATION_APPROVED);
dimensionScoreRepository.saveAll(List.of(
dimensionScore(five.getId(), "TEAM_ATMOSPHERE", 5),
dimensionScore(five.getId(), "GROWTH", 5),
dimensionScore(four.getId(), "TEAM_ATMOSPHERE", 4),
dimensionScore(four.getId(), "GROWTH", 4),
dimensionScore(three.getId(), "TEAM_ATMOSPHERE", 3),
dimensionScore(hidden.getId(), "TEAM_ATMOSPHERE", 1)
));
dimensionScoreRepository.flush();
List<Object[]> summary = reviewRepository.countAndAveragePublishedRatingByCompanyId(companyId);
assertThat(summary).hasSize(1);
assertThat(((Number) summary.get(0)[0]).longValue()).isEqualTo(3);
assertThat(((Number) summary.get(0)[1]).doubleValue()).isEqualTo(4.0);
List<Object[]> distribution = reviewRepository.findPublishedRatingDistributionByCompanyId(companyId);
assertThat(distribution).hasSize(3);
assertThat(((Number) distribution.get(0)[0]).intValue()).isEqualTo(3);
assertThat(((Number) distribution.get(0)[1]).longValue()).isEqualTo(1);
assertThat(((Number) distribution.get(1)[0]).intValue()).isEqualTo(4);
assertThat(((Number) distribution.get(1)[1]).longValue()).isEqualTo(1);
assertThat(((Number) distribution.get(2)[0]).intValue()).isEqualTo(5);
assertThat(((Number) distribution.get(2)[1]).longValue()).isEqualTo(1);
List<String> tags = reviewRepository.findPublishedTagsByCompanyId(companyId);
assertThat(tags).hasSize(3);
assertThat(tags).allSatisfy(tag ->
assertThat(tag).doesNotContain("薪资透明", "其他企业标签"));
assertThat(tags).anySatisfy(tag ->
assertThat(tag).contains("成长空间").contains("团队氛围"));
assertThat(tags).anySatisfy(tag ->
assertThat(tag).contains("成长空间").doesNotContain("团队氛围"));
assertThat(tags).anySatisfy(tag ->
assertThat(tag).contains("团队氛围").doesNotContain("成长空间"));
List<Object[]> dimensions = dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(companyId);
assertThat(dimensions).hasSize(2);
assertThat(dimensions.get(0)[0]).isEqualTo("GROWTH");
assertThat(((Number) dimensions.get(0)[1]).doubleValue()).isEqualTo(4.5);
assertThat(((Number) dimensions.get(0)[2]).longValue()).isEqualTo(2);
assertThat(dimensions.get(1)[0]).isEqualTo("TEAM_ATMOSPHERE");
assertThat(((Number) dimensions.get(1)[1]).doubleValue()).isEqualTo(4.0);
assertThat(((Number) dimensions.get(1)[2]).longValue()).isEqualTo(3);
}
@Test
void companyReviewAggregatesExcludeHiddenApprovedReviews() {
Long companyId = createCompany("隐藏评价企业");
Long jobId = createJob(companyId, "隐藏评价岗位");
JobReview visibleFive = saveReview(jobId, createUser(User.Role.STUDENT, "可见学生A"),
5, "[\"visible-five\"]", "PUBLISHED", JobReview.MODERATION_APPROVED);
JobReview visibleFour = saveReview(jobId, createUser(User.Role.STUDENT, "可见学生B"),
4, "[\"visible-four\"]", "PUBLISHED", JobReview.MODERATION_AUTO_APPROVED);
JobReview hidden = saveReview(jobId, createUser(User.Role.STUDENT, "隐藏学生"),
1, "[\"hidden-approved\"]", "HIDDEN", JobReview.MODERATION_APPROVED);
dimensionScoreRepository.saveAll(List.of(
dimensionScore(visibleFive.getId(), "TEAM_ATMOSPHERE", 5),
dimensionScore(visibleFour.getId(), "TEAM_ATMOSPHERE", 4),
dimensionScore(hidden.getId(), "TEAM_ATMOSPHERE", 1)
));
dimensionScoreRepository.flush();
List<Object[]> summary = reviewRepository.countAndAveragePublishedRatingByCompanyId(companyId);
assertThat(summary).hasSize(1);
assertThat(((Number) summary.get(0)[0]).longValue()).isEqualTo(2);
assertThat(((Number) summary.get(0)[1]).doubleValue()).isEqualTo(4.5);
List<String> tags = reviewRepository.findPublishedTagsByCompanyId(companyId);
assertThat(tags).hasSize(2);
assertThat(tags).allSatisfy(tag -> assertThat(tag).doesNotContain("hidden-approved"));
List<Object[]> dimensions = dimensionScoreRepository
.aggregatePublishedDimensionsByCompanyId(companyId);
assertThat(dimensions).hasSize(1);
assertThat(dimensions.get(0)[0]).isEqualTo("TEAM_ATMOSPHERE");
assertThat(((Number) dimensions.get(0)[1]).doubleValue()).isEqualTo(4.5);
assertThat(((Number) dimensions.get(0)[2]).longValue()).isEqualTo(2);
}
private Long createCompany(String name) {
Long ownerId = createUser(User.Role.COMPANY, name + "负责人");
return companyRepository.save(Company.builder()
.ownerUserId(ownerId)
.name(name)
.verified(true)
.build()).getId();
}
private Long createUser(User.Role role, String nickname) {
User user = userRepository.save(User.builder()
.phone("175" + String.format("%08d", PHONE_SEQ.incrementAndGet()))
.nickname(nickname)
.role(role)
.roles(Set.of(role))
.active(true)
.verified(true)
.build());
return user.getId();
}
private Long createJob(Long companyId, String title) {
return jobRepository.save(Job.builder()
.companyId(companyId)
.title(title)
.description("岗位描述")
.city("上海")
.employmentType("FULL_TIME")
.status("PUBLISHED")
.build()).getId();
}
private OnboardingChecklistItem saveChecklistItem(
String scopeType,
Long scopeId,
Long companyId,
String itemKey,
String title,
int sortOrder,
boolean active,
String idempotencyKey,
String requestHash) {
return checklistItemRepository.save(OnboardingChecklistItem.builder()
.scopeType(scopeType)
.scopeId(scopeId)
.companyId(companyId)
.itemKey(itemKey)
.title(title)
.description(title + "说明")
.category("OTHER")
.sortOrder(sortOrder)
.required(false)
.active(active)
.idempotencyKey(idempotencyKey)
.requestHash(requestHash)
.build());
}
private JobReview saveReview(
Long jobId,
Long studentUserId,
int rating,
String tags,
String status,
String moderationStatus) {
return reviewRepository.save(JobReview.builder()
.jobId(jobId)
.studentUserId(studentUserId)
.rating(rating)
.content("评价内容")
.tags(tags)
.status(status)
.moderationStatus(moderationStatus)
.build());
}
private JobReviewDimensionScore dimensionScore(Long reviewId, String dimensionCode, int score) {
return JobReviewDimensionScore.builder()
.reviewId(reviewId)
.dimensionCode(dimensionCode)
.score(score)
.build();
}
public static class OnboardingSchemaFilterProvider implements SchemaFilterProvider {
private static final Set<String> VALIDATED_TABLES = Set.of(
"onboarding_checklist_items",
"student_onboarding_progress",
"company_knowledge"
);
@Override
public SchemaFilter getCreateFilter() {
return SchemaFilter.ALL;
}
@Override
public SchemaFilter getDropFilter() {
return SchemaFilter.ALL;
}
@Override
public SchemaFilter getTruncatorFilter() {
return SchemaFilter.ALL;
}
@Override
public SchemaFilter getMigrateFilter() {
return SchemaFilter.ALL;
}
@Override
public SchemaFilter getValidateFilter() {
return new SchemaFilter() {
@Override
public boolean includeNamespace(Namespace namespace) {
return true;
}
@Override
public boolean includeTable(Table table) {
return VALIDATED_TABLES.contains(table.getName().toLowerCase(Locale.ROOT));
}
@Override
public boolean includeSequence(Sequence sequence) {
return false;
}
};
}
}
}

105
mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java

@ -0,0 +1,105 @@
package com.mydreamjob.onboarding;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:integrationdb_onboarding_seed;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
@ActiveProfiles("integration-test")
class OnboardingSeedDataIntegrationTest {
private static final Set<String> SUPPORTED_CATEGORIES = Set.of(
"DOCUMENT", "ADMIN", "EQUIPMENT", "TRAINING", "LOGISTICS", "OTHER");
private static final List<ExpectedStandardItem> EXPECTED_ITEMS = List.of(
new ExpectedStandardItem("standard-id-card", "准备身份证件", "DOCUMENT", 100, true),
new ExpectedStandardItem("standard-education-proof", "准备学历学位证明", "DOCUMENT", 110, true),
new ExpectedStandardItem("standard-id-photo", "准备证件照", "DOCUMENT", 120, false),
new ExpectedStandardItem("standard-bank-account", "提供工资卡信息", "ADMIN", 200, true),
new ExpectedStandardItem("standard-emergency-contact", "填写紧急联系人", "ADMIN", 210, true),
new ExpectedStandardItem("standard-background-check", "完成背景核验", "ADMIN", 220, false),
new ExpectedStandardItem("standard-medical-check", "确认入职体检要求", "ADMIN", 230, false),
new ExpectedStandardItem("standard-equipment-request", "确认办公设备需求", "EQUIPMENT", 300, false),
new ExpectedStandardItem("standard-report-location", "确认报到地址和时间", "LOGISTICS", 400, true),
new ExpectedStandardItem("standard-commute-plan", "规划通勤路线", "LOGISTICS", 410, false),
new ExpectedStandardItem("standard-compliance-training", "完成合规与保密培训", "TRAINING", 500, true),
new ExpectedStandardItem("standard-onboarding-guide", "阅读入职指南", "TRAINING", 510, false)
);
@Autowired
private JdbcTemplate jdbcTemplate;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Test
void standardChecklistSeedIsStableAndComplete() {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT item_key, title, category, sort_order, required, active, " +
"scope_type, scope_id, company_id " +
"FROM onboarding_checklist_items " +
"WHERE scope_type = 'STANDARD' ORDER BY sort_order ASC, id ASC");
assertEquals(EXPECTED_ITEMS.size(), rows.size());
for (int index = 0; index < EXPECTED_ITEMS.size(); index++) {
ExpectedStandardItem expected = EXPECTED_ITEMS.get(index);
Map<String, Object> actual = rows.get(index);
assertEquals(expected.itemKey(), actual.get("item_key"));
assertEquals(expected.title(), actual.get("title"));
assertEquals(expected.category(), actual.get("category"));
assertEquals(expected.sortOrder(), ((Number) actual.get("sort_order")).intValue());
assertEquals(expected.required(), asBoolean(actual.get("required")));
assertTrue(asBoolean(actual.get("active")));
assertEquals("STANDARD", actual.get("scope_type"));
assertEquals(0L, ((Number) actual.get("scope_id")).longValue());
assertNull(actual.get("company_id"));
assertTrue(SUPPORTED_CATEGORIES.contains(expected.category()));
assertNotNull(actual.get("title"));
}
assertEquals(EXPECTED_ITEMS.size(), count(
"SELECT COUNT(DISTINCT item_key) FROM onboarding_checklist_items " +
"WHERE scope_type = 'STANDARD'"));
}
private int count(String sql) {
Integer count = jdbcTemplate.queryForObject(sql, Integer.class);
return count == null ? 0 : count;
}
private boolean asBoolean(Object value) {
if (value instanceof Boolean booleanValue) {
return booleanValue;
}
if (value instanceof Number numberValue) {
return numberValue.intValue() != 0;
}
return Boolean.parseBoolean(String.valueOf(value));
}
private record ExpectedStandardItem(
String itemKey,
String title,
String category,
int sortOrder,
boolean required
) {
}
}

129
specs/company-knowledge/spec.md

@ -0,0 +1,129 @@
# Company Knowledge Specification
## Purpose
定义公司知识配置、学生公司知识聚合和公开评价小样本保护行为。
## Requirements
### Requirement: 学生读取公司知识介绍
系统 SHALL 允许已登录 STUDENT 通过 `GET /onboarding/companies/{companyId}/knowledge` 读取公司知识介绍。响应 MUST 聚合企业基础资料、企业维护的知识内容以及公开评价摘要,并使用白名单 DTO 排除 `ownerUserId` 等内部字段。
#### Scenario: 企业已维护知识内容
- **WHEN** STUDENT 请求存在企业且该企业已有知识内容
- **THEN** 系统返回企业基础资料、文化、工作制度、入职指南和更新时间
- **AND** `knowledgeState` 为 `CONFIGURED`
- **AND** 不返回企业 `ownerUserId`、员工用户 ID、评价哈希或审核原因
#### Scenario: 企业未维护知识内容
- **WHEN** STUDENT 请求存在企业且该企业没有知识内容记录
- **THEN** 系统仍返回企业基础资料
- **AND** `knowledgeState` 为 `BASIC_ONLY`
- **AND** 知识内容字段为 `null` 或空集合,不返回 404
#### Scenario: 企业不存在
- **WHEN** STUDENT 请求不存在的 `companyId`
- **THEN** 系统返回 404 `COMPANY_NOT_FOUND`
### Requirement: 公司知识评价摘要使用公开数据和小样本保护
系统 SHALL 聚合该企业全部岗位中状态为 `PUBLISHED` 且审核状态为 `AUTO_APPROVED` 或 `APPROVED` 的评价。公开评价少于 3 条时 MUST 返回 `INSUFFICIENT` 或 `EMPTY`,且不得返回平均分或维度均值。达到 3 条时 MAY 返回平均评分、评分分布、热门标签和八维均值。
#### Scenario: 无公开评价
- **WHEN** 企业没有任何符合条件的公开评价
- **THEN** `reviewSummary.state` 为 `EMPTY`
- **AND** `totalReviews` 为 0
- **AND** `averageRating` 与 `dimensionSummary` 为空
#### Scenario: 公开评价少于 3 条
- **WHEN** 企业只有 1 或 2 条符合条件的公开评价
- **THEN** `reviewSummary.state` 为 `INSUFFICIENT`
- **AND** 系统返回真实 `totalReviews`
- **AND** 不返回平均分、评分分布或维度均值
#### Scenario: 公开评价达到 3 条
- **WHEN** 企业有至少 3 条符合条件的公开评价
- **THEN** `reviewSummary.state` 为 `SUFFICIENT`
- **AND** 系统返回平均评分、评分分布、热门标签和维度汇总
- **AND** 每个维度返回样本数及是否达到小样本阈值
### Requirement: 企业读取本企业知识配置
系统 SHALL 允许 COMPANY 通过 `GET /onboarding/company/knowledge` 读取本企业知识配置。无记录时 MUST 返回 `configured=false` 和空内容,而不是 404;有记录时 MUST 返回内容和当前版本。
#### Scenario: 尚未配置
- **WHEN** COMPANY 请求本企业知识配置且尚未创建记录
- **THEN** 系统返回 `configured=false`
- **AND** 内容字段为空,版本为 `0`
#### Scenario: 已配置
- **WHEN** COMPANY 请求本企业已有知识配置
- **THEN** 系统返回文化、工作制度、入职指南、更新时间和当前版本
- **AND** `configured=true`
### Requirement: 企业 Upsert 公司知识内容
系统 SHALL 允许 COMPANY 通过 `PUT /onboarding/company/knowledge` 创建或更新本企业知识内容。字段 MUST 使用纯文本并受长度限制,不允许 HTML 富文本。更新已有记录时 MUST 使用乐观锁版本;版本冲突返回 409 `ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE`。
#### Scenario: 首次创建
- **WHEN** COMPANY 首次提交合法的知识内容
- **THEN** 系统创建绑定该企业的知识记录
- **AND** 返回 `configured=true` 和版本 `0`
#### Scenario: 使用当前版本更新
- **WHEN** COMPANY 使用当前版本提交新的文化、制度或入职指南
- **THEN** 系统更新记录并递增版本
- **AND** 返回最新内容
#### Scenario: 使用过期版本更新
- **WHEN** COMPANY 使用过期版本提交知识内容
- **THEN** 系统返回 409 `ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE`
- **AND** 不覆盖数据库中的新内容
#### Scenario: 非法内容
- **WHEN** 请求字段超出最大长度或包含不受支持的格式
- **THEN** 系统返回 400 `BAD_REQUEST`
- **AND** 不写入知识记录
### Requirement: 公司知识权限与所有权隔离
学生读取接口 MUST 仅允许 STUDENT;企业配置接口 MUST 仅允许 COMPANY 且只操作当前用户拥有的企业。任何跨企业读取配置或修改操作 MUST 被拒绝。
#### Scenario: 企业访问本企业配置
- **WHEN** COMPANY 读取或更新自己的知识记录
- **THEN** 系统允许操作
#### Scenario: 非企业访问企业配置
- **WHEN** STUDENT、ADMIN 或未登录用户访问企业知识配置接口
- **THEN** 系统返回 401 或 403
- **AND** 不读取或修改企业配置
#### Scenario: 不存在的企业
- **WHEN** STUDENT 请求不存在的公司知识
- **THEN** 系统返回 404 `COMPANY_NOT_FOUND`
### Requirement: 不改变既有评价与企业接口语义
本变更 MUST 只读复用既有企业、岗位、评价和维度评分数据,不得修改既有表结构、响应字段或权限语义。
#### Scenario: 既有评价接口回归
- **WHEN** 执行已有评价与企业模块测试
- **THEN** 所有既有测试保持通过
- **AND** OpenAPI 中既有路径的请求与响应语义不变

155
specs/onboarding-checklist/spec.md

@ -0,0 +1,155 @@
# Onboarding Checklist Specification
## Purpose
定义学生入职清单读取、个人完成状态、企业自定义项管理和通用清单回退行为。
## Requirements
### Requirement: 学生读取有效入职清单
系统 SHALL 允许已登录 STUDENT 按 `companyId` 读取由通用清单与企业活动自定义项组成的有效入职清单。响应 MUST 包含企业、当前学生完成状态、完成数量、总数量、完成率、是否是企业自定义配置,以及按 `sortOrder`、`id` 稳定排序的条目。
#### Scenario: 企业未配置自定义项
- **WHEN** STUDENT 请求存在企业的清单,且该企业没有活动自定义项
- **THEN** 系统返回全部活动通用清单项
- **AND** `customized` 为 `false`
- **AND** 每个条目返回标准来源、分类、标题、说明、必填标记和当前学生的完成状态
#### Scenario: 企业已配置自定义项
- **WHEN** STUDENT 请求存在企业的清单,且该企业存在活动自定义项
- **THEN** 系统返回活动通用项与企业自定义项
- **AND** 通用项与企业项使用稳定排序,响应 `customized` 为 `true`
- **AND** 每个条目的 `itemId` 和 `source` 足以用于后续完成状态更新
#### Scenario: 企业不存在
- **WHEN** STUDENT 请求不存在的 `companyId`
- **THEN** 系统返回 404 与既有 `COMPANY_NOT_FOUND` 错误码
- **AND** 不创建完成状态行
### Requirement: 学生更新个人清单完成状态
系统 SHALL 允许 STUDENT 通过 `PUT /onboarding/companies/{companyId}/checklist/items/{itemId}/completion` 将本人对指定有效条目的状态设置为完成或未完成。请求 MUST 使用绝对状态语义,重复提交同一状态 MUST 不创建重复记录。响应 MUST 返回条目状态、版本和更新后的完成率。
#### Scenario: 首次标记完成
- **WHEN** STUDENT 对本人可访问的条目提交 `completed=true`
- **THEN** 系统创建该学生与该条目的完成记录
- **AND** 设置 `completedAt`
- **AND** 返回更新后的完成数量、总数量和完成率
#### Scenario: 重复设置相同状态
- **WHEN** STUDENT 对同一条目重复提交相同 `completed` 值
- **THEN** 系统返回幂等成功结果
- **AND** 数据库仍只有一条学生与条目完成记录
- **AND** 不改变首次完成后的业务结果
#### Scenario: 取消完成
- **WHEN** STUDENT 对已完成条目的提交 `completed=false`
- **THEN** 系统将记录置为未完成
- **AND** 清除 `completedAt`
- **AND** 返回更新后的完成率
#### Scenario: 条目不属于该企业
- **WHEN** STUDENT 使用属于另一企业自定义项的 `itemId` 调用更新接口
- **THEN** 系统返回 404 `ONBOARDING_CHECKLIST_ITEM_NOT_FOUND`
- **AND** 不允许创建或修改完成记录
### Requirement: 企业创建自定义清单项
系统 SHALL 允许 COMPANY 为本企业创建自定义清单项。请求 MUST 校验标题、说明、分类、排序值和必填标记。请求携带 `idempotencyKey` 时,同一企业、同一 key 且请求体相同 MUST 返回原条目;请求体不同 MUST 返回 409 `ONBOARDING_IDEMPOTENCY_CONFLICT`。
#### Scenario: 创建成功
- **WHEN** COMPANY 提交合法的标题、分类和可选说明
- **THEN** 系统创建仅属于该企业的活动条目
- **AND** 返回条目 ID、来源 `COMPANY`、版本、排序值和必填标记
#### Scenario: 幂等重放
- **WHEN** COMPANY 使用相同 `idempotencyKey` 和相同请求体重复创建
- **THEN** 系统返回第一次创建的条目
- **AND** 数据库不产生第二条记录
#### Scenario: 幂等键内容冲突
- **WHEN** COMPANY 重复使用同一 `idempotencyKey`,但标题、分类、说明、排序或必填标记不同
- **THEN** 系统返回 409 `ONBOARDING_IDEMPOTENCY_CONFLICT`
#### Scenario: 超出企业条目上限
- **WHEN** COMPANY 已拥有 50 条活动自定义项并继续创建
- **THEN** 系统返回 409 `ONBOARDING_ITEM_LIMIT_EXCEEDED`
- **AND** 不写入第 51 条活动条目
### Requirement: 企业查询和更新自定义清单项
系统 SHALL 允许 COMPANY 查询本企业全部自定义项,并通过带版本号的更新请求修改标题、说明、分类、排序、必填标记和活动状态。版本冲突 MUST 返回 409 `ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE`,不得静默覆盖。
#### Scenario: 查询本企业条目
- **WHEN** COMPANY 请求本企业自定义条目列表
- **THEN** 系统返回该企业条目,包括活动与软删除条目及当前版本
- **AND** 不返回其他企业的条目
#### Scenario: 正常更新
- **WHEN** COMPANY 使用当前 `version` 更新本企业条目
- **THEN** 系统保存修改并递增版本
- **AND** 返回更新后的条目
#### Scenario: 版本过期
- **WHEN** COMPANY 使用过期 `version` 更新条目
- **THEN** 系统返回 409 `ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE`
- **AND** 不覆盖数据库中较新的值
### Requirement: 企业软删除自定义清单项
系统 SHALL 允许 COMPANY 通过带当前 `version` 的删除请求软删除本企业条目。软删除 MUST 只将条目标记为非活动,不物理删除条目或学生历史完成记录。重复删除已非活动条目 MUST 返回幂等成功结果。
#### Scenario: 首次删除
- **WHEN** COMPANY 使用当前版本删除本企业活动条目
- **THEN** 系统将条目 `active` 置为 `false`
- **AND** 该条目不再出现在学生有效清单中
- **AND** 既有完成记录保留
#### Scenario: 重复删除
- **WHEN** COMPANY 再次删除已软删除条目
- **THEN** 系统返回成功
- **AND** 不产生第二次数据变更
#### Scenario: 删除其他企业条目
- **WHEN** COMPANY 删除不属于本企业的条目
- **THEN** 系统返回 404 `ONBOARDING_CHECKLIST_ITEM_NOT_FOUND`
- **AND** 目标条目保持原状态
### Requirement: 清单权限和数据最小化
学生接口 MUST 仅允许 STUDENT;企业配置接口 MUST 仅允许 COMPANY 且操作绑定当前用户拥有的企业。所有响应 MUST 使用白名单 DTO,不返回用户密码、Token、IP、User-Agent、评价审核原因或其他企业内部的非必要字段。
#### Scenario: 非学生访问学生清单
- **WHEN** COMPANY、ADMIN 或未登录用户访问学生清单接口
- **THEN** 系统返回 401 或 403
- **AND** 不返回清单或完成状态
#### Scenario: 非企业访问企业配置
- **WHEN** STUDENT、ADMIN 或未登录用户访问企业配置接口
- **THEN** 系统返回 401 或 403
- **AND** 不创建或修改企业条目
#### Scenario: 企业无绑定资料
- **WHEN** COMPANY 用户尚未创建企业资料却调用企业配置接口
- **THEN** 系统返回 404 `COMPANY_NOT_FOUND`
Loading…
Cancel
Save