16 changed files with 1661 additions and 6 deletions
@ -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 |
|||
``` |
|||
@ -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 |
|||
``` |
|||
|
|||
@ -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 |
|||
``` |
|||
|
|||
@ -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. |
|||
@ -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. |
|||
@ -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 |
|||
``` |
|||
@ -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`。 |
|||
@ -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。 |
|||
@ -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 而遗漏契约错误路径。 |
|||
- 测试在随机顺序下仍稳定,不依赖真实时钟精度或外部服务。 |
|||
@ -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 注解一致,无未说明错误。 |
|||
- 全量测试和打包通过。 |
|||
@ -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")); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue