From a2c66b3f7de274ead91c4756baa11f7ba619fe09 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 15:58:46 +0800 Subject: [PATCH 01/17] docs: add N04 onboarding planning artifacts --- changes/n04-onboard/.spec-superflow.yaml | 33 ++ .../.superpowers/sdd/execution-plan.yaml | 24 ++ .../n04-onboard/.superpowers/sdd/progress.md | 6 + changes/n04-onboard/design.md | 329 ++++++++++++++++++ changes/n04-onboard/execution-contract.md | 165 +++++++++ changes/n04-onboard/proposal.md | 65 ++++ .../specs/company-knowledge/spec.md | 125 +++++++ .../specs/onboarding-checklist/spec.md | 151 ++++++++ changes/n04-onboard/tasks.md | 47 +++ 9 files changed, 945 insertions(+) create mode 100644 changes/n04-onboard/.spec-superflow.yaml create mode 100644 changes/n04-onboard/.superpowers/sdd/execution-plan.yaml create mode 100644 changes/n04-onboard/.superpowers/sdd/progress.md create mode 100644 changes/n04-onboard/design.md create mode 100644 changes/n04-onboard/execution-contract.md create mode 100644 changes/n04-onboard/proposal.md create mode 100644 changes/n04-onboard/specs/company-knowledge/spec.md create mode 100644 changes/n04-onboard/specs/onboarding-checklist/spec.md create mode 100644 changes/n04-onboard/tasks.md diff --git a/changes/n04-onboard/.spec-superflow.yaml b/changes/n04-onboard/.spec-superflow.yaml new file mode 100644 index 0000000..f0d52d9 --- /dev/null +++ b/changes/n04-onboard/.spec-superflow.yaml @@ -0,0 +1,33 @@ +version: 1 +state: executing +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: 0 diff --git a/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml b/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml new file mode 100644 index 0000000..7c9b364 --- /dev/null +++ b/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml @@ -0,0 +1,24 @@ +plan_revision: 1 +selected_mode: sdd +contract_hash: 79e718e36a2fd349a8c26dfd080e74a1844c01a6e52363613414694bc75a74d1 +created_at: 2026-09-16T07:58:31Z +current: W1 +waves: + - id: W1 + strategy: serial + tasks: [T1, T2] + depends_on: [] + state: eligible + receipt: pending + - id: W2 + strategy: serial + tasks: [T3, T4, T5] + depends_on: [W1] + state: blocked + receipt: pending + - id: W3 + strategy: serial + tasks: [T6, T7, T8] + depends_on: [W2] + state: blocked + receipt: pending \ No newline at end of file diff --git a/changes/n04-onboard/.superpowers/sdd/progress.md b/changes/n04-onboard/.superpowers/sdd/progress.md new file mode 100644 index 0000000..86f62ba --- /dev/null +++ b/changes/n04-onboard/.superpowers/sdd/progress.md @@ -0,0 +1,6 @@ +# 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. \ No newline at end of file diff --git a/changes/n04-onboard/design.md b/changes/n04-onboard/design.md new file mode 100644 index 0000000..79594c8 --- /dev/null +++ b/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`。 + +## 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` | +| 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 对比路径数 | \ No newline at end of file diff --git a/changes/n04-onboard/execution-contract.md b/changes/n04-onboard/execution-contract.md new file mode 100644 index 0000000..985f994 --- /dev/null +++ b/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`;请求 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`。 \ No newline at end of file diff --git a/changes/n04-onboard/proposal.md b/changes/n04-onboard/proposal.md new file mode 100644 index 0000000..b2884d0 --- /dev/null +++ b/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 个路径、请求体和响应模型,且与运行时文档一致。 \ No newline at end of file diff --git a/changes/n04-onboard/specs/company-knowledge/spec.md b/changes/n04-onboard/specs/company-knowledge/spec.md new file mode 100644 index 0000000..485a83a --- /dev/null +++ b/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 中既有路径的请求与响应语义不变 \ No newline at end of file diff --git a/changes/n04-onboard/specs/onboarding-checklist/spec.md b/changes/n04-onboard/specs/onboarding-checklist/spec.md new file mode 100644 index 0000000..3f68693 --- /dev/null +++ b/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` \ No newline at end of file diff --git a/changes/n04-onboard/tasks.md b/changes/n04-onboard/tasks.md new file mode 100644 index 0000000..621aa20 --- /dev/null +++ b/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。 +- 不允许降低既有接口语义、删除业务数据或跳过迁移验证。 \ No newline at end of file From 7fe96cc7a84473f956b5c641473d833c436e31d9 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 16:08:06 +0800 Subject: [PATCH 02/17] feat: add onboarding data model migrations --- .../.superpowers/sdd/task-briefs/T1.md | 50 ++++++ .../.superpowers/sdd/task-briefs/T2.md | 53 ++++++ ...arding_checklist_and_company_knowledge.sql | 57 +++++++ ...39__onboarding_standard_checklist_seed.sql | 31 ++++ .../onboarding/OnboardingMigrationTest.java | 157 ++++++++++++++++++ .../OnboardingSeedDataIntegrationTest.java | 105 ++++++++++++ 6 files changed, 453 insertions(+) create mode 100644 changes/n04-onboard/.superpowers/sdd/task-briefs/T1.md create mode 100644 changes/n04-onboard/.superpowers/sdd/task-briefs/T2.md create mode 100644 mydreamjob-server/src/main/resources/db/migration/V38__onboarding_checklist_and_company_knowledge.sql create mode 100644 mydreamjob-server/src/main/resources/db/migration/V39__onboarding_standard_checklist_seed.sql create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T1.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T1.md new file mode 100644 index 0000000..2f9108f --- /dev/null +++ b/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。 \ No newline at end of file diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T2.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T2.md new file mode 100644 index 0000000..9b1be25 --- /dev/null +++ b/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/八维聚合仅统计公开评价。 \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/db/migration/V38__onboarding_checklist_and_company_knowledge.sql b/mydreamjob-server/src/main/resources/db/migration/V38__onboarding_checklist_and_company_knowledge.sql new file mode 100644 index 0000000..30fe9ad --- /dev/null +++ b/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='企业知识介绍'; diff --git a/mydreamjob-server/src/main/resources/db/migration/V39__onboarding_standard_checklist_seed.sql b/mydreamjob-server/src/main/resources/db/migration/V39__onboarding_standard_checklist_seed.sql new file mode 100644 index 0000000..e249093 --- /dev/null +++ b/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); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingMigrationTest.java new file mode 100644 index 0000000..91f78ec --- /dev/null +++ b/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 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 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> 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 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingSeedDataIntegrationTest.java new file mode 100644 index 0000000..856f2b4 --- /dev/null +++ b/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 SUPPORTED_CATEGORIES = Set.of( + "DOCUMENT", "ADMIN", "EQUIPMENT", "TRAINING", "LOGISTICS", "OTHER"); + + private static final List 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 redisTemplate; + + @Test + void standardChecklistSeedIsStableAndComplete() { + List> 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 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 + ) { + } +} From ef4a9b875bee05b397bc7931db38bcb71e0d4652 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 16:19:35 +0800 Subject: [PATCH 03/17] feat: add onboarding repositories and review aggregation --- .../onboarding/CompanyKnowledge.java | 63 +++ .../CompanyKnowledgeRepository.java | 12 + .../onboarding/OnboardingChecklistItem.java | 95 +++++ .../OnboardingChecklistItemRepository.java | 26 ++ .../onboarding/StudentOnboardingProgress.java | 64 +++ .../StudentOnboardingProgressRepository.java | 19 + .../JobReviewDimensionScoreRepository.java | 19 + .../review/JobReviewRepository.java | 35 ++ .../OnboardingRepositoryIntegrationTest.java | 399 ++++++++++++++++++ 9 files changed, 732 insertions(+) create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledge.java new file mode 100644 index 0000000..1b98711 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeRepository.java new file mode 100644 index 0000000..17bc85f --- /dev/null +++ b/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 { + + Optional findByCompanyId(Long companyId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItem.java new file mode 100644 index 0000000..f503449 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java new file mode 100644 index 0000000..5206055 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java @@ -0,0 +1,26 @@ +package com.mydreamjob.onboarding; + +import org.springframework.data.jpa.repository.JpaRepository; +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 { + + @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 findEffectiveChecklistItems(@Param("companyId") Long companyId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgress.java new file mode 100644 index 0000000..563c409 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/StudentOnboardingProgressRepository.java new file mode 100644 index 0000000..599315d --- /dev/null +++ b/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 { + + List findByStudentUserIdAndItemIdIn( + Long studentUserId, Collection itemIds); + + Optional findByStudentUserIdAndItemId( + Long studentUserId, Long itemId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java index 9ac6f60..b27968d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewDimensionScoreRepository.java @@ -42,4 +42,23 @@ public interface JobReviewDimensionScoreRepository extends JpaRepository 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 aggregatePublishedDimensionsByCompanyId(@Param("companyId") Long companyId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java index 7766da1..75be056 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/review/JobReviewRepository.java @@ -31,6 +31,41 @@ public interface JobReviewRepository extends JpaRepository { List findByJobIdAndStatusAndModerationStatusIn( Long jobId, String status, Collection 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 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 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 findPublishedTagsByCompanyId(@Param("companyId") Long companyId); + // ── 防刷规则查询 ────────────────────────────────────────── long countByStudentUserIdAndCreatedAtAfter(Long studentUserId, LocalDateTime after); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java new file mode 100644 index 0000000..0f10218 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java @@ -0,0 +1,399 @@ +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 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 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 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 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 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 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 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 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 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); + } + + 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 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; + } + }; + } + } +} \ No newline at end of file From 3e7c6e7f630efdec0de74bb6c7762cd805d3426a Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 16:24:55 +0800 Subject: [PATCH 04/17] docs: record W1 onboarding review --- changes/n04-onboard/.spec-superflow.yaml | 2 +- .../.superpowers/sdd/execution-plan.yaml | 8 +- .../n04-onboard/.superpowers/sdd/progress.md | 5 +- .../.superpowers/sdd/reviews/W1-T1.md | 59 +++++++ .../.superpowers/sdd/reviews/W1-T2.md | 72 +++++++++ .../.superpowers/sdd/reviews/W1-wave.md | 150 ++++++++++++++++++ .../.superpowers/sdd/task-briefs/T3.md | 56 +++++++ .../.superpowers/sdd/task-briefs/T4.md | 53 +++++++ .../.superpowers/sdd/task-briefs/T5.md | 57 +++++++ 9 files changed, 456 insertions(+), 6 deletions(-) create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W1-T1.md create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W1-T2.md create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W1-wave.md create mode 100644 changes/n04-onboard/.superpowers/sdd/task-briefs/T3.md create mode 100644 changes/n04-onboard/.superpowers/sdd/task-briefs/T4.md create mode 100644 changes/n04-onboard/.superpowers/sdd/task-briefs/T5.md diff --git a/changes/n04-onboard/.spec-superflow.yaml b/changes/n04-onboard/.spec-superflow.yaml index f0d52d9..af050b3 100644 --- a/changes/n04-onboard/.spec-superflow.yaml +++ b/changes/n04-onboard/.spec-superflow.yaml @@ -30,4 +30,4 @@ 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: 0 +batches_completed: 1 diff --git a/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml b/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml index 7c9b364..e315d2e 100644 --- a/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml +++ b/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml @@ -2,19 +2,19 @@ plan_revision: 1 selected_mode: sdd contract_hash: 79e718e36a2fd349a8c26dfd080e74a1844c01a6e52363613414694bc75a74d1 created_at: 2026-09-16T07:58:31Z -current: W1 +current: W2 waves: - id: W1 strategy: serial tasks: [T1, T2] depends_on: [] - state: eligible - receipt: pending + state: passed + receipt: .superpowers/sdd/reviews/W1-wave.md - id: W2 strategy: serial tasks: [T3, T4, T5] depends_on: [W1] - state: blocked + state: eligible receipt: pending - id: W3 strategy: serial diff --git a/changes/n04-onboard/.superpowers/sdd/progress.md b/changes/n04-onboard/.superpowers/sdd/progress.md index 86f62ba..b786d48 100644 --- a/changes/n04-onboard/.superpowers/sdd/progress.md +++ b/changes/n04-onboard/.superpowers/sdd/progress.md @@ -3,4 +3,7 @@ - 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. \ No newline at end of file +- 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. diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W1-T1.md b/changes/n04-onboard/.superpowers/sdd/reviews/W1-T1.md new file mode 100644 index 0000000..3435072 --- /dev/null +++ b/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 +``` diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W1-T2.md b/changes/n04-onboard/.superpowers/sdd/reviews/W1-T2.md new file mode 100644 index 0000000..3ae3ea2 --- /dev/null +++ b/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 +``` diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W1-wave.md b/changes/n04-onboard/.superpowers/sdd/reviews/W1-wave.md new file mode 100644 index 0000000..20722a9 --- /dev/null +++ b/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. + diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T3.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T3.md new file mode 100644 index 0000000..2ac5957 --- /dev/null +++ b/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 和企业不存在。 +- 响应不泄露内部用户字段。 +- 不修改既有已验收接口。 \ No newline at end of file diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T4.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T4.md new file mode 100644 index 0000000..1cd16df --- /dev/null +++ b/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 + +- 覆盖查询、创建、幂等重放、幂等冲突、上限、更新、版本冲突、软删除、重复删除和跨企业隔离。 +- 创建请求哈希可重复,不依赖随机或当前时间。 +- 既有学生清单行为不回归。 \ No newline at end of file diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T5.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T5.md new file mode 100644 index 0000000..2285508 --- /dev/null +++ b/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。 +- 覆盖公开审核过滤、标签排序、八维缺失和数据最小化。 \ No newline at end of file From 037ddc79d7004db2065da21a1fecdc60c4ef8c50 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 16:33:55 +0800 Subject: [PATCH 05/17] feat: add student onboarding checklist APIs --- .../common/exception/ErrorCode.java | 6 +- .../exception/GlobalExceptionHandler.java | 6 +- .../OnboardingChecklistController.java | 67 ++++ .../OnboardingChecklistService.java | 244 ++++++++++++++ .../onboarding/dto/ChecklistItemView.java | 28 ++ .../dto/EffectiveChecklistResponse.java | 23 ++ .../onboarding/dto/ProgressUpdateRequest.java | 15 + .../dto/ProgressUpdateResponse.java | 23 ++ .../OnboardingChecklistControllerTest.java | 166 ++++++++++ .../OnboardingChecklistServiceTest.java | 305 ++++++++++++++++++ 10 files changed, 881 insertions(+), 2 deletions(-) create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index dbd01f8..1fd35f3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -170,7 +170,11 @@ public enum ErrorCode { REPORT_REASON_REQUIRED(9607, "report reason is required"), CANNOT_BLOCK_SELF(9608, "cannot block yourself"), 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_PROGRESS_CONCURRENT_UPDATE(9705, "onboarding progress was modified concurrently"); private final int code; private final String message; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index af6a7c5..5047798 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/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.CONVERSATION_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 if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() @@ -163,6 +164,9 @@ public class GlobalExceptionHandler { || errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode() || errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode()) return HttpStatus.CONFLICT; + // 入职进度并发冲突 → 409 + if (errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()) + return HttpStatus.CONFLICT; // 举报限流 → 429 if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) return HttpStatus.TOO_MANY_REQUESTS; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java new file mode 100644 index 0000000..15c5cd2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java @@ -0,0 +1,67 @@ +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.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.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 OnboardingChecklistController { + + private final OnboardingChecklistService onboardingChecklistService; + + @Operation(summary = "读取有效入职清单", description = "返回通用项与企业活动自定义项的合并结果") + @GetMapping("/companies/{companyId}/checklist") + public ResponseEntity> 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> 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))); + } + + 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"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java new file mode 100644 index 0000000..71cb6f8 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java @@ -0,0 +1,244 @@ +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.ChecklistItemView; +import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; +import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; +import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +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 final UserRepository userRepository; + private final CompanyRepository companyRepository; + private final OnboardingChecklistItemRepository checklistItemRepository; + private final StudentOnboardingProgressRepository progressRepository; + + @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 items = sortedItems( + checklistItemRepository.findEffectiveChecklistItems(companyId)); + List progressRows = loadProgress(userId, items); + return toEffectiveChecklistResponse(company, items, progressRows); + } + + @Transactional + public ProgressUpdateResponse updateCompletion( + Long userId, + Long companyId, + Long itemId, + ProgressUpdateRequest request) { + assertStudentRole(userId); + if (request == null || request.getCompleted() == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + companyRepository.findById(companyId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + + List 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 progressRows = + new ArrayList<>(loadProgress(userId, items)); + StudentOnboardingProgress current = + progressRepository.findByStudentUserIdAndItemId(userId, itemId).orElse(null); + boolean desired = Boolean.TRUE.equals(request.getCompleted()); + + 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); + } + + 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 sortedItems(List items) { + if (items == null || items.isEmpty()) { + return List.of(); + } + List 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 loadProgress( + Long userId, List items) { + if (items.isEmpty()) { + return List.of(); + } + List 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 items, + List progressRows) { + Map progressByItemId = progressRows.stream() + .collect(Collectors.toMap( + StudentOnboardingProgress::getItemId, + Function.identity(), + (first, ignored) -> first)); + + List 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 items, + List 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 items, + List 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 ex) { + throw new BusinessException( + ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE, ex); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ChecklistItemView.java new file mode 100644 index 0000000..a59950c --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/EffectiveChecklistResponse.java new file mode 100644 index 0000000..a1d0837 --- /dev/null +++ b/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 items; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateRequest.java new file mode 100644 index 0000000..752fb2c --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ProgressUpdateResponse.java new file mode 100644 index 0000000..7dfcd9f --- /dev/null +++ b/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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistControllerTest.java new file mode 100644 index 0000000..d8201bf --- /dev/null +++ b/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 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(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingChecklistServiceTest.java new file mode 100644 index 0000000..209d121 --- /dev/null +++ b/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 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; + } +} From 7370e510a8fd800058ad5c782cb6edf02041bdc7 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 16:46:29 +0800 Subject: [PATCH 06/17] feat: add company onboarding checklist management --- .../common/exception/ErrorCode.java | 3 + .../exception/GlobalExceptionHandler.java | 7 +- .../OnboardingChecklistController.java | 55 ++- .../OnboardingChecklistService.java | 271 +++++++++++++ .../CompanyChecklistItemCreateRequest.java | 34 ++ .../CompanyChecklistItemUpdateRequest.java | 41 ++ .../dto/CompanyChecklistItemView.java | 24 ++ ...oardingCompanyChecklistControllerTest.java | 277 +++++++++++++ ...OnboardingCompanyChecklistServiceTest.java | 374 ++++++++++++++++++ 9 files changed, 1083 insertions(+), 3 deletions(-) create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index 1fd35f3..83dc1a6 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -174,6 +174,9 @@ public enum ErrorCode { // 入职清单 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"); private final int code; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 5047798..0950f19 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -164,8 +164,11 @@ public class GlobalExceptionHandler { || errorCode == ErrorCode.REPORT_INVALID_TRANSITION.getCode() || errorCode == ErrorCode.REPORT_IDEMPOTENCY_CONFLICT.getCode()) return HttpStatus.CONFLICT; - // 入职进度并发冲突 → 409 - if (errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()) + // 入职清单并发、上限与幂等冲突 → 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()) return HttpStatus.CONFLICT; // 举报限流 → 429 if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java index 15c5cd2..4308d9b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistController.java @@ -3,6 +3,9 @@ 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; @@ -14,13 +17,19 @@ 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") @@ -53,6 +62,50 @@ public class OnboardingChecklistController { return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); } + @Operation(summary = "查询本企业自定义清单项", description = "返回活动与软删除项及当前版本") + @GetMapping("/company/checklist-items") + public ResponseEntity>> listCompanyChecklistItems( + HttpServletRequest request) { + Long userId = resolveUserId(); + List data = + onboardingChecklistService.listCompanyChecklistItems(userId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "创建本企业自定义清单项", description = "支持 idempotencyKey 重放与冲突检测") + @PostMapping("/company/checklist-items") + public ResponseEntity> 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> 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> 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()) { @@ -64,4 +117,4 @@ public class OnboardingChecklistController { private String getRequestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java index 71cb6f8..01aa328 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java @@ -7,6 +7,9 @@ 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; @@ -15,12 +18,18 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; 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; @@ -30,6 +39,13 @@ 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 ALLOWED_CATEGORIES = Set.of( + "DOCUMENT", "ADMIN", "EQUIPMENT", "TRAINING", "LOGISTICS", "OTHER"); private final UserRepository userRepository; private final CompanyRepository companyRepository; @@ -101,6 +117,261 @@ public class OnboardingChecklistService { return toProgressUpdateResponse(item, current, items, progressRows); } + @Transactional(readOnly = true) + public List 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 + public CompanyChecklistItemView createCompanyChecklistItem( + Long userId, + CompanyChecklistItemCreateRequest request) { + Company company = requireCompanyOwner(userId); + validateCompanyItemCreateRequest(request); + + 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 companyItems = + ownedCompanyItems(checklistItemRepository.findAll(), 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(saveCompanyItem(item)); + } + + @Transactional + public CompanyChecklistItemView updateCompanyChecklistItem( + Long userId, + Long itemId, + CompanyChecklistItemUpdateRequest request) { + Company company = requireCompanyOwner(userId); + validateCompanyItemUpdateRequest(request); + 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); + 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 ownedCompanyItems( + List 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 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)); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemCreateRequest.java new file mode 100644 index 0000000..a3db1cb --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemUpdateRequest.java new file mode 100644 index 0000000..26e4e51 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyChecklistItemView.java new file mode 100644 index 0000000..18167a6 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistControllerTest.java new file mode 100644 index 0000000..8bb966c --- /dev/null +++ b/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 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(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java new file mode 100644 index 0000000..29fc74b --- /dev/null +++ b/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 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 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.findAll()).thenReturn(List.of()); + ArgumentCaptor 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 stored = new AtomicReference<>(); + when(checklistItemRepository.findAll()).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.findAll()).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 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.findAll()).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; + } +} From a5730a7acba5220a401f6123e91e36f1b14bd2c7 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 17:03:00 +0800 Subject: [PATCH 07/17] feat: add company onboarding knowledge APIs --- .../common/exception/ErrorCode.java | 3 +- .../exception/GlobalExceptionHandler.java | 3 +- .../CompanyKnowledgeController.java | 74 ++++ .../onboarding/CompanyKnowledgeService.java | 386 ++++++++++++++++++ .../dto/CompanyKnowledgeOverviewResponse.java | 53 +++ .../dto/CompanyKnowledgeUpdateRequest.java | 21 + .../onboarding/dto/CompanyKnowledgeView.java | 24 ++ .../onboarding/dto/ReviewSummaryView.java | 37 ++ .../CompanyKnowledgeControllerTest.java | 228 +++++++++++ .../CompanyKnowledgeServiceTest.java | 376 +++++++++++++++++ 10 files changed, 1203 insertions(+), 2 deletions(-) create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index 83dc1a6..ae3b69c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -177,7 +177,8 @@ public enum ErrorCode { 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_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 String message; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 0950f19..7743c3e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -168,7 +168,8 @@ public class GlobalExceptionHandler { 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_PROGRESS_CONCURRENT_UPDATE.getCode() + || errorCode == ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode()) return HttpStatus.CONFLICT; // 举报限流 → 429 if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java new file mode 100644 index 0000000..1e40d95 --- /dev/null +++ b/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> 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> 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> 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java new file mode 100644 index 0000000..7a113f9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java @@ -0,0 +1,386 @@ +package com.mydreamjob.onboarding; + +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.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; + + @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); + 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) { + 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 ex) { + throw new BusinessException( + ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE, ex); + } + } + + 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 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 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 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 buildRatingDistribution(Long companyId) { + Map distribution = new LinkedHashMap<>(); + for (int rating = 1; rating <= 5; rating++) { + distribution.put("rating" + rating, 0); + } + + List 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 buildTopTags(Long companyId) { + Map counts = new HashMap<>(); + List 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 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 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 buildDimensionSummary( + Long companyId) { + Map rowsByCode = new HashMap<>(); + List 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 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) { + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java new file mode 100644 index 0000000..ba63c91 --- /dev/null +++ b/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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java new file mode 100644 index 0000000..187c699 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java new file mode 100644 index 0000000..2fe61a2 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java new file mode 100644 index 0000000..8ea8ac4 --- /dev/null +++ b/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 ratingDistribution; + private List topTags; + private Map dimensionSummary; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class DimensionSummaryView { + + private Double averageScore; + private long sampleCount; + private String dataState; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java new file mode 100644 index 0000000..3635e27 --- /dev/null +++ b/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 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")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java new file mode 100644 index 0000000..886143f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java @@ -0,0 +1,376 @@ +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.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.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 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 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("", 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.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.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.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_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; + } +} From ca85ab541004551f45fec5670bc892379465853c Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 17:20:56 +0800 Subject: [PATCH 08/17] fix: harden onboarding permissions and concurrency --- .../com/mydreamjob/config/SecurityConfig.java | 2 + .../onboarding/CompanyKnowledgeService.java | 27 +- .../OnboardingChecklistService.java | 48 +- ...nboardingAuthorizationIntegrationTest.java | 211 +++++++++ .../OnboardingConcurrencyIntegrationTest.java | 424 ++++++++++++++++++ .../onboarding/OnboardingPrivacyTest.java | 227 ++++++++++ 6 files changed, 936 insertions(+), 3 deletions(-) create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index 0394c90..148f8c9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -126,6 +126,8 @@ public class SecurityConfig { .requestMatchers("/company/scoring/dashboard/**").authenticated() // 面试(需登录) .requestMatchers("/interviews/**").authenticated() + // 入职清单与公司知识(需登录;细粒度角色/所有权在 Service 校验) + .requestMatchers("/onboarding/**").authenticated() // API 信息入口 .requestMatchers("/").permitAll() .anyRequest().authenticated() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java index 7a113f9..a6350ce 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java @@ -1,5 +1,9 @@ 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; @@ -16,6 +20,7 @@ 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; @@ -59,6 +64,9 @@ public class CompanyKnowledgeService { private final JobReviewRepository jobReviewRepository; private final JobReviewDimensionScoreRepository dimensionScoreRepository; + @PersistenceContext + private EntityManager entityManager; + @Transactional(readOnly = true) public CompanyKnowledgeView getCompanyKnowledge(Long userId) { Company company = requireOwnedCompany(userId); @@ -76,6 +84,7 @@ public class CompanyKnowledgeService { CompanyKnowledgeUpdateRequest request) { Company company = requireOwnedCompany(userId); KnowledgeContent content = validateContent(request); + lockCompany(company); CompanyKnowledge existing = companyKnowledgeRepository.findByCompanyId(company.getId()) .orElse(null); @@ -89,6 +98,9 @@ public class CompanyKnowledgeService { return toConfiguredView(saveKnowledge(created)); } + if (request.getVersion() == null && matchesContent(existing, content)) { + return toConfiguredView(existing); + } if (request.getVersion() == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); } @@ -123,12 +135,25 @@ public class CompanyKnowledgeService { private CompanyKnowledge saveKnowledge(CompanyKnowledge knowledge) { try { return companyKnowledgeRepository.saveAndFlush(knowledge); - } catch (ObjectOptimisticLockingFailureException ex) { + } 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); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java index 01aa328..eac9f55 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java @@ -1,5 +1,9 @@ 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; @@ -14,6 +18,7 @@ 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.Transactional; @@ -52,6 +57,9 @@ public class OnboardingChecklistService { private final OnboardingChecklistItemRepository checklistItemRepository; private final StudentOnboardingProgressRepository progressRepository; + @PersistenceContext + private EntityManager entityManager; + @Transactional(readOnly = true) public EffectiveChecklistResponse getEffectiveChecklist(Long userId, Long companyId) { assertStudentRole(userId); @@ -71,6 +79,7 @@ public class OnboardingChecklistService { Long itemId, ProgressUpdateRequest request) { assertStudentRole(userId); + lockUser(userId); if (request == null || request.getCompleted() == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); } @@ -92,6 +101,13 @@ public class OnboardingChecklistService { 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) { @@ -136,6 +152,7 @@ public class OnboardingChecklistService { CompanyChecklistItemCreateRequest request) { Company company = requireCompanyOwner(userId); validateCompanyItemCreateRequest(request); + lockCompany(company); String title = normalizeOptional(request.getTitle()); String description = normalizeOptional(request.getDescription()); @@ -183,7 +200,7 @@ public class OnboardingChecklistService { .idempotencyKey(idempotencyKey) .requestHash(requestHash) .build(); - return toCompanyChecklistItemView(saveCompanyItem(item)); + return toCompanyChecklistItemView(saveNewCompanyItem(item)); } @Transactional @@ -193,6 +210,7 @@ public class OnboardingChecklistService { 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( @@ -214,6 +232,7 @@ public class OnboardingChecklistService { 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)) { @@ -363,6 +382,31 @@ public class OnboardingChecklistService { .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); @@ -507,7 +551,7 @@ public class OnboardingChecklistService { private void saveProgress(StudentOnboardingProgress progress) { try { progressRepository.saveAndFlush(progress); - } catch (ObjectOptimisticLockingFailureException ex) { + } catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { throw new BusinessException( ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE, ex); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingAuthorizationIntegrationTest.java new file mode 100644 index 0000000..ab5a2b7 --- /dev/null +++ b/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 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")); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java new file mode 100644 index 0000000..5f52d0d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java @@ -0,0 +1,424 @@ +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.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 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> 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> 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 concurrentCreatesAtLimitBoundaryNeverWriteFiftyFirstItem() throws Exception { + CompanyOwner owner = createCompanyOwner("上限企业"); + seedCompanyItems(owner.companyId(), 49); + + List> 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); + } + + @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> 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> calls = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + calls.add(() -> checklistService.updateCompletion( + student, owner.companyId(), item.getId(), progressRequest(true, 0L))); + } + + List> 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> 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> 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); + } + + 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 seedCompanyItems(Long companyId, int count) { + List 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 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 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 List> runConcurrently(Callable... actions) throws Exception { + return runConcurrently(List.of(actions)); + } + + private List> runConcurrently(List> actions) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(actions.size()); + CountDownLatch ready = new CountDownLatch(actions.size()); + CountDownLatch start = new CountDownLatch(1); + List>> futures = new ArrayList<>(); + try { + for (Callable 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> results = new ArrayList<>(); + for (Future> 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 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 get(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingPrivacyTest.java new file mode 100644 index 0000000..8187124 --- /dev/null +++ b/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 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")); + } +} \ No newline at end of file From 97b3b162615ad7e15c0963102569b44f2f80901f Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 17:36:53 +0800 Subject: [PATCH 09/17] fix: enforce onboarding item limit under repeatable read --- .../OnboardingChecklistItemRepository.java | 13 ++++++ .../OnboardingChecklistService.java | 5 ++- ...OnboardingCompanyChecklistServiceTest.java | 8 ++-- .../OnboardingConcurrencyIntegrationTest.java | 43 +++++++++++-------- 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java index 5206055..c6a0217 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistItemRepository.java @@ -1,6 +1,8 @@ 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; @@ -23,4 +25,15 @@ public interface OnboardingChecklistItemRepository extends JpaRepository 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 findCompanyItemsForUpdate(@Param("companyId") Long companyId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java index eac9f55..37233fa 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/OnboardingChecklistService.java @@ -21,6 +21,7 @@ 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; @@ -146,7 +147,7 @@ public class OnboardingChecklistService { .toList(); } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public CompanyChecklistItemView createCompanyChecklistItem( Long userId, CompanyChecklistItemCreateRequest request) { @@ -165,7 +166,7 @@ public class OnboardingChecklistService { title, description, category, sortOrder, required); List companyItems = - ownedCompanyItems(checklistItemRepository.findAll(), company.getId()); + checklistItemRepository.findCompanyItemsForUpdate(company.getId()); if (idempotencyKey != null) { OnboardingChecklistItem existing = companyItems.stream() .filter(item -> Objects.equals(item.getIdempotencyKey(), idempotencyKey)) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java index 29fc74b..3f15dcc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingCompanyChecklistServiceTest.java @@ -90,7 +90,7 @@ class OnboardingCompanyChecklistServiceTest { @Test void createCompanyChecklistItem_shouldPersistNormalizedItemAndStableHash() { stubCompanyOwner(); - when(checklistItemRepository.findAll()).thenReturn(List.of()); + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(List.of()); ArgumentCaptor captor = ArgumentCaptor.forClass(OnboardingChecklistItem.class); when(checklistItemRepository.saveAndFlush(any(OnboardingChecklistItem.class))) @@ -125,7 +125,7 @@ class OnboardingCompanyChecklistServiceTest { void createCompanyChecklistItem_idempotentReplay_shouldReturnOriginalWithoutSecondSave() { stubCompanyOwner(); AtomicReference stored = new AtomicReference<>(); - when(checklistItemRepository.findAll()).thenAnswer(invocation -> stored.get() == null + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenAnswer(invocation -> stored.get() == null ? List.of() : List.of(stored.get())); when(checklistItemRepository.saveAndFlush(any(OnboardingChecklistItem.class))) .thenAnswer(invocation -> { @@ -154,7 +154,7 @@ class OnboardingCompanyChecklistServiceTest { OnboardingChecklistItem existing = item( 31L, COMPANY_ID, "existing", "ADMIN", 200, true, 0L, "same-key"); existing.setRequestHash("a".repeat(64)); - when(checklistItemRepository.findAll()).thenReturn(List.of(existing)); + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(List.of(existing)); assertThatThrownBy(() -> service.createCompanyChecklistItem( COMPANY_USER_ID, @@ -173,7 +173,7 @@ class OnboardingCompanyChecklistServiceTest { existing.add(item((long) index + 1, COMPANY_ID, "item-" + index, "OTHER", index, true, 0L, null)); } - when(checklistItemRepository.findAll()).thenReturn(existing); + when(checklistItemRepository.findCompanyItemsForUpdate(COMPANY_ID)).thenReturn(existing); assertThatThrownBy(() -> service.createCompanyChecklistItem( COMPANY_USER_ID, diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java index 5f52d0d..d562e75 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingConcurrencyIntegrationTest.java @@ -13,6 +13,7 @@ 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; @@ -97,23 +98,6 @@ class OnboardingConcurrencyIntegrationTest { .hasSize(1); } - @Test - void concurrentCreatesAtLimitBoundaryNeverWriteFiftyFirstItem() throws Exception { - CompanyOwner owner = createCompanyOwner("上限企业"); - seedCompanyItems(owner.companyId(), 49); - - List> 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); - } @Test void inactiveItemsDoNotConsumeLimitAndExistingKeyStillReplays() throws Exception { @@ -241,6 +225,31 @@ class OnboardingConcurrencyIntegrationTest { .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> 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(); From 73afd87e508c389c05f6a9ed65b95d766650f816 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 17:51:52 +0800 Subject: [PATCH 10/17] chore: checkpoint N04 paused at T7 review --- changes/n04-onboard/.spec-superflow.yaml | 7 +- .../.superpowers/sdd/execution-plan.yaml | 10 +- .../n04-onboard/.superpowers/sdd/progress.md | 8 + .../.superpowers/sdd/reviews/W2-T3.md | 75 ++ .../.superpowers/sdd/reviews/W2-T4.md | 94 +++ .../.superpowers/sdd/reviews/W2-T5.md | 75 ++ .../.superpowers/sdd/reviews/W2-wave.md | 136 ++++ .../sdd/reviews/W3-T6-rereview.md | 115 +++ .../.superpowers/sdd/reviews/W3-T6.md | 104 +++ .../sdd/task-briefs/T6-repair-1.md | 30 + .../.superpowers/sdd/task-briefs/T6.md | 55 ++ .../.superpowers/sdd/task-briefs/T7.md | 47 ++ .../.superpowers/sdd/task-briefs/T8.md | 48 ++ .../CompanyKnowledgeServiceTest.java | 72 ++ .../OnboardingEndToEndIntegrationTest.java | 755 ++++++++++++++++++ .../OnboardingRepositoryIntegrationTest.java | 36 + 16 files changed, 1661 insertions(+), 6 deletions(-) create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W2-T3.md create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W2-T4.md create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W2-T5.md create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W2-wave.md create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W3-T6-rereview.md create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W3-T6.md create mode 100644 changes/n04-onboard/.superpowers/sdd/task-briefs/T6-repair-1.md create mode 100644 changes/n04-onboard/.superpowers/sdd/task-briefs/T6.md create mode 100644 changes/n04-onboard/.superpowers/sdd/task-briefs/T7.md create mode 100644 changes/n04-onboard/.superpowers/sdd/task-briefs/T8.md create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingEndToEndIntegrationTest.java diff --git a/changes/n04-onboard/.spec-superflow.yaml b/changes/n04-onboard/.spec-superflow.yaml index af050b3..2f922c7 100644 --- a/changes/n04-onboard/.spec-superflow.yaml +++ b/changes/n04-onboard/.spec-superflow.yaml @@ -30,4 +30,9 @@ 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: 1 +batches_completed: 2 +execution_paused: true +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 diff --git a/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml b/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml index e315d2e..0cdba13 100644 --- a/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml +++ b/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml @@ -2,7 +2,7 @@ plan_revision: 1 selected_mode: sdd contract_hash: 79e718e36a2fd349a8c26dfd080e74a1844c01a6e52363613414694bc75a74d1 created_at: 2026-09-16T07:58:31Z -current: W2 +current: W3 waves: - id: W1 strategy: serial @@ -14,11 +14,11 @@ waves: strategy: serial tasks: [T3, T4, T5] depends_on: [W1] - state: eligible - receipt: pending + state: passed + receipt: .superpowers/sdd/reviews/W2-wave.md - id: W3 strategy: serial tasks: [T6, T7, T8] depends_on: [W2] - state: blocked - receipt: pending \ No newline at end of file + state: paused + receipt: T6 pass; T7 tests complete, independent review pending; T8 pending; manual resume only \ No newline at end of file diff --git a/changes/n04-onboard/.superpowers/sdd/progress.md b/changes/n04-onboard/.superpowers/sdd/progress.md index b786d48..c6ae9cc 100644 --- a/changes/n04-onboard/.superpowers/sdd/progress.md +++ b/changes/n04-onboard/.superpowers/sdd/progress.md @@ -7,3 +7,11 @@ - 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. diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W2-T3.md b/changes/n04-onboard/.superpowers/sdd/reviews/W2-T3.md new file mode 100644 index 0000000..ad20d6f --- /dev/null +++ b/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 +``` diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W2-T4.md b/changes/n04-onboard/.superpowers/sdd/reviews/W2-T4.md new file mode 100644 index 0000000..197b8ec --- /dev/null +++ b/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 +``` + diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W2-T5.md b/changes/n04-onboard/.superpowers/sdd/reviews/W2-T5.md new file mode 100644 index 0000000..174c282 --- /dev/null +++ b/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 +``` + diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W2-wave.md b/changes/n04-onboard/.superpowers/sdd/reviews/W2-wave.md new file mode 100644 index 0000000..8d784b1 --- /dev/null +++ b/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. diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W3-T6-rereview.md b/changes/n04-onboard/.superpowers/sdd/reviews/W3-T6-rereview.md new file mode 100644 index 0000000..ca0c755 --- /dev/null +++ b/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. diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W3-T6.md b/changes/n04-onboard/.superpowers/sdd/reviews/W3-T6.md new file mode 100644 index 0000000..b226c8f --- /dev/null +++ b/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 +``` diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T6-repair-1.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T6-repair-1.md new file mode 100644 index 0000000..2b5f656 --- /dev/null +++ b/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`。 \ No newline at end of file diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T6.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T6.md new file mode 100644 index 0000000..3d70cea --- /dev/null +++ b/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。 diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T7.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T7.md new file mode 100644 index 0000000..33d28fc --- /dev/null +++ b/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 而遗漏契约错误路径。 +- 测试在随机顺序下仍稳定,不依赖真实时钟精度或外部服务。 \ No newline at end of file diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T8.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T8.md new file mode 100644 index 0000000..86ac6e6 --- /dev/null +++ b/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 注解一致,无未说明错误。 +- 全量测试和打包通过。 \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java index 886143f..dbbc31c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java @@ -1,5 +1,6 @@ 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; @@ -23,6 +24,7 @@ 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; @@ -286,6 +288,76 @@ class CompanyKnowledgeServiceTest { .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.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.of(new Object[]{3L, BigDecimal.valueOf(4.0)})); + when(reviewRepository.findPublishedRatingDistributionByCompanyId(COMPANY_ID)) + .thenReturn(List.of()); + List 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.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(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingEndToEndIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingEndToEndIntegrationTest.java new file mode 100644 index 0000000..dc673a6 --- /dev/null +++ b/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 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 allOnboardingRequests(Long companyId, Long itemId) { + List requests = new ArrayList<>(); + requests.addAll(studentOnboardingRequests(companyId, itemId)); + requests.addAll(companyOnboardingRequests(itemId)); + return requests; + } + + private List 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 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 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 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")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java index 0f10218..31d0b8d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/OnboardingRepositoryIntegrationTest.java @@ -264,6 +264,42 @@ class OnboardingRepositoryIntegrationTest { 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 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 tags = reviewRepository.findPublishedTagsByCompanyId(companyId); + assertThat(tags).hasSize(2); + assertThat(tags).allSatisfy(tag -> assertThat(tag).doesNotContain("hidden-approved")); + + List 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() From aad0ba6a29ff6a12cfd5b25a4686a81c704eef55 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Thu, 17 Sep 2026 09:35:47 +0800 Subject: [PATCH 11/17] docs: publish onboarding APIs and OpenAPI --- README.md | 12 +- changes/n04-onboard/.spec-superflow.yaml | 4 +- .../.superpowers/sdd/execution-plan.yaml | 4 +- .../n04-onboard/.superpowers/sdd/progress.md | 3 + .../.superpowers/sdd/reviews/W3-T7.md | 81 + docs/backend-development-roadmap.md | 30 +- docs/frontend-integration.md | 127 +- docs/onboarding-api.md | 433 ++ docs/openapi-rc.json | 5575 +++++++++++------ docs/verify_openapi.py | 107 +- 10 files changed, 4485 insertions(+), 1891 deletions(-) create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W3-T7.md create mode 100644 docs/onboarding-api.md diff --git a/README.md b/README.md index 0707cd5..470d381 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,16 @@ | 项目 | 当前状态 | | --- | --- | -| Git 检查点 | V34 已冻结,最新检查点为 `bf75fc` | -| 数据库迁移 | Flyway V1–V33;V34 为纯接口聚合模块,无新增迁移 | -| API 契约 | 126 paths / 160 operations | -| 自动化测试 | 1275 个通过,0 失败,0 错误 | +| Git 检查点 | N04 已建立 T7 暂停检查点 `73afd87`;T8 完成后形成最终检查点 | +| 数据库迁移 | Flyway V1–V39;N04 新增 V38–V39 | +| API 契约 | 135 paths / 172 operations | +| 自动化测试 | 1492 个执行:1481 通过、2 失败、9 错误;失败均在既有 CareerProgress/JobReviewDimension/LocalResumeStorage 测试,与 N04 文档变更无关 | | 已验收范围 | V27–V34;V33 已完成真实 MySQL/Docker 黑盒验收 | +| N04 入职清单与公司知识介绍 | 实现完成,待真实 MySQL/Docker 黑盒验收 | | 账号注销冷静期 | 30 天 | | 本地服务端口 | `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) - [前端联调说明](docs/frontend-integration.md) - [后端开发路线图](docs/backend-development-roadmap.md) +- [入职清单与公司知识介绍](docs/onboarding-api.md) - [统一求职进度接口](docs/career-progress-api.md) - [同岗位竞争力排名](docs/competition-ranking-api.md) - [举报与屏蔽](docs/report-block-api.md) diff --git a/changes/n04-onboard/.spec-superflow.yaml b/changes/n04-onboard/.spec-superflow.yaml index 2f922c7..7c72627 100644 --- a/changes/n04-onboard/.spec-superflow.yaml +++ b/changes/n04-onboard/.spec-superflow.yaml @@ -31,8 +31,10 @@ dp_4_result: approved: SDD selected; W1 serial T1-T2, W2 serial T3-T5 depends W1 dp_4_confirmed: true dp_4_timestamp: 2026-09-16T07:58:31Z batches_completed: 2 -execution_paused: true +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 diff --git a/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml b/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml index 0cdba13..07fb15d 100644 --- a/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml +++ b/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml @@ -20,5 +20,5 @@ waves: strategy: serial tasks: [T6, T7, T8] depends_on: [W2] - state: paused - receipt: T6 pass; T7 tests complete, independent review pending; T8 pending; manual resume only \ No newline at end of file + state: eligible + receipt: T6 pass; T7 pass; T8 pending \ No newline at end of file diff --git a/changes/n04-onboard/.superpowers/sdd/progress.md b/changes/n04-onboard/.superpowers/sdd/progress.md index c6ae9cc..c9232d2 100644 --- a/changes/n04-onboard/.superpowers/sdd/progress.md +++ b/changes/n04-onboard/.superpowers/sdd/progress.md @@ -15,3 +15,6 @@ - 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. diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W3-T7.md b/changes/n04-onboard/.superpowers/sdd/reviews/W3-T7.md new file mode 100644 index 0000000..f69f871 --- /dev/null +++ b/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 -- ` 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 +``` \ No newline at end of file diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 815194d..3543b39 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -26,11 +26,11 @@ | 项目 | 当前状态 | |---|---| -| OpenAPI | **126 paths、160 operations** | -| Flyway | **V1–V33** 已存在(V34 为纯接口扩展,不含 Flyway 迁移) | -| 全量测试 | **1275/1275 通过**(本次 `mvn clean verify` 实际数量) | -| 已完成范围 | 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、看板等核心闭环 | -| 当前结论 | 已达到已实现范围的 Release Candidate;V33 Report/Block 已完成真实 MySQL/Docker 黑盒验收并正式冻结;V34 统一求职进度聚合已完成实现并通过真实 MySQL/Docker 黑盒验收(PASS=99 / FAIL=0 / SKIP=0),待建立正式 Git 检查点 | +| OpenAPI | **135 paths、172 operations** | +| Flyway | **V1–V39** 已存在(V34 为纯接口扩展;N04 新增 V38–V39) | +| 全量测试 | **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 统一求职进度聚合,以及 N04 入职清单与公司知识介绍;另含认证、资料、岗位、投递、面试、Offer、看板等核心闭环 | +| 当前结论 | N04 入职清单与公司知识介绍已完成代码、N04 专项 96/96 测试和 OpenAPI/文档更新;全量 `mvn clean verify` 仍受既有非 N04 测试失败阻塞,待真实 MySQL/Docker 黑盒验收 | | 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 | ### 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 统一求职进度聚合 | -| 已完成、待真实 MySQL/Docker 验收 | — | +| 已完成、待真实 MySQL/Docker 验收 | N04 入职清单与公司知识介绍(V38/V39,9 个接口;专项测试 96/96,全量回归待清除既有失败) | | 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐 | -| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | +| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、薪资计算器、诚信积分、消息撤回 | ### 2.1 已确认的产品决策 @@ -76,7 +76,9 @@ - 简历版本管理和简历导出; - 求职者评分卡; - Candidate Scoring Engine; -- 评分申诉、审核和回滚。 +- 评分申诉、审核和回滚; +- 入职清单:通用项与企业自定义项合并、个人完成状态和完成率; +- 公司知识介绍:企业资料、企业知识内容和公开评价摘要。 ### 3.2 企业端 @@ -89,7 +91,9 @@ - Offer 管理; - 面试管理; - 评价审核基础能力; -- 双向匹配、通知和直聊。 +- 双向匹配、通知和直聊; +- 本企业入职清单自定义项管理; +- 本企业公司知识内容维护。 ## 4. Figma 与后端覆盖结论 @@ -114,6 +118,7 @@ | 企业候选人看板 | `/company/candidates`、`/company/candidates/{id}` | 已完成 | | 企业评分看板 | `/company/scoring/dashboard/*` | 已完成 | | 企业认证 | `/companies/me/verification`、`/admin/companies/verifications*` | 已完成 | +| 入职清单与公司知识介绍 | `/onboarding/**`(9 个接口) | **实现完成,待真实 MySQL/Docker 验收** | ### 4.2 部分完成或口径不一致 @@ -136,13 +141,12 @@ - AI 简历诊断和优化; - AI 模拟面试和面试题预测; - 行业技能词云; -- 入职准备清单和公司知识介绍; - 薪资计算器和薪资参考; - 公平性检测; - SHAP/规则解释; - ADMIN 防刷审计和规则配置; - 诚信积分和激励。 -- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)。 +- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)、入职清单和公司知识介绍(N04)。 ## 5. 已有代码的优先修复项 @@ -440,8 +444,6 @@ scoring_risk_actions - AI 模拟面试; - 面试题预测; - 行业技能词云; -- 入职准备清单; -- 公司知识介绍; - 简历查重; - 诚信积分; - 消息撤回。 @@ -464,7 +466,7 @@ scoring_risk_actions | P1 | ADMIN 防刷审计 | | P2 | SHAP 高级解释 | | P2 | AI 简历、AI 模拟面试 | -| P2 | 技能词云、入职清单、诚信积分 | +| P2 | 技能词云、入职清单(N04 实现完成,待验收)、诚信积分 | ## 8. 每个模块的统一交付要求 diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index d4b397e..7379f67 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -125,8 +125,8 @@ Authorization: Bearer | 401 | 401(安全过滤器) | 未登录 / 无 token | | 403 | 2002, 8004, 8303, 8402 | 无角色权限 / 越权(含企业归属不匹配) | | 400 | 1001, 3002-3006, 8404-8408, 8505, 9105 | 参数错误 / 非法请求体 / 资源未命中默认映射 | -| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501 | 资源不存在 | -| 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103 | 重复 / 非法状态流转 | +| 404 | 4001, 5001, 6001, 7001, 8002, 9102, 9104, 8305, 8501, 9701 | 资源不存在 | +| 409 | 5003, 7003, 8001, 8003, 8301-8302, 8304, 8502-8504, 9101, 9103, 9702-9706 | 重复 / 非法状态流转 / 版本或幂等冲突 | | 500 | 1002, 9001 | 服务器内部错误 | **前端策略**:优先判断 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))`。 - 匹配度公式为 `round(matchedCount * 100 / min(10, total))`;范围 15 条、命中 5 条时为 `33`。 - 学习路径不返回课程链接、AI 文本、学习时长或进度记录。 + + +## 22. 入职清单与公司知识介绍联调 + +完整接口、字段和错误示例见 [onboarding-api.md](onboarding-api.md)。Base URL 与认证方式沿用本文第 1、5 节: + +```text +http://127.0.0.1:8080/api/v1 +Authorization: Bearer +``` + +### 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 +Content-Type: application/json + +{"completed": true, "version": 0} +``` + +企业创建自定义项: + +```http +POST /api/v1/onboarding/company/checklist-items +Authorization: Bearer +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 +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` 覆盖本地状态,不能继续使用旧值。 diff --git a/docs/onboarding-api.md b/docs/onboarding-api.md new file mode 100644 index 0000000..683c436 --- /dev/null +++ b/docs/onboarding-api.md @@ -0,0 +1,433 @@ +# 入职清单与公司知识介绍 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 +Content-Type: application/json +X-Request-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 +``` + +响应: + +```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 + +{"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 + +{ + "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 +``` + +响应包含活动与软删除条目,不返回通用项和其他企业项。更新请求: + +```http +PUT /api/v1/onboarding/company/checklist-items/501 +Authorization: Bearer + +{ + "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 +``` + +首次删除必须使用当前版本,系统只将 `active=false`,不物理删除。重复删除已非活动条目保持幂等成功;接口仍要求携带 `version` 查询参数。 + +## 4. 公司知识介绍语义 + +### 4.1 学生读取与回退 + +```http +GET /api/v1/onboarding/companies/10/knowledge +Authorization: Bearer +``` + +响应: + +```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 +``` + +未配置时: + +```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 + +{ + "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` 时不要复用旧请求体。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 2c8a229..ee39142 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -118,6 +118,14 @@ { "name": "企业", "description": "企业资料创建、查询与更新" + }, + { + "name": "入职清单", + "description": "学生读取有效入职清单并更新本人完成状态;企业维护本企业自定义项" + }, + { + "name": "公司知识介绍", + "description": "企业维护知识内容,学生读取公司基础资料、知识与公开评价摘要" } ], "paths": { @@ -6739,118 +6747,1401 @@ } } } - } - }, - "components": { - "schemas": { - "AccountDeletionResponse": { - "type": "object", - "properties": { - "userId": { - "type": "integer", - "format": "int64", - "example": 2 - }, - "status": { - "type": "string", - "enum": [ - "NONE", - "PENDING", - "COMPLETED" - ], - "example": "PENDING", - "description": "注销状态:NONE/PENDING/COMPLETED" - }, - "requestedAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "effectiveAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "cancelledAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "completedAt": { - "type": "string", - "format": "date-time", - "nullable": true - }, - "version": { - "type": "integer", - "format": "int64", - "example": 0 + }, + "/onboarding/company/checklist-items": { + "get": { + "tags": [ + "入职清单" + ], + "summary": "查询本企业自定义清单项", + "description": "返回当前 COMPANY 所属企业的活动与软删除自定义项,不包含通用项和其他企业项。", + "operationId": "listCompanyChecklistItems", + "security": [ + { + "bearerAuth": [] } - } - }, - "AdminDashboardOverviewResponse": { - "type": "object", - "properties": { - "totalCompanies": { - "type": "integer", - "format": "int32" - }, - "totalJobs": { - "type": "integer", - "format": "int32" + ], + "parameters": [], + "responses": { + "200": { + "description": "查询成功;无自定义项时返回空数组", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseListCompanyChecklistItemView" + }, + "example": { + "code": 200, + "message": "ok", + "data": [ + { + "itemId": 501, + "source": "COMPANY", + "itemKey": "company-10-8f1c", + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "required": false, + "sortOrder": 1000, + "active": true, + "version": 0 + } + ], + "requestId": "req-onboarding-company-items", + "timestamp": "2026-09-17T01:32:00Z" + } + } + } }, - "totalCandidates": { - "type": "integer", - "format": "int32" + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } }, - "avgCandidateScore": { - "type": "number" + "403": { + "description": "仅 COMPANY 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } }, - "topCompanies": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CompanyScoreItem" + "404": { + "description": "当前账号没有所属企业", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } } } } }, - "ApiResponse": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" + "post": { + "tags": [ + "入职清单" + ], + "summary": "创建本企业自定义清单项", + "description": "为当前 COMPANY 所属企业创建自定义项;idempotencyKey 用于网络重试,活动项累计不超过 50 条。", + "operationId": "createCompanyChecklistItem", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanyChecklistItemCreateRequest" + }, + "example": { + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "sortOrder": 1000, + "required": false, + "idempotencyKey": "company-10-access-card-20260917" + } + } + } + }, + "responses": { + "200": { + "description": "创建成功;相同幂等请求返回首次创建的条目", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCompanyChecklistItemView" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "itemId": 501, + "source": "COMPANY", + "itemKey": "company-10-8f1c", + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "required": false, + "sortOrder": 1000, + "active": true, + "version": 0 + }, + "requestId": "req-onboarding-company-item", + "timestamp": "2026-09-17T01:32:00Z" + } + } + } }, - "message": { - "type": "string" + "400": { + "description": "标题、分类、排序、必填标记或幂等键不符合约束", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } }, - "data": {}, - "requestId": { - "type": "string" + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } }, - "timestamp": { - "type": "string", - "format": "date-time" - } - } - }, - "CareerProgressResponse": { - "type": "object", - "properties": { - "todo": { - "$ref": "#/components/schemas/ProgressTodoSummary" + "403": { + "description": "仅 COMPANY 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } }, - "applications": { - "$ref": "#/components/schemas/PaginatedApplications" + "404": { + "description": "当前账号没有所属企业", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } }, - "generatedAt": { - "type": "string", - "format": "date-time" + "409": { + "description": "活动自定义项已达到 50 条;或 9704 幂等键已用于不同请求体", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 9703, + "message": "onboarding checklist item limit exceeded", + "data": null, + "requestId": "req-9703", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } } } - }, - "ProgressTodoSummary": { - "type": "object", + } + }, + "/onboarding/company/checklist-items/{itemId}": { + "put": { + "tags": [ + "入职清单" + ], + "summary": "更新本企业自定义清单项", + "description": "使用当前 version 更新本企业条目;版本过期返回 9702,不静默覆盖。", + "operationId": "updateCompanyChecklistItem", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "description": "企业自定义清单项 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanyChecklistItemUpdateRequest" + }, + "example": { + "title": "领取门禁卡并激活", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "sortOrder": 900, + "required": true, + "active": true, + "version": 0 + } + } + } + }, + "responses": { + "200": { + "description": "更新成功并递增 version", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCompanyChecklistItemView" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "itemId": 501, + "source": "COMPANY", + "itemKey": "company-10-8f1c", + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "required": false, + "sortOrder": 1000, + "active": true, + "version": 0 + }, + "requestId": "req-onboarding-company-item", + "timestamp": "2026-09-17T01:32:00Z" + } + } + } + }, + "400": { + "description": "参数或请求体验证失败", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 COMPANY 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "条目不存在、已不属于该企业,或不属于该企业的有效清单", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 9701, + "message": "onboarding checklist item not found", + "data": null, + "requestId": "req-9701", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "409": { + "description": "请求 version 已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 9702, + "message": "onboarding checklist item was modified concurrently", + "data": null, + "requestId": "req-9702", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + }, + "delete": { + "tags": [ + "入职清单" + ], + "summary": "软删除本企业自定义清单项", + "description": "使用当前 version 将 active 置为 false;重复删除保持幂等成功。", + "operationId": "deleteCompanyChecklistItem", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "itemId", + "in": "path", + "required": true, + "description": "企业自定义清单项 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "version", + "in": "query", + "required": true, + "description": "客户端最后读取到的条目版本", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "软删除成功;重复删除返回当前非活动条目", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCompanyChecklistItemView" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "itemId": 501, + "source": "COMPANY", + "itemKey": "company-10-8f1c", + "title": "领取门禁卡", + "description": "到行政前台领取门禁卡并完成权限激活。", + "category": "ADMIN", + "required": false, + "sortOrder": 1000, + "active": false, + "version": 1 + }, + "requestId": "req-onboarding-company-item", + "timestamp": "2026-09-17T01:32:00Z" + } + } + } + }, + "400": { + "description": "缺少 version 或参数类型不正确", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 COMPANY 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "条目不存在、已不属于该企业,或不属于该企业的有效清单", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 9701, + "message": "onboarding checklist item not found", + "data": null, + "requestId": "req-9701", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "409": { + "description": "请求 version 已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 9702, + "message": "onboarding checklist item was modified concurrently", + "data": null, + "requestId": "req-9702", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + } + }, + "/onboarding/company/knowledge": { + "get": { + "tags": [ + "公司知识介绍" + ], + "summary": "读取本企业知识配置", + "description": "返回当前 COMPANY 所属企业的知识配置;尚未配置时 configured=false、version=0。", + "operationId": "getCompanyKnowledge", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [], + "responses": { + "200": { + "description": "查询成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCompanyKnowledgeView" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "configured": true, + "culture": "以用户价值为中心,保持透明和长期主义。", + "workPolicies": "弹性上班;每周一、三线下办公。", + "onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", + "updatedAt": "2026-09-17T01:33:00Z", + "version": 2 + }, + "requestId": "req-company-knowledge", + "timestamp": "2026-09-17T01:33:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 COMPANY 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "当前账号没有所属企业", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + }, + "put": { + "tags": [ + "公司知识介绍" + ], + "summary": "创建或更新本企业知识配置", + "description": "首次创建可省略 version;已有配置必须提交当前 version。字段按纯文本保存且禁止 HTML 标签。", + "operationId": "upsertCompanyKnowledge", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanyKnowledgeUpdateRequest" + }, + "example": { + "culture": "以用户价值为中心,保持透明和长期主义。", + "workPolicies": "弹性上班;每周一、三线下办公。", + "onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", + "version": 1 + } + } + } + }, + "responses": { + "200": { + "description": "创建或更新成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCompanyKnowledgeView" + }, + "example": { + "code": 200, + "message": "ok", + "data": { + "configured": true, + "culture": "以用户价值为中心,保持透明和长期主义。", + "workPolicies": "弹性上班;每周一、三线下办公。", + "onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", + "updatedAt": "2026-09-17T01:33:00Z" + }, + "requestId": "req-company-knowledge", + "timestamp": "2026-09-17T01:33:00Z" + } + } + } + }, + "400": { + "description": "内容为空、超长、包含 HTML 标签,或已有配置缺少 version", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 COMPANY 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "当前账号没有所属企业", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "409": { + "description": "知识配置版本已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 9706, + "message": "company knowledge was modified concurrently", + "data": null, + "requestId": "req-9706", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + } + }, + "/onboarding/companies/{companyId}/checklist": { + "get": { + "tags": [ + "入职清单" + ], + "summary": "读取有效入职清单", + "description": "STUDENT 读取通用活动项与当前企业活动自定义项的合并清单;无企业自定义项时回退通用清单。", + "operationId": "getEffectiveChecklist", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "companyId", + "in": "path", + "required": true, + "description": "企业 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "查询成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseEffectiveChecklistResponse" + }, + "example": { + "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" + } + } + } + }, + "400": { + "description": "companyId 不是正整数", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 STUDENT 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "企业不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + } + }, + "/onboarding/companies/{companyId}/checklist/items/{itemId}/completion": { + "put": { + "tags": [ + "入职清单" + ], + "summary": "更新清单条目完成状态", + "description": "STUDENT 使用绝对 completed 状态更新本人进度;已有进度变化时必须提交当前 version,首次创建只接受 version=0 或空。", + "operationId": "updateCompletion", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "companyId", + "in": "path", + "required": true, + "description": "企业 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + }, + { + "name": "itemId", + "in": "path", + "required": true, + "description": "有效清单条目 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProgressUpdateRequest" + }, + "example": { + "completed": true, + "version": 0 + } + } + } + }, + "responses": { + "200": { + "description": "更新成功;重复提交相同状态返回幂等结果", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseProgressUpdateResponse" + }, + "example": { + "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" + } + } + } + }, + "400": { + "description": "completed 缺失或 version 为负数", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 STUDENT 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "条目不存在、已不属于该企业,或不属于该企业的有效清单", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 9701, + "message": "onboarding checklist item not found", + "data": null, + "requestId": "req-9701", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "409": { + "description": "进度版本已过期", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 9705, + "message": "onboarding progress was modified concurrently", + "data": null, + "requestId": "req-9705", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + } + }, + "/onboarding/companies/{companyId}/knowledge": { + "get": { + "tags": [ + "公司知识介绍" + ], + "summary": "读取公司知识介绍", + "description": "STUDENT 读取企业基础资料、企业维护内容和公开评价摘要;企业未配置知识时回退基础资料,公开评价少于 3 条时不返回均值。", + "operationId": "getCompanyKnowledgeOverview", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "companyId", + "in": "path", + "required": true, + "description": "企业 ID", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "查询成功", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseCompanyKnowledgeOverviewResponse" + }, + "example": { + "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" + } + } + } + }, + "400": { + "description": "companyId 不是正整数", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 1001, + "message": "bad request", + "data": null, + "requestId": "req-1001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "401": { + "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + }, + "example": { + "code": 401, + "message": "not authenticated", + "timestamp": "" + } + } + } + }, + "403": { + "description": "仅 STUDENT 角色可访问", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 2002, + "message": "forbidden", + "data": null, + "requestId": "req-2002", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + }, + "404": { + "description": "企业不存在", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + }, + "example": { + "code": 5001, + "message": "company not found", + "data": null, + "requestId": "req-5001", + "timestamp": "2026-09-17T01:30:00Z" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AccountDeletionResponse": { + "type": "object", + "properties": { + "userId": { + "type": "integer", + "format": "int64", + "example": 2 + }, + "status": { + "type": "string", + "enum": [ + "NONE", + "PENDING", + "COMPLETED" + ], + "example": "PENDING", + "description": "注销状态:NONE/PENDING/COMPLETED" + }, + "requestedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "effectiveAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "cancelledAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "completedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "version": { + "type": "integer", + "format": "int64", + "example": 0 + } + } + }, + "AdminDashboardOverviewResponse": { + "type": "object", + "properties": { + "totalCompanies": { + "type": "integer", + "format": "int32" + }, + "totalJobs": { + "type": "integer", + "format": "int32" + }, + "totalCandidates": { + "type": "integer", + "format": "int32" + }, + "avgCandidateScore": { + "type": "number" + }, + "topCompanies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyScoreItem" + } + } + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": {}, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "CareerProgressResponse": { + "type": "object", + "properties": { + "todo": { + "$ref": "#/components/schemas/ProgressTodoSummary" + }, + "applications": { + "$ref": "#/components/schemas/PaginatedApplications" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProgressTodoSummary": { + "type": "object", "properties": { "pendingInterviewResponseCount": { "type": "integer", @@ -7366,7 +8657,164 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/CandidateScoringTrendResponse" + "$ref": "#/components/schemas/CandidateScoringTrendResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCompanyCandidateScoringResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyCandidateScoringResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseCompanyPublicView": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyPublicView" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseDashboardOverviewResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/DashboardOverviewResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseDimensionScoreDetailResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/DimensionScoreDetailResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseJobRankingItem": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/JobRankingItem" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseJobRankingListResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/JobRankingListResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseListAppealHistoryItem": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppealHistoryItem" + } }, "requestId": { "type": "string" @@ -7377,7 +8825,7 @@ } } }, - "ApiResponseCompanyCandidateScoringResponse": { + "ApiResponseListMapStringObject": { "type": "object", "properties": { "code": { @@ -7388,7 +8836,11 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/CompanyCandidateScoringResponse" + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } }, "requestId": { "type": "string" @@ -7399,7 +8851,7 @@ } } }, - "ApiResponseCompanyPublicView": { + "ApiResponseListSnapshotHistoryItem": { "type": "object", "properties": { "code": { @@ -7410,7 +8862,10 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/CompanyPublicView" + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotHistoryItem" + } }, "requestId": { "type": "string" @@ -7421,7 +8876,7 @@ } } }, - "ApiResponseDashboardOverviewResponse": { + "ApiResponseListWeightTemplateResponse": { "type": "object", "properties": { "code": { @@ -7432,7 +8887,10 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/DashboardOverviewResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/WeightTemplateResponse" + } }, "requestId": { "type": "string" @@ -7443,7 +8901,7 @@ } } }, - "ApiResponseDimensionScoreDetailResponse": { + "ApiResponseMapStringObject": { "type": "object", "properties": { "code": { @@ -7454,7 +8912,8 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/DimensionScoreDetailResponse" + "type": "object", + "additionalProperties": {} }, "requestId": { "type": "string" @@ -7465,7 +8924,7 @@ } } }, - "ApiResponseJobRankingItem": { + "ApiResponseMapStringString": { "type": "object", "properties": { "code": { @@ -7476,7 +8935,10 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/JobRankingItem" + "type": "object", + "additionalProperties": { + "type": "string" + } }, "requestId": { "type": "string" @@ -7487,7 +8949,7 @@ } } }, - "ApiResponseJobRankingListResponse": { + "ApiResponsePersonalizedScoreResponse": { "type": "object", "properties": { "code": { @@ -7498,7 +8960,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/JobRankingListResponse" + "$ref": "#/components/schemas/PersonalizedScoreResponse" }, "requestId": { "type": "string" @@ -7509,24 +8971,24 @@ } } }, - "ApiResponseListAppealHistoryItem": { + "ApiResponsePrivacySettingsResponse": { "type": "object", "properties": { "code": { "type": "integer", - "format": "int32" + "format": "int32", + "example": 200 }, "message": { - "type": "string" + "type": "string", + "example": "ok" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AppealHistoryItem" - } + "$ref": "#/components/schemas/PrivacySettingsResponse" }, "requestId": { - "type": "string" + "type": "string", + "example": "req-1" }, "timestamp": { "type": "string", @@ -7534,7 +8996,7 @@ } } }, - "ApiResponseListMapStringObject": { + "ApiResponseReviewWeightResponse": { "type": "object", "properties": { "code": { @@ -7545,11 +9007,7 @@ "type": "string" }, "data": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } + "$ref": "#/components/schemas/ReviewWeightResponse" }, "requestId": { "type": "string" @@ -7560,7 +9018,7 @@ } } }, - "ApiResponseListSnapshotHistoryItem": { + "ApiResponseScoreTrendResponse": { "type": "object", "properties": { "code": { @@ -7571,10 +9029,7 @@ "type": "string" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotHistoryItem" - } + "$ref": "#/components/schemas/ScoreTrendResponse" }, "requestId": { "type": "string" @@ -7585,301 +9040,453 @@ } } }, - "ApiResponseListWeightTemplateResponse": { + "AppealDecisionRequest": { "type": "object", "properties": { - "code": { + "decision": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "decision" + ] + }, + "AppealHistoryItem": { + "type": "object", + "properties": { + "id": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "fromStatus": { "type": "string" }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WeightTemplateResponse" - } + "toStatus": { + "type": "string" }, - "requestId": { + "actorUserId": { + "type": "integer", + "format": "int64" + }, + "actorRole": { "type": "string" }, - "timestamp": { + "reason": { + "type": "string" + }, + "createdAt": { "type": "string", "format": "date-time" } } }, - "ApiResponseMapStringObject": { + "AppealResponse": { "type": "object", "properties": { - "code": { + "appealId": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "candidateId": { + "type": "integer", + "format": "int64" + }, + "dimension": { "type": "string" }, - "data": { - "type": "object", - "additionalProperties": {} + "period": { + "type": "string" }, - "requestId": { + "status": { "type": "string" }, - "timestamp": { + "reason": { + "type": "string" + }, + "decision": { + "type": "string" + }, + "decisionReason": { + "type": "string" + }, + "reviewerId": { + "type": "integer", + "format": "int64" + }, + "reviewedAt": { + "type": "string", + "format": "date-time" + }, + "rollbackAt": { + "type": "string", + "format": "date-time" + }, + "scoreAtAppeal": { + "type": "number" + }, + "snapshotVersionAtAppeal": { + "type": "integer", + "format": "int64" + }, + "rulesVersion": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { "type": "string", "format": "date-time" } } }, - "ApiResponseMapStringString": { + "ApplicationCreateRequest": { "type": "object", "properties": { - "code": { + "jobId": { "type": "integer", - "format": "int32" + "format": "int64" + } + }, + "required": [ + "jobId" + ] + }, + "ApplicationStatusRequest": { + "type": "object", + "properties": { + "status": { + "type": "string", + "minLength": 1, + "pattern": "^(VIEWED|INTERVIEW|REJECTED|HIRED)$" + } + }, + "required": [ + "status" + ] + }, + "CandidateDetailView": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" }, - "message": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "jobTitle": { "type": "string" }, - "data": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "jobCity": { + "type": "string" }, - "requestId": { + "jobEmploymentType": { "type": "string" }, - "timestamp": { + "status": { + "type": "string" + }, + "coverLetter": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { "type": "string", "format": "date-time" + }, + "candidateName": { + "type": "string" + }, + "candidateAvatarUrl": { + "type": "string" + }, + "candidateBio": { + "type": "string" + }, + "candidateLocation": { + "type": "string" + }, + "candidateSchool": { + "type": "string" + }, + "candidateEducation": { + "type": "string" + }, + "candidateGraduationYear": { + "type": "integer", + "format": "int32" + }, + "candidateSkills": { + "type": "array", + "items": { + "type": "string" + } } } }, - "ApiResponsePersonalizedScoreResponse": { + "CandidateFeedbackRequest": { "type": "object", "properties": { - "code": { + "rating": { "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/PersonalizedScoreResponse" - }, - "requestId": { - "type": "string" + "format": "int32", + "maximum": 5, + "minimum": 1 }, - "timestamp": { + "content": { "type": "string", - "format": "date-time" + "maxLength": 1000, + "minLength": 0 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 } - } + }, + "required": [ + "content", + "rating" + ] }, - "ApiResponsePrivacySettingsResponse": { + "CandidateFeedbackResponse": { "type": "object", + "description": "脱敏后的候选人反馈", "properties": { - "code": { + "id": { + "type": "integer", + "format": "int64", + "description": "反馈 ID", + "example": 1 + }, + "applicationId": { + "type": "integer", + "format": "int64", + "description": "投递 ID", + "example": 31 + }, + "rating": { "type": "integer", "format": "int32", - "example": 200 + "description": "评分,范围 1-5", + "example": 5 }, - "message": { + "content": { "type": "string", - "example": "ok" + "description": "反馈内容", + "example": "沟通良好" }, - "data": { - "$ref": "#/components/schemas/PrivacySettingsResponse" + "tags": { + "type": "array", + "description": "反馈标签", + "items": { + "type": "string" + } }, - "requestId": { + "createdAt": { "type": "string", - "example": "req-1" + "format": "date-time", + "description": "创建时间", + "example": "2026-08-25T10:00:00" }, - "timestamp": { + "updatedAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "更新时间", + "example": "2026-08-25T10:00:00" } } }, - "ApiResponseReviewWeightResponse": { + "CandidateFeedbackSummary": { "type": "object", "properties": { - "code": { + "totalCount": { "type": "integer", "format": "int32" }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/ReviewWeightResponse" + "averageRating": { + "type": "number" }, - "requestId": { - "type": "string" + "ratingDistribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } }, - "timestamp": { - "type": "string", - "format": "date-time" + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagCount" + } } } }, - "ApiResponseScoreTrendResponse": { + "CandidateItemView": { "type": "object", "properties": { - "code": { + "applicationId": { "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" + "format": "int64" }, - "data": { - "$ref": "#/components/schemas/ScoreTrendResponse" + "jobId": { + "type": "integer", + "format": "int64" }, - "requestId": { + "jobTitle": { "type": "string" }, - "timestamp": { - "type": "string", - "format": "date-time" - } - } - }, - "AppealDecisionRequest": { - "type": "object", - "properties": { - "decision": { - "type": "string", - "minLength": 1 + "status": { + "type": "string" }, - "reason": { + "candidateName": { "type": "string" - } - }, - "required": [ - "decision" - ] - }, - "AppealHistoryItem": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64" }, - "fromStatus": { + "candidateSchool": { "type": "string" }, - "toStatus": { + "candidateEducation": { "type": "string" }, - "actorUserId": { + "candidateGraduationYear": { "type": "integer", - "format": "int64" - }, - "actorRole": { - "type": "string" + "format": "int32" }, - "reason": { + "candidateAvatarUrl": { "type": "string" }, "createdAt": { "type": "string", "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" } } }, - "AppealResponse": { + "CandidateListResponse": { "type": "object", "properties": { - "appealId": { - "type": "integer", - "format": "int64" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateItemView" + } }, - "candidateId": { + "total": { "type": "integer", "format": "int64" }, - "dimension": { - "type": "string" + "page": { + "type": "integer", + "format": "int32" }, - "period": { - "type": "string" + "size": { + "type": "integer", + "format": "int32" }, - "status": { - "type": "string" + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "CandidateMatchSignals": { + "type": "object", + "properties": { + "candidateInterestScore": { + "$ref": "#/components/schemas/MatchSignal" }, - "reason": { - "type": "string" + "hrSuitabilityScore": { + "$ref": "#/components/schemas/MatchSignal" + } + } + }, + "CandidatePercentileResponse": { + "type": "object", + "properties": { + "candidateId": { + "type": "integer", + "format": "int64" }, - "decision": { + "status": { "type": "string" }, - "decisionReason": { + "scoreType": { "type": "string" }, - "reviewerId": { - "type": "integer", - "format": "int64" - }, - "reviewedAt": { - "type": "string", - "format": "date-time" - }, - "rollbackAt": { - "type": "string", - "format": "date-time" + "score": { + "type": "number" }, - "scoreAtAppeal": { + "percentile": { "type": "number" }, - "snapshotVersionAtAppeal": { + "cohortSize": { "type": "integer", - "format": "int64" - }, - "rulesVersion": { - "type": "string" + "format": "int32" }, - "createdAt": { - "type": "string", - "format": "date-time" + "sampleCount": { + "type": "integer", + "format": "int32" }, - "updatedAt": { - "type": "string", - "format": "date-time" + "minimumSampleCount": { + "type": "integer", + "format": "int32" } } }, - "ApplicationCreateRequest": { + "CandidateRankingDetailResponse": { "type": "object", "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, "jobId": { "type": "integer", "format": "int64" + }, + "candidateName": { + "type": "string" + }, + "candidateSchool": { + "type": "string" + }, + "overallScore": { + "type": "number" + }, + "percentile": { + "type": "number" + }, + "cohortSize": { + "type": "integer", + "format": "int32" + }, + "dimensionScores": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DimensionScoreItem" + } } - }, - "required": [ - "jobId" - ] - }, - "ApplicationStatusRequest": { - "type": "object", - "properties": { - "status": { - "type": "string", - "minLength": 1, - "pattern": "^(VIEWED|INTERVIEW|REJECTED|HIRED)$" - } - }, - "required": [ - "status" - ] + } }, - "CandidateDetailView": { + "CandidateRankingItem": { "type": "object", "properties": { "applicationId": { @@ -7890,2022 +9497,2324 @@ "type": "integer", "format": "int64" }, - "jobTitle": { - "type": "string" - }, - "jobCity": { - "type": "string" - }, - "jobEmploymentType": { + "candidateName": { "type": "string" }, - "status": { + "candidateSchool": { "type": "string" }, - "coverLetter": { - "type": "string" + "overallScore": { + "type": "number" }, - "createdAt": { - "type": "string", - "format": "date-time" + "percentile": { + "type": "number" }, - "updatedAt": { - "type": "string", - "format": "date-time" + "sampleCount": { + "type": "integer", + "format": "int32" + } + } + }, + "CandidateRankingListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateRankingItem" + } }, - "candidateName": { - "type": "string" + "total": { + "type": "integer", + "format": "int64" }, - "candidateAvatarUrl": { - "type": "string" + "page": { + "type": "integer", + "format": "int32" }, - "candidateBio": { - "type": "string" + "size": { + "type": "integer", + "format": "int32" }, - "candidateLocation": { - "type": "string" + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "CandidateScoreCardResponse": { + "type": "object", + "properties": { + "candidateId": { + "type": "integer", + "format": "int64" }, - "candidateSchool": { + "status": { "type": "string" }, - "candidateEducation": { - "type": "string" + "overallScore": { + "type": "number" }, - "candidateGraduationYear": { + "sampleCount": { "type": "integer", "format": "int32" }, - "candidateSkills": { + "feedbackSummary": { + "$ref": "#/components/schemas/CandidateFeedbackSummary" + }, + "matchSignals": { + "$ref": "#/components/schemas/CandidateMatchSignals" + }, + "unsupportedDimensions": { "type": "array", "items": { "type": "string" } + }, + "generatedAt": { + "type": "string", + "format": "date-time" } } }, - "CandidateFeedbackRequest": { + "CandidateScoreTrendItem": { "type": "object", "properties": { - "rating": { + "month": { + "type": "string" + }, + "feedbackCount": { "type": "integer", - "format": "int32", - "maximum": 5, - "minimum": 1 + "format": "int32" }, - "content": { - "type": "string", - "maxLength": 1000, - "minLength": 0 + "averageRating": { + "type": "number" }, - "tags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 50, - "minLength": 1 - }, - "maxItems": 10, - "minItems": 0 + "ratingScore": { + "type": "number" } - }, - "required": [ - "content", - "rating" - ] + } }, - "CandidateFeedbackResponse": { + "CandidateScoreTrendResponse": { "type": "object", - "description": "脱敏后的候选人反馈", "properties": { - "id": { + "candidateId": { "type": "integer", - "format": "int64", - "description": "反馈 ID", - "example": 1 + "format": "int64" }, - "applicationId": { + "months": { "type": "integer", - "format": "int64", - "description": "投递 ID", - "example": 31 + "format": "int32" }, - "rating": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CandidateScoreTrendItem" + } + } + } + }, + "CandidateScoringOverviewResponse": { + "type": "object", + "properties": { + "candidateId": { "type": "integer", - "format": "int32", - "description": "评分,范围 1-5", - "example": 5 + "format": "int64" }, - "content": { - "type": "string", - "description": "反馈内容", - "example": "沟通良好" + "status": { + "type": "string" }, - "tags": { + "overallScore": { + "type": "number" + }, + "weights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "dimensions": { "type": "array", - "description": "反馈标签", "items": { - "type": "string" + "$ref": "#/components/schemas/DimensionItem" } }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "创建时间", - "example": "2026-08-25T10:00:00" + "rulesVersion": { + "type": "string" }, - "updatedAt": { + "generatedAt": { "type": "string", - "format": "date-time", - "description": "更新时间", - "example": "2026-08-25T10:00:00" + "format": "date-time" } } }, - "CandidateFeedbackSummary": { + "CandidateScoringTrendResponse": { "type": "object", "properties": { - "totalCount": { + "candidateId": { "type": "integer", - "format": "int32" - }, - "averageRating": { - "type": "number" + "format": "int64" }, - "ratingDistribution": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int32" - } + "months": { + "type": "integer", + "format": "int32" }, - "tags": { + "items": { "type": "array", "items": { - "$ref": "#/components/schemas/TagCount" + "$ref": "#/components/schemas/TrendItem" } } } }, - "CandidateItemView": { + "CompanyCandidateScoringResponse": { "type": "object", "properties": { "applicationId": { "type": "integer", "format": "int64" }, - "jobId": { - "type": "integer", - "format": "int64" - }, - "jobTitle": { - "type": "string" + "overallScore": { + "type": "number" }, "status": { "type": "string" }, - "candidateName": { - "type": "string" + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyDimensionItem" + } }, - "candidateSchool": { + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "rulesVersion": { "type": "string" + } + } + }, + "CompanyCreateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 0 }, - "candidateEducation": { + "logoUrl": { "type": "string" }, - "candidateGraduationYear": { - "type": "integer", - "format": "int32" + "description": { + "type": "string" }, - "candidateAvatarUrl": { + "industry": { "type": "string" }, - "createdAt": { - "type": "string", - "format": "date-time" + "website": { + "type": "string" }, - "updatedAt": { - "type": "string", - "format": "date-time" + "address": { + "type": "string" } - } + }, + "required": [ + "name" + ] }, - "CandidateListResponse": { + "CompanyDimensionItem": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CandidateItemView" - } - }, - "total": { - "type": "integer", - "format": "int64" - }, - "page": { - "type": "integer", - "format": "int32" + "dimension": { + "type": "string" }, - "size": { - "type": "integer", - "format": "int32" + "score": { + "type": "number" }, - "totalPages": { - "type": "integer", - "format": "int32" + "status": { + "type": "string" } } }, - "CandidateMatchSignals": { + "CompanyMatchRuleRequest": { "type": "object", "properties": { - "candidateInterestScore": { - "$ref": "#/components/schemas/MatchSignal" - }, - "hrSuitabilityScore": { - "$ref": "#/components/schemas/MatchSignal" + "requiredCandidateScore": { + "type": "integer", + "format": "int32", + "maximum": 100, + "minimum": 0 } - } + }, + "required": [ + "requiredCandidateScore" + ] }, - "CandidatePercentileResponse": { + "CompanyPublicView": { "type": "object", "properties": { - "candidateId": { + "id": { "type": "integer", "format": "int64" }, - "status": { + "name": { "type": "string" }, - "scoreType": { + "logoUrl": { "type": "string" }, - "score": { - "type": "number" - }, - "percentile": { - "type": "number" - }, - "cohortSize": { - "type": "integer", - "format": "int32" + "description": { + "type": "string" }, - "sampleCount": { - "type": "integer", - "format": "int32" + "industry": { + "type": "string" }, - "minimumSampleCount": { - "type": "integer", - "format": "int32" + "verified": { + "type": "boolean" } } }, - "CandidateRankingDetailResponse": { + "CompanyScoreItem": { "type": "object", "properties": { - "applicationId": { - "type": "integer", - "format": "int64" - }, - "jobId": { + "companyId": { "type": "integer", "format": "int64" }, - "candidateName": { - "type": "string" - }, - "candidateSchool": { + "companyName": { "type": "string" }, - "overallScore": { - "type": "number" - }, - "percentile": { + "avgCandidateScore": { "type": "number" }, - "cohortSize": { + "jobCount": { "type": "integer", "format": "int32" - }, - "dimensionScores": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DimensionScoreItem" - } } } }, - "CandidateRankingItem": { + "CompanyUpdateRequest": { "type": "object", "properties": { - "applicationId": { - "type": "integer", - "format": "int64" + "name": { + "type": "string", + "maxLength": 120, + "minLength": 0 }, - "jobId": { - "type": "integer", - "format": "int64" + "logoUrl": { + "type": "string", + "maxLength": 500, + "minLength": 0 }, - "candidateName": { + "description": { "type": "string" }, - "candidateSchool": { - "type": "string" + "industry": { + "type": "string", + "maxLength": 80, + "minLength": 0 }, - "overallScore": { - "type": "number" + "website": { + "type": "string", + "maxLength": 255, + "minLength": 0 }, - "percentile": { - "type": "number" + "address": { + "type": "string", + "maxLength": 255, + "minLength": 0 + } + }, + "required": [ + "name" + ] + }, + "CounterOfferCreateRequest": { + "type": "object", + "properties": { + "proposedSalaryMin": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "sampleCount": { + "proposedSalaryMax": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 + }, + "proposedOnboardingDate": { + "type": "string", + "format": "date" + }, + "reason": { + "type": "string", + "maxLength": 2000, + "minLength": 0 } } }, - "CandidateRankingListResponse": { + "CreateAppealRequest": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CandidateRankingItem" - } - }, - "total": { - "type": "integer", - "format": "int64" + "dimension": { + "type": "string", + "minLength": 1 }, - "page": { - "type": "integer", - "format": "int32" + "period": { + "type": "string", + "minLength": 1 }, - "size": { - "type": "integer", - "format": "int32" + "reason": { + "type": "string" }, - "totalPages": { + "idempotencyKey": { + "type": "string", + "maxLength": 64, + "minLength": 0 + } + }, + "required": [ + "dimension", + "period" + ] + }, + "CreateConversationRequest": { + "type": "object", + "properties": { + "participantUserId": { "type": "integer", - "format": "int32" + "format": "int64" } - } + }, + "required": [ + "participantUserId" + ] }, - "CandidateScoreCardResponse": { + "DashboardOverviewResponse": { "type": "object", "properties": { - "candidateId": { + "companyId": { "type": "integer", "format": "int64" }, - "status": { - "type": "string" + "totalJobs": { + "type": "integer", + "format": "int32" }, - "overallScore": { + "publishedJobs": { + "type": "integer", + "format": "int32" + }, + "avgJobScore": { "type": "number" }, - "sampleCount": { + "totalCandidates": { "type": "integer", "format": "int32" }, - "feedbackSummary": { - "$ref": "#/components/schemas/CandidateFeedbackSummary" - }, - "matchSignals": { - "$ref": "#/components/schemas/CandidateMatchSignals" + "avgCandidateScore": { + "type": "number" }, - "unsupportedDimensions": { - "type": "array", - "items": { - "type": "string" + "scoreDistribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" } }, - "generatedAt": { + "updatedAt": { "type": "string", "format": "date-time" } } }, - "CandidateScoreTrendItem": { + "DataSourceItem": { "type": "object", "properties": { - "month": { + "source": { "type": "string" }, - "feedbackCount": { - "type": "integer", - "format": "int32" - }, - "averageRating": { - "type": "number" + "time": { + "type": "string", + "format": "date-time" }, - "ratingScore": { - "type": "number" + "note": { + "type": "string" } } }, - "CandidateScoreTrendResponse": { + "DimensionItem": { "type": "object", "properties": { - "candidateId": { - "type": "integer", - "format": "int64" + "dimension": { + "type": "string" }, - "months": { - "type": "integer", - "format": "int32" + "score": { + "type": "number" }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CandidateScoreTrendItem" - } + "status": { + "type": "string" + }, + "weight": { + "type": "number" + }, + "summary": { + "type": "string" } } }, - "CandidateScoringOverviewResponse": { + "DimensionScoreDetailResponse": { "type": "object", "properties": { - "candidateId": { - "type": "integer", - "format": "int64" - }, - "status": { + "dimension": { "type": "string" }, - "overallScore": { + "score": { "type": "number" }, - "weights": { - "type": "object", - "additionalProperties": { - "type": "number" + "status": { + "type": "string" + }, + "subScores": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SubScore" } }, - "dimensions": { + "reasons": { "type": "array", "items": { - "$ref": "#/components/schemas/DimensionItem" + "type": "string" } }, - "rulesVersion": { - "type": "string" + "dataSources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DataSourceItem" + } }, - "generatedAt": { + "updatedAt": { "type": "string", "format": "date-time" + }, + "rulesVersion": { + "type": "string" } } }, - "CandidateScoringTrendResponse": { + "DimensionScoreItem": { "type": "object", "properties": { - "candidateId": { - "type": "integer", - "format": "int64" + "dimension": { + "type": "string" }, - "months": { - "type": "integer", - "format": "int32" + "score": { + "type": "number" }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TrendItem" - } + "status": { + "type": "string" } } }, - "CompanyCandidateScoringResponse": { + "InterviewCancelRequest": { "type": "object", "properties": { - "applicationId": { - "type": "integer", - "format": "int64" - }, - "overallScore": { - "type": "number" - }, - "status": { - "type": "string" - }, - "dimensions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CompanyDimensionItem" - } - }, - "generatedAt": { - "type": "string", - "format": "date-time" - }, - "rulesVersion": { + "reason": { "type": "string" } } }, - "CompanyCreateRequest": { + "InterviewCreateRequest": { "type": "object", "properties": { - "name": { + "roundNo": { + "type": "integer", + "format": "int32" + }, + "title": { "type": "string", - "maxLength": 100, + "maxLength": 200, "minLength": 0 }, - "logoUrl": { - "type": "string" + "interviewType": { + "type": "string", + "minLength": 1 }, - "description": { - "type": "string" + "startAt": { + "type": "string", + "format": "date-time" }, - "industry": { + "endAt": { + "type": "string", + "format": "date-time" + }, + "timezone": { "type": "string" }, - "website": { + "meetingUrl": { "type": "string" }, "address": { "type": "string" + }, + "interviewerName": { + "type": "string", + "maxLength": 100, + "minLength": 0 + }, + "notes": { + "type": "string" } }, "required": [ - "name" + "endAt", + "interviewType", + "startAt", + "title" ] }, - "CompanyDimensionItem": { + "InterviewRespondRequest": { "type": "object", "properties": { - "dimension": { - "type": "string" - }, - "score": { - "type": "number" + "action": { + "type": "string", + "minLength": 1 }, - "status": { + "reason": { "type": "string" } - } - }, - "CompanyMatchRuleRequest": { - "type": "object", - "properties": { - "requiredCandidateScore": { - "type": "integer", - "format": "int32", - "maximum": 100, - "minimum": 0 - } }, "required": [ - "requiredCandidateScore" + "action" ] }, - "CompanyPublicView": { + "InterviewUpdateRequest": { "type": "object", "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "name": { + "title": { "type": "string" }, - "logoUrl": { + "startAt": { + "type": "string", + "format": "date-time" + }, + "endAt": { + "type": "string", + "format": "date-time" + }, + "timezone": { "type": "string" }, - "description": { + "meetingUrl": { "type": "string" }, - "industry": { + "address": { "type": "string" }, - "verified": { - "type": "boolean" - } - } - }, - "CompanyScoreItem": { - "type": "object", - "properties": { - "companyId": { - "type": "integer", - "format": "int64" - }, - "companyName": { + "interviewerName": { "type": "string" }, - "avgCandidateScore": { - "type": "number" + "notes": { + "type": "string" }, - "jobCount": { - "type": "integer", - "format": "int32" + "timeRangeValid": { + "type": "boolean" } } }, - "CompanyUpdateRequest": { + "JobCreateRequest": { "type": "object", "properties": { - "name": { + "title": { "type": "string", "maxLength": 120, "minLength": 0 }, - "logoUrl": { + "description": { "type": "string", - "maxLength": 500, - "minLength": 0 + "minLength": 1 }, - "description": { + "requirements": { "type": "string" }, - "industry": { + "city": { "type": "string", "maxLength": 80, "minLength": 0 }, - "website": { + "employmentType": { "type": "string", - "maxLength": 255, + "maxLength": 30, "minLength": 0 }, - "address": { - "type": "string", - "maxLength": 255, - "minLength": 0 + "salaryMin": { + "type": "integer", + "format": "int32" + }, + "salaryMax": { + "type": "integer", + "format": "int32" } }, "required": [ - "name" + "city", + "description", + "employmentType", + "title" ] }, - "CounterOfferCreateRequest": { + "JobRankingItem": { "type": "object", "properties": { - "proposedSalaryMin": { + "jobId": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "proposedSalaryMax": { - "type": "integer", - "format": "int32", - "minimum": 0 + "title": { + "type": "string" }, - "proposedOnboardingDate": { - "type": "string", - "format": "date" + "city": { + "type": "string" }, - "reason": { - "type": "string", - "maxLength": 2000, - "minLength": 0 - } - } - }, - "CreateAppealRequest": { - "type": "object", - "properties": { - "dimension": { - "type": "string", - "minLength": 1 + "employmentType": { + "type": "string" }, - "period": { - "type": "string", - "minLength": 1 + "avgRating": { + "type": "number" }, - "reason": { - "type": "string" + "reviewCount": { + "type": "integer", + "format": "int32" }, - "idempotencyKey": { - "type": "string", - "maxLength": 64, - "minLength": 0 - } - }, - "required": [ - "dimension", - "period" - ] - }, - "CreateConversationRequest": { - "type": "object", - "properties": { - "participantUserId": { + "rank": { "type": "integer", - "format": "int64" + "format": "int32" + }, + "cohortSize": { + "type": "integer", + "format": "int32" + }, + "dimensionAverages": { + "type": "object", + "additionalProperties": { + "type": "number" + } } - }, - "required": [ - "participantUserId" - ] + } }, - "DashboardOverviewResponse": { + "JobRankingListResponse": { "type": "object", "properties": { - "companyId": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobRankingItem" + } + }, + "total": { "type": "integer", "format": "int64" }, - "totalJobs": { + "page": { "type": "integer", "format": "int32" }, - "publishedJobs": { + "size": { "type": "integer", "format": "int32" }, - "avgJobScore": { - "type": "number" - }, - "totalCandidates": { + "totalPages": { "type": "integer", "format": "int32" + } + } + }, + "JobReviewCreateRequest": { + "type": "object", + "properties": { + "rating": { + "type": "integer", + "format": "int32", + "maximum": 5, + "minimum": 1 }, - "avgCandidateScore": { - "type": "number" + "content": { + "type": "string", + "maxLength": 1000, + "minLength": 0 }, - "scoreDistribution": { + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 + }, + "dimensionScores": { "type": "object", "additionalProperties": { "type": "integer", "format": "int32" } - }, - "updatedAt": { - "type": "string", - "format": "date-time" } - } + }, + "required": [ + "content", + "rating" + ] }, - "DataSourceItem": { + "JobReviewUpdateRequest": { "type": "object", "properties": { - "source": { - "type": "string" + "rating": { + "type": "integer", + "format": "int32", + "maximum": 5, + "minimum": 1 }, - "time": { + "content": { "type": "string", - "format": "date-time" - }, - "note": { - "type": "string" - } - } - }, - "DimensionItem": { - "type": "object", - "properties": { - "dimension": { - "type": "string" + "maxLength": 1000, + "minLength": 1 }, - "score": { - "type": "number" + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "maxItems": 10, + "minItems": 0 }, "status": { - "type": "string" - }, - "weight": { - "type": "number" + "type": "string", + "pattern": "^(PUBLISHED|HIDDEN)$" }, - "summary": { - "type": "string" + "dimensionScores": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } } } }, - "DimensionScoreDetailResponse": { + "JobStatusRequest": { "type": "object", "properties": { - "dimension": { - "type": "string" - }, - "score": { - "type": "number" + "status": { + "type": "string", + "minLength": 1, + "pattern": "^(PUBLISHED|CLOSED)$" + } + }, + "required": [ + "status" + ] + }, + "JobUpdateRequest": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 }, - "status": { + "description": { "type": "string" }, - "subScores": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SubScore" - } - }, - "reasons": { - "type": "array", - "items": { - "type": "string" - } + "requirements": { + "type": "string" }, - "dataSources": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DataSourceItem" - } + "city": { + "type": "string", + "maxLength": 80, + "minLength": 0 }, - "updatedAt": { + "employmentType": { "type": "string", - "format": "date-time" + "maxLength": 30, + "minLength": 0 }, - "rulesVersion": { - "type": "string" + "salaryMin": { + "type": "integer", + "format": "int32" + }, + "salaryMax": { + "type": "integer", + "format": "int32" } } }, - "DimensionScoreItem": { + "LoginRequest": { "type": "object", "properties": { - "dimension": { - "type": "string" + "phone": { + "type": "string", + "minLength": 1, + "pattern": "[0-9]{11}" }, - "score": { - "type": "number" + "code": { + "type": "string", + "minLength": 1, + "pattern": "[0-9]{6}" }, - "status": { + "purpose": { "type": "string" } - } + }, + "required": [ + "code", + "phone" + ] }, - "InterviewCancelRequest": { + "MatchSignal": { "type": "object", "properties": { - "reason": { - "type": "string" + "average": { + "type": "number" + }, + "sampleCount": { + "type": "integer", + "format": "int32" } } }, - "InterviewCreateRequest": { + "OfferCreateRequest": { "type": "object", "properties": { - "roundNo": { + "applicationId": { "type": "integer", - "format": "int32" + "format": "int64" }, "title": { "type": "string", - "maxLength": 200, + "maxLength": 120, "minLength": 0 }, - "interviewType": { + "salaryMin": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "salaryMax": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "salaryCurrency": { "type": "string", - "minLength": 1 + "maxLength": 10, + "minLength": 0 }, - "startAt": { + "salaryPeriod": { "type": "string", - "format": "date-time" + "pattern": "^(MONTH|YEAR|HOUR)$" }, - "endAt": { + "workCity": { "type": "string", - "format": "date-time" + "maxLength": 80, + "minLength": 0 }, - "timezone": { - "type": "string" + "employmentType": { + "type": "string", + "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" }, - "meetingUrl": { - "type": "string" + "probationMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "address": { - "type": "string" + "benefits": { + "type": "string", + "maxLength": 2000, + "minLength": 0 }, - "interviewerName": { + "description": { "type": "string", - "maxLength": 100, + "maxLength": 5000, "minLength": 0 }, - "notes": { - "type": "string" + "expireAt": { + "type": "string", + "format": "date-time" + }, + "onboardingDate": { + "type": "string", + "format": "date" + }, + "baseSalary": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "housingAllowance": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "annualBonusMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "otherAllowances": { + "type": "string", + "maxLength": 1000, + "minLength": 0 } }, "required": [ - "endAt", - "interviewType", - "startAt", - "title" + "applicationId", + "description", + "expireAt", + "title", + "workCity" ] }, - "InterviewRespondRequest": { + "OfferStatusRequest": { "type": "object", "properties": { - "action": { + "status": { "type": "string", - "minLength": 1 + "minLength": 1, + "pattern": "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED|WITHDRAWN)$" }, "reason": { - "type": "string" + "type": "string", + "maxLength": 255, + "minLength": 0 } }, "required": [ - "action" + "status" ] }, - "InterviewUpdateRequest": { + "OfferUpdateRequest": { "type": "object", "properties": { "title": { - "type": "string" - }, - "startAt": { - "type": "string", - "format": "date-time" - }, - "endAt": { "type": "string", - "format": "date-time" - }, - "timezone": { - "type": "string" + "maxLength": 120, + "minLength": 0 }, - "meetingUrl": { - "type": "string" + "salaryMin": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "address": { - "type": "string" + "salaryMax": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "interviewerName": { - "type": "string" + "salaryCurrency": { + "type": "string", + "maxLength": 10, + "minLength": 0 }, - "notes": { - "type": "string" + "salaryPeriod": { + "type": "string", + "pattern": "^(MONTH|YEAR|HOUR)$" }, - "timeRangeValid": { - "type": "boolean" - } - } - }, - "JobCreateRequest": { - "type": "object", - "properties": { - "title": { + "workCity": { "type": "string", - "maxLength": 120, + "maxLength": 80, "minLength": 0 }, - "description": { + "employmentType": { "type": "string", - "minLength": 1 + "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" }, - "requirements": { - "type": "string" + "probationMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 }, - "city": { + "benefits": { "type": "string", - "maxLength": 80, + "maxLength": 2000, "minLength": 0 }, - "employmentType": { + "description": { "type": "string", - "maxLength": 30, + "maxLength": 5000, "minLength": 0 }, - "salaryMin": { + "expireAt": { + "type": "string", + "format": "date-time" + }, + "onboardingDate": { + "type": "string", + "format": "date" + }, + "baseSalary": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 }, - "salaryMax": { + "housingAllowance": { "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 0 + }, + "annualBonusMonths": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "otherAllowances": { + "type": "string", + "maxLength": 1000, + "minLength": 0 } - }, - "required": [ - "city", - "description", - "employmentType", - "title" - ] + } }, - "JobRankingItem": { + "PersonalizedScoreResponse": { "type": "object", "properties": { "jobId": { "type": "integer", "format": "int64" }, - "title": { - "type": "string" + "score": { + "type": "number" }, - "city": { + "status": { "type": "string" }, - "employmentType": { + "source": { "type": "string" }, - "avgRating": { - "type": "number" - }, - "reviewCount": { - "type": "integer", - "format": "int32" + "templateCode": { + "type": "string" }, - "rank": { - "type": "integer", - "format": "int32" + "configuredWeights": { + "type": "object", + "additionalProperties": { + "type": "number" + } }, - "cohortSize": { - "type": "integer", - "format": "int32" + "effectiveWeights": { + "type": "object", + "additionalProperties": { + "type": "number" + } }, - "dimensionAverages": { + "dimensionScores": { "type": "object", "additionalProperties": { "type": "number" } + }, + "coveredWeight": { + "type": "number" + }, + "missingDimensions": { + "type": "array", + "items": { + "type": "string" + } } } }, - "JobRankingListResponse": { + "PrivacySettingsResponse": { "type": "object", "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/JobRankingItem" - } - }, - "total": { + "id": { "type": "integer", - "format": "int64" + "format": "int64", + "example": 1, + "nullable": true }, - "page": { + "studentUserId": { "type": "integer", - "format": "int32" + "format": "int64", + "example": 2 }, - "size": { - "type": "integer", - "format": "int32" + "resumeVisibility": { + "$ref": "#/components/schemas/ResumeVisibility", + "description": "简历可见性(PRIVATE/APPLIED_COMPANIES/VERIFIED_COMPANIES)", + "example": "APPLIED_COMPANIES" }, - "totalPages": { - "type": "integer", - "format": "int32" - } - } - }, - "JobReviewCreateRequest": { - "type": "object", - "properties": { - "rating": { + "contactVisible": { + "type": "boolean", + "example": true + }, + "version": { "type": "integer", - "format": "int32", - "maximum": 5, - "minimum": 1 + "format": "int64", + "example": 0 }, - "content": { + "createdAt": { "type": "string", - "maxLength": 1000, - "minLength": 0 - }, - "tags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 50, - "minLength": 1 - }, - "maxItems": 10, - "minItems": 0 + "format": "date-time", + "nullable": true }, - "dimensionScores": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int32" - } + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true } - }, - "required": [ - "content", - "rating" - ] + } }, - "JobReviewUpdateRequest": { + "PrivacySettingsUpdateRequest": { "type": "object", + "required": [ + "resumeVisibility", + "contactVisible" + ], "properties": { - "rating": { - "type": "integer", - "format": "int32", - "maximum": 5, - "minimum": 1 - }, - "content": { - "type": "string", - "maxLength": 1000, - "minLength": 1 - }, - "tags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 50, - "minLength": 1 - }, - "maxItems": 10, - "minItems": 0 + "resumeVisibility": { + "$ref": "#/components/schemas/ResumeVisibility", + "description": "简历可见性(必填)", + "example": "APPLIED_COMPANIES" }, - "status": { - "type": "string", - "pattern": "^(PUBLISHED|HIDDEN)$" + "contactVisible": { + "type": "boolean", + "description": "联系方式是否对企业可见(必填)", + "example": true }, - "dimensionScores": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int32" - } + "version": { + "type": "integer", + "format": "int64", + "description": "乐观锁版本号(可选)", + "example": 0 } } }, - "JobStatusRequest": { + "ResumeVisibility": { + "type": "string", + "enum": [ + "PRIVATE", + "APPLIED_COMPANIES", + "VERIFIED_COMPANIES" + ], + "description": "简历可见性:PRIVATE 私有;APPLIED_COMPANIES 仅对企业有效投递可见;VERIFIED_COMPANIES 仅对认证企业可见", + "example": "PRIVATE" + }, + "ReviewDecisionRequest": { "type": "object", "properties": { - "status": { + "decision": { "type": "string", "minLength": 1, - "pattern": "^(PUBLISHED|CLOSED)$" + "pattern": "^(APPROVED|REJECTED)$" + }, + "reason": { + "type": "string", + "maxLength": 500, + "minLength": 0 } }, "required": [ - "status" + "decision" ] }, - "JobUpdateRequest": { + "ReviewWeightResponse": { "type": "object", "properties": { - "title": { - "type": "string", - "maxLength": 120, - "minLength": 0 - }, - "description": { + "source": { "type": "string" }, - "requirements": { + "templateCode": { "type": "string" }, - "city": { - "type": "string", - "maxLength": 80, - "minLength": 0 - }, - "employmentType": { - "type": "string", - "maxLength": 30, - "minLength": 0 - }, - "salaryMin": { - "type": "integer", - "format": "int32" + "configuredWeights": { + "type": "object", + "additionalProperties": { + "type": "number" + } }, - "salaryMax": { - "type": "integer", - "format": "int32" + "updatedAt": { + "type": "string", + "format": "date-time" } } }, - "LoginRequest": { + "SaveWeightRequest": { "type": "object", "properties": { - "phone": { - "type": "string", - "minLength": 1, - "pattern": "[0-9]{11}" - }, - "code": { - "type": "string", - "minLength": 1, - "pattern": "[0-9]{6}" - }, - "purpose": { + "templateCode": { "type": "string" + }, + "weights": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + } + }, + "ScoreRequest": { + "type": "object", + "properties": { + "score": { + "type": "integer", + "format": "int32", + "maximum": 100, + "minimum": 0 } }, "required": [ - "code", - "phone" + "score" ] }, - "MatchSignal": { + "ScoreTrendResponse": { "type": "object", "properties": { - "average": { - "type": "number" + "companyId": { + "type": "integer", + "format": "int64" }, - "sampleCount": { + "months": { "type": "integer", "format": "int32" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrendItem" + } } } }, - "OfferCreateRequest": { + "SendMessageRequest": { "type": "object", "properties": { - "applicationId": { + "conversationId": { "type": "integer", "format": "int64" }, - "title": { + "content": { "type": "string", - "maxLength": 120, + "maxLength": 2000, "minLength": 0 - }, - "salaryMin": { + } + }, + "required": [ + "content", + "conversationId" + ] + }, + "SnapshotHistoryItem": { + "type": "object", + "properties": { + "id": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "salaryMax": { + "appealId": { "type": "integer", - "format": "int32", - "minimum": 0 - }, - "salaryCurrency": { - "type": "string", - "maxLength": 10, - "minLength": 0 - }, - "salaryPeriod": { - "type": "string", - "pattern": "^(MONTH|YEAR|HOUR)$" - }, - "workCity": { - "type": "string", - "maxLength": 80, - "minLength": 0 - }, - "employmentType": { - "type": "string", - "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" + "format": "int64" }, - "probationMonths": { - "type": "integer", - "format": "int32", - "minimum": 0 + "action": { + "type": "string" }, - "benefits": { - "type": "string", - "maxLength": 2000, - "minLength": 0 + "dimension": { + "type": "string" }, - "description": { - "type": "string", - "maxLength": 5000, - "minLength": 0 + "period": { + "type": "string" }, - "expireAt": { - "type": "string", - "format": "date-time" + "beforeScore": { + "type": "number" }, - "onboardingDate": { - "type": "string", - "format": "date" + "afterScore": { + "type": "number" }, - "baseSalary": { + "beforeVersion": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "housingAllowance": { + "afterVersion": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "annualBonusMonths": { - "type": "integer", - "format": "int32", - "minimum": 0 + "beforeRulesVersion": { + "type": "string" }, - "otherAllowances": { + "afterRulesVersion": { + "type": "string" + }, + "createdAt": { "type": "string", - "maxLength": 1000, - "minLength": 0 + "format": "date-time" } - }, - "required": [ - "applicationId", - "description", - "expireAt", - "title", - "workCity" - ] + } }, - "OfferStatusRequest": { + "StudentMatchRuleRequest": { "type": "object", "properties": { - "status": { - "type": "string", - "minLength": 1, - "pattern": "^(SENT|NEGOTIATING|ACCEPTED|REJECTED|EXPIRED|WITHDRAWN)$" - }, - "reason": { - "type": "string", - "maxLength": 255, - "minLength": 0 + "requiredHrScore": { + "type": "integer", + "format": "int32", + "maximum": 100, + "minimum": 0 } }, "required": [ - "status" + "requiredHrScore" ] }, - "OfferUpdateRequest": { + "StudentPreferenceRequest": { "type": "object", "properties": { - "title": { - "type": "string", - "maxLength": 120, - "minLength": 0 - }, - "salaryMin": { + "expectedSalaryMin": { "type": "integer", "format": "int32", "minimum": 0 }, - "salaryMax": { + "expectedSalaryMax": { "type": "integer", "format": "int32", "minimum": 0 }, - "salaryCurrency": { - "type": "string", - "maxLength": 10, - "minLength": 0 + "preferredCities": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "salaryPeriod": { - "type": "string", - "pattern": "^(MONTH|YEAR|HOUR)$" + "employmentTypes": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "workCity": { - "type": "string", - "maxLength": 80, - "minLength": 0 + "preferredIndustries": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "employmentType": { - "type": "string", - "pattern": "^(FULL_TIME|PART_TIME|INTERN|INTERNSHIP)$" + "preferredJobTypes": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "maxItems": 20, + "minItems": 0 }, - "probationMonths": { - "type": "integer", - "format": "int32", - "minimum": 0 + "remoteAllowed": { + "type": "boolean" }, - "benefits": { + "salaryRangeValid": { + "type": "boolean" + } + } + }, + "StudentProfileUpdateRequest": { + "type": "object", + "properties": { + "realName": { "type": "string", - "maxLength": 2000, + "maxLength": 50, "minLength": 0 }, - "description": { + "bio": { + "type": "string" + }, + "location": { "type": "string", - "maxLength": 5000, + "maxLength": 100, "minLength": 0 }, - "expireAt": { + "education": { "type": "string", - "format": "date-time" + "maxLength": 100, + "minLength": 0 }, - "onboardingDate": { + "school": { "type": "string", - "format": "date" - }, - "baseSalary": { - "type": "integer", - "format": "int32", - "minimum": 0 - }, - "housingAllowance": { - "type": "integer", - "format": "int32", - "minimum": 0 + "maxLength": 150, + "minLength": 0 }, - "annualBonusMonths": { + "graduationYear": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int32" }, - "otherAllowances": { + "resumeUrl": { "type": "string", - "maxLength": 1000, + "maxLength": 500, "minLength": 0 + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } } } }, - "PersonalizedScoreResponse": { + "SubScore": { "type": "object", "properties": { - "jobId": { - "type": "integer", - "format": "int64" + "name": { + "type": "string" }, "score": { "type": "number" }, - "status": { + "weight": { + "type": "number" + }, + "description": { + "type": "string" + } + } + }, + "TagCount": { + "type": "object", + "properties": { + "name": { "type": "string" }, - "source": { + "count": { + "type": "integer", + "format": "int32" + } + } + }, + "TrendItem": { + "type": "object", + "properties": { + "period": { "type": "string" }, - "templateCode": { + "overallScore": { + "type": "number" + }, + "rulesVersion": { "type": "string" }, - "configuredWeights": { + "dimensions": { "type": "object", "additionalProperties": { "type": "number" } + } + } + }, + "VerificationDecisionRequest": { + "type": "object", + "properties": { + "decision": { + "type": "string", + "minLength": 1 }, - "effectiveWeights": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "reason": { + "type": "string" + } + }, + "required": [ + "decision" + ] + }, + "VerificationSubmitRequest": { + "type": "object", + "properties": { + "materialsJson": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "materialsJson" + ] + }, + "WeightTemplateResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" }, - "dimensionScores": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "weights": { "type": "object", "additionalProperties": { "type": "number" } + } + } + }, + "InterviewScoreSaveRequest": { + "type": "object", + "properties": { + "technicalScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true }, - "coveredWeight": { - "type": "number" + "communicationScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true }, - "missingDimensions": { - "type": "array", - "items": { - "type": "string" - } + "problemSolvingScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "roleFitScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "professionalismScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "nullable": true + }, + "recommendation": { + "type": "string", + "enum": [ + "STRONG_HIRE", + "HIRE", + "NEUTRAL", + "NO_HIRE" + ], + "nullable": true, + "description": "企业手动选择,不根据 overallScore 自动推导" + }, + "comment": { + "type": "string", + "nullable": true } - } + }, + "description": "评分草稿保存请求;overallScore 由服务端计算,客户端不得传入" }, - "PrivacySettingsResponse": { + "InterviewScoreResponse": { "type": "object", "properties": { "id": { "type": "integer", - "format": "int64", - "example": 1, + "format": "int64" + }, + "interviewId": { + "type": "integer", + "format": "int64" + }, + "applicationId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string", + "enum": [ + "DRAFT", + "SUBMITTED" + ] + }, + "technicalScore": { + "type": "integer", "nullable": true }, - "studentUserId": { + "communicationScore": { "type": "integer", - "format": "int64", - "example": 2 + "nullable": true }, - "resumeVisibility": { - "$ref": "#/components/schemas/ResumeVisibility", - "description": "简历可见性(PRIVATE/APPLIED_COMPANIES/VERIFIED_COMPANIES)", - "example": "APPLIED_COMPANIES" + "problemSolvingScore": { + "type": "integer", + "nullable": true }, - "contactVisible": { - "type": "boolean", - "example": true + "roleFitScore": { + "type": "integer", + "nullable": true + }, + "professionalismScore": { + "type": "integer", + "nullable": true + }, + "overallScore": { + "type": "number", + "format": "decimal", + "nullable": true, + "description": "由服务端 BigDecimal 计算:(t+c+ps+rf+p)/5,保留两位小数 HALF_UP" + }, + "recommendation": { + "type": "string", + "enum": [ + "STRONG_HIRE", + "HIRE", + "NEUTRAL", + "NO_HIRE" + ], + "nullable": true + }, + "comment": { + "type": "string", + "nullable": true + }, + "submittedAt": { + "type": "string", + "format": "date-time", + "nullable": true }, "version": { "type": "integer", - "format": "int64", - "example": 0 + "format": "int64" }, "createdAt": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" }, "updatedAt": { "type": "string", - "format": "date-time", - "nullable": true + "format": "date-time" } - } + }, + "description": "面试评分响应(脱敏:不返回 companyUserId/studentUserId/evaluatorUserId/手机号/邮箱)" }, - "PrivacySettingsUpdateRequest": { + "ApiResponseInterviewScoreResponse": { "type": "object", - "required": [ - "resumeVisibility", - "contactVisible" - ], "properties": { - "resumeVisibility": { - "$ref": "#/components/schemas/ResumeVisibility", - "description": "简历可见性(必填)", - "example": "APPLIED_COMPANIES" + "code": { + "type": "integer", + "format": "int32" }, - "contactVisible": { - "type": "boolean", - "description": "联系方式是否对企业可见(必填)", - "example": true + "message": { + "type": "string" }, - "version": { - "type": "integer", - "format": "int64", - "description": "乐观锁版本号(可选)", - "example": 0 + "data": { + "$ref": "#/components/schemas/InterviewScoreResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "ResumeVisibility": { - "type": "string", - "enum": [ - "PRIVATE", - "APPLIED_COMPANIES", - "VERIFIED_COMPANIES" - ], - "description": "简历可见性:PRIVATE 私有;APPLIED_COMPANIES 仅对企业有效投递可见;VERIFIED_COMPANIES 仅对认证企业可见", - "example": "PRIVATE" - }, - "ReviewDecisionRequest": { + "InterviewScoreHistoryItem": { "type": "object", "properties": { - "decision": { + "id": { + "type": "integer", + "format": "int64" + }, + "scoreId": { + "type": "integer", + "format": "int64" + }, + "interviewId": { + "type": "integer", + "format": "int64" + }, + "fromStatus": { "type": "string", - "minLength": 1, - "pattern": "^(APPROVED|REJECTED)$" + "enum": [ + "DRAFT", + "SUBMITTED" + ], + "nullable": true }, - "reason": { + "toStatus": { "type": "string", - "maxLength": 500, - "minLength": 0 + "enum": [ + "DRAFT", + "SUBMITTED" + ] + }, + "actorRole": { + "type": "string", + "example": "COMPANY" + }, + "snapshot": { + "type": "object", + "additionalProperties": {} + }, + "createdAt": { + "type": "string", + "format": "date-time" } }, - "required": [ - "decision" - ] + "description": "评分历史条目(脱敏:不返回 actorUserId/手机号/邮箱)" }, - "ReviewWeightResponse": { + "ApiResponseListInterviewScoreHistoryItem": { "type": "object", "properties": { - "source": { - "type": "string" + "code": { + "type": "integer", + "format": "int32" }, - "templateCode": { + "message": { "type": "string" }, - "configuredWeights": { - "type": "object", - "additionalProperties": { - "type": "number" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InterviewScoreHistoryItem" } }, - "updatedAt": { + "requestId": { + "type": "string" + }, + "timestamp": { "type": "string", "format": "date-time" } } }, - "SaveWeightRequest": { + "CreateReportRequest": { "type": "object", "properties": { - "templateCode": { - "type": "string" - }, - "weights": { - "type": "object", - "additionalProperties": { - "type": "number" - } - } + "targetType": { "type": "string", "enum": ["USER", "MESSAGE"], "description": "举报目标类型;USER 必填 targetUserId,MESSAGE 必填 messageId" }, + "targetUserId": { "type": "integer", "format": "int64", "description": "targetType=USER 时必填" }, + "messageId": { "type": "integer", "format": "int64", "description": "targetType=MESSAGE 时必填" }, + "category": { "type": "string", "maxLength": 40, "description": "举报分类,如 HARASSMENT" }, + "reason": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "evidenceUrls": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "description": "最多 5 个,仅 http/https,最多 500 字符" }, + "idempotencyKey": { "type": "string", "maxLength": 64, "description": "幂等键(可选)" } } }, - "ScoreRequest": { + "ReportDecisionRequest": { "type": "object", "properties": { - "score": { - "type": "integer", - "format": "int32", - "maximum": 100, - "minimum": 0 - } - }, - "required": [ - "score" - ] + "decision": { "type": "string", "enum": ["RESOLVED", "REJECTED"] }, + "note": { "type": "string", "maxLength": 1000, "description": "REJECTED 必填" } + } }, - "ScoreTrendResponse": { + "ReportResponse": { "type": "object", "properties": { - "companyId": { - "type": "integer", - "format": "int64" - }, - "months": { - "type": "integer", - "format": "int32" + "id": { "type": "integer", "format": "int64" }, + "targetType": { "type": "string", "enum": ["USER", "MESSAGE"] }, + "targetUserId": { "type": "integer", "format": "int64" }, + "messageId": { "type": "integer", "format": "int64" }, + "reportedUserId": { "type": "integer", "format": "int64" }, + "category": { "type": "string" }, + "reason": { "type": "string" }, + "evidenceUrls": { "type": "array", "items": { "type": "string" } }, + "status": { "type": "string", "enum": ["PENDING", "RESOLVED", "REJECTED"] }, + "decision": { "type": "string", "enum": ["RESOLVED", "REJECTED"] }, + "decisionNote": { "type": "string" }, + "decidedAt": { "type": "string", "format": "date-time" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "messagePreview": { "type": "string", "description": "MESSAGE 举报的脱敏消息预览,不返回会话其他敏感内容" }, + "reportedUser": { "$ref": "#/components/schemas/ReportResponseReportedUser" }, + "history": { "type": "array", "items": { "$ref": "#/components/schemas/ReportResponseDecisionHistoryItem" } } + } + }, + "ReportResponseReportedUser": { + "type": "object", + "properties": { + "userId": { "type": "integer", "format": "int64" }, + "nickname": { "type": "string" }, + "role": { "type": "string" } + } + }, + "ReportResponseDecisionHistoryItem": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64" }, + "fromStatus": { "type": "string" }, + "toStatus": { "type": "string" }, + "actorUserId": { "type": "integer", "format": "int64" }, + "actorRole": { "type": "string" }, + "note": { "type": "string" }, + "createdAt": { "type": "string", "format": "date-time" } + } + }, + "BlockRelationResponse": { + "type": "object", + "properties": { + "id": { "type": "integer", "format": "int64" }, + "blockedUserId": { "type": "integer", "format": "int64" }, + "nickname": { "type": "string" }, + "createdAt": { "type": "string", "format": "date-time" } + } + }, + "ApiResponseReportResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" }, + "data": { "$ref": "#/components/schemas/ReportResponse" }, + "requestId": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } + } + }, + "ApiResponsePageResponseReportResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" }, + "data": { + "type": "object", + "properties": { + "items": { "type": "array", "items": { "$ref": "#/components/schemas/ReportResponse" } }, + "totalElements": { "type": "integer", "format": "int64" }, + "page": { "type": "integer", "format": "int32" }, + "size": { "type": "integer", "format": "int32" } + } }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TrendItem" + "requestId": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } + } + }, + "ApiResponseBlockRelationResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" }, + "data": { "$ref": "#/components/schemas/BlockRelationResponse" }, + "requestId": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } + } + }, + "ApiResponsePageResponseBlockRelationResponse": { + "type": "object", + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" }, + "data": { + "type": "object", + "properties": { + "items": { "type": "array", "items": { "$ref": "#/components/schemas/BlockRelationResponse" } }, + "totalElements": { "type": "integer", "format": "int64" }, + "page": { "type": "integer", "format": "int32" }, + "size": { "type": "integer", "format": "int32" } } - } + }, + "requestId": { "type": "string" }, + "timestamp": { "type": "string", "format": "date-time" } } }, - "SendMessageRequest": { + "SkillCloudNode": { "type": "object", "properties": { - "conversationId": { + "id": { "type": "integer", "format": "int64" }, - "content": { - "type": "string", - "maxLength": 2000, - "minLength": 0 + "code": { + "type": "string" + }, + "name": { + "type": "string" } - }, - "required": [ - "content", - "conversationId" - ] + } }, - "SnapshotHistoryItem": { + "SkillCloudItem": { "type": "object", "properties": { - "id": { + "rank": { "type": "integer", - "format": "int64" + "format": "int32", + "minimum": 1, + "description": "当前筛选范围内的稳定排名,从 1 开始。" }, - "appealId": { + "skillId": { "type": "integer", "format": "int64" }, - "action": { - "type": "string" - }, - "dimension": { + "skillCode": { "type": "string" }, - "period": { + "nameZh": { "type": "string" }, - "beforeScore": { - "type": "number" + "nameEn": { + "type": "string", + "nullable": true }, - "afterScore": { - "type": "number" + "sourceType": { + "type": "string", + "enum": [ + "ESCO", + "O*NET" + ], + "description": "技能身份主来源。" }, - "beforeVersion": { - "type": "integer", - "format": "int64" + "sourceCode": { + "type": "string", + "description": "ESCO URI 或 O*NET 三元组标识。" }, - "afterVersion": { + "categoryId": { "type": "integer", "format": "int64" }, - "beforeRulesVersion": { + "categoryName": { "type": "string" }, - "afterRulesVersion": { - "type": "string" + "heat": { + "type": "number", + "nullable": true, + "description": "O*NET Importance;无映射为 null,且排在非空热度之后。" }, - "createdAt": { + "heatSourceCode": { "type": "string", - "format": "date-time" + "nullable": true, + "description": "热度来源 SOC。" + }, + "heatElementId": { + "type": "string", + "nullable": true, + "description": "热度来源 O*NET Element ID。" + }, + "heatProxy": { + "type": "boolean", + "description": "热度是否为代理映射。" } } }, - "StudentMatchRuleRequest": { + "SkillCloudResponse": { "type": "object", "properties": { - "requiredHrScore": { + "industry": { + "$ref": "#/components/schemas/SkillCloudNode" + }, + "occupation": { + "$ref": "#/components/schemas/SkillCloudNode" + }, + "category": { + "$ref": "#/components/schemas/SkillCloudNode", + "nullable": true + }, + "total": { "type": "integer", "format": "int32", - "maximum": 100, "minimum": 0 + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SkillCloudItem" + } } - }, - "required": [ - "requiredHrScore" - ] + } }, - "StudentPreferenceRequest": { + "SkillMatchResponse": { "type": "object", "properties": { - "expectedSalaryMin": { + "matchScore": { "type": "integer", "format": "int32", - "minimum": 0 + "minimum": 0, + "maximum": 100, + "description": "round(matchedCount*100/min(10,total));total=0 时为 0。" }, - "expectedSalaryMax": { + "matchedCount": { "type": "integer", "format": "int32", "minimum": 0 }, - "preferredCities": { - "type": "array", - "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 - }, - "employmentTypes": { - "type": "array", - "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 + "totalCount": { + "type": "integer", + "format": "int32", + "minimum": 0, + "description": "实际匹配集合大小 min(10,total)。" }, - "preferredIndustries": { + "matchedSkills": { "type": "array", "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 + "$ref": "#/components/schemas/SkillCloudItem" + } }, - "preferredJobTypes": { + "missingSkills": { "type": "array", "items": { - "type": "string", - "maxLength": 100, - "minLength": 1 - }, - "maxItems": 20, - "minItems": 0 - }, - "remoteAllowed": { - "type": "boolean" - }, - "salaryRangeValid": { - "type": "boolean" + "$ref": "#/components/schemas/SkillCloudItem" + } } } }, - "StudentProfileUpdateRequest": { + "SkillLearningPathItem": { "type": "object", "properties": { - "realName": { - "type": "string", - "maxLength": 50, - "minLength": 0 + "priority": { + "type": "integer", + "format": "int32", + "minimum": 1 }, - "bio": { + "skillId": { + "type": "integer", + "format": "int64" + }, + "skillName": { "type": "string" }, - "location": { - "type": "string", - "maxLength": 100, - "minLength": 0 + "categoryId": { + "type": "integer", + "format": "int64" }, - "education": { - "type": "string", - "maxLength": 100, - "minLength": 0 + "categoryName": { + "type": "string" }, - "school": { + "suggestion": { "type": "string", - "maxLength": 150, - "minLength": 0 - }, - "graduationYear": { + "description": "按技能分类生成的通用建议,不含课程、AI、链接、时长或进度。" + } + } + }, + "SkillLearningPathResponse": { + "type": "object", + "properties": { + "total": { "type": "integer", - "format": "int32" - }, - "resumeUrl": { - "type": "string", - "maxLength": 500, - "minLength": 0 + "format": "int32", + "minimum": 0 }, - "skills": { + "items": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/SkillLearningPathItem" } } } }, - "SubScore": { + "ApiResponseSkillCloudResponse": { "type": "object", "properties": { - "name": { - "type": "string" + "code": { + "type": "integer", + "format": "int32" }, - "score": { - "type": "number" + "message": { + "type": "string" }, - "weight": { - "type": "number" + "data": { + "$ref": "#/components/schemas/SkillCloudResponse" }, - "description": { + "requestId": { "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "TagCount": { + "ApiResponseSkillMatchResponse": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "count": { + "code": { "type": "integer", "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/SkillMatchResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "TrendItem": { + "ApiResponseSkillLearningPathResponse": { "type": "object", "properties": { - "period": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { "type": "string" }, - "overallScore": { - "type": "number" + "data": { + "$ref": "#/components/schemas/SkillLearningPathResponse" }, - "rulesVersion": { + "requestId": { "type": "string" }, - "dimensions": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "VerificationDecisionRequest": { + "UnauthorizedResponse": { "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message", + "timestamp" + ], "properties": { - "decision": { + "code": { + "type": "integer", + "format": "int32", + "enum": [ + 401 + ] + }, + "message": { "type": "string", - "minLength": 1 + "enum": [ + "not authenticated" + ] }, - "reason": { - "type": "string" + "timestamp": { + "type": "string", + "enum": [ + "" + ] } - }, - "required": [ - "decision" - ] + } }, - "VerificationSubmitRequest": { + "CompanyChecklistItemCreateRequest": { "type": "object", "properties": { - "materialsJson": { + "title": { "type": "string", - "minLength": 1 + "maxLength": 120, + "minLength": 0 + }, + "description": { + "type": "string", + "maxLength": 1000, + "minLength": 0 + }, + "category": { + "type": "string", + "minLength": 1, + "pattern": "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER" + }, + "sortOrder": { + "type": "integer", + "format": "int32", + "maximum": 9999, + "minimum": 0 + }, + "required": { + "type": "boolean" + }, + "idempotencyKey": { + "type": "string", + "maxLength": 64, + "minLength": 0 } }, "required": [ - "materialsJson" + "category", + "title" ] }, - "WeightTemplateResponse": { + "CompanyChecklistItemUpdateRequest": { "type": "object", "properties": { - "code": { - "type": "string" - }, - "name": { - "type": "string" + "title": { + "type": "string", + "maxLength": 120, + "minLength": 0 }, "description": { - "type": "string" + "type": "string", + "maxLength": 1000, + "minLength": 0 }, - "weights": { - "type": "object", - "additionalProperties": { - "type": "number" - } - } - } - }, - "InterviewScoreSaveRequest": { - "type": "object", - "properties": { - "technicalScore": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "category": { + "type": "string", + "minLength": 1, + "pattern": "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER" }, - "communicationScore": { + "sortOrder": { "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "format": "int32", + "maximum": 9999, + "minimum": 0 }, - "problemSolvingScore": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "required": { + "type": "boolean" }, - "roleFitScore": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true + "active": { + "type": "boolean" }, - "professionalismScore": { + "version": { "type": "integer", - "minimum": 1, - "maximum": 5, - "nullable": true - }, - "recommendation": { - "type": "string", - "enum": [ - "STRONG_HIRE", - "HIRE", - "NEUTRAL", - "NO_HIRE" - ], - "nullable": true, - "description": "企业手动选择,不根据 overallScore 自动推导" - }, - "comment": { - "type": "string", - "nullable": true + "format": "int64", + "minimum": 0 } }, - "description": "评分草稿保存请求;overallScore 由服务端计算,客户端不得传入" + "required": [ + "active", + "category", + "required", + "sortOrder", + "title", + "version" + ] }, - "InterviewScoreResponse": { + "CompanyChecklistItemView": { "type": "object", "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "interviewId": { + "itemId": { "type": "integer", "format": "int64" }, - "applicationId": { - "type": "integer", - "format": "int64" + "source": { + "type": "string" }, - "status": { - "type": "string", - "enum": [ - "DRAFT", - "SUBMITTED" - ] + "itemKey": { + "type": "string" }, - "technicalScore": { - "type": "integer", - "nullable": true + "title": { + "type": "string" }, - "communicationScore": { - "type": "integer", - "nullable": true + "description": { + "type": "string" }, - "problemSolvingScore": { - "type": "integer", - "nullable": true + "category": { + "type": "string" }, - "roleFitScore": { - "type": "integer", - "nullable": true + "required": { + "type": "boolean" }, - "professionalismScore": { + "sortOrder": { "type": "integer", - "nullable": true - }, - "overallScore": { - "type": "number", - "format": "decimal", - "nullable": true, - "description": "由服务端 BigDecimal 计算:(t+c+ps+rf+p)/5,保留两位小数 HALF_UP" - }, - "recommendation": { - "type": "string", - "enum": [ - "STRONG_HIRE", - "HIRE", - "NEUTRAL", - "NO_HIRE" - ], - "nullable": true - }, - "comment": { - "type": "string", - "nullable": true + "format": "int32" }, - "submittedAt": { - "type": "string", - "format": "date-time", - "nullable": true + "active": { + "type": "boolean" }, "version": { - "type": "integer", - "format": "int64" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "format": "date-time" + "type": "integer", + "format": "int64" } - }, - "description": "面试评分响应(脱敏:不返回 companyUserId/studentUserId/evaluatorUserId/手机号/邮箱)" + } }, - "ApiResponseInterviewScoreResponse": { + "ApiResponseCompanyChecklistItemView": { "type": "object", "properties": { "code": { @@ -9916,7 +11825,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/InterviewScoreResponse" + "$ref": "#/components/schemas/CompanyChecklistItemView" }, "requestId": { "type": "string" @@ -9927,52 +11836,7 @@ } } }, - "InterviewScoreHistoryItem": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "scoreId": { - "type": "integer", - "format": "int64" - }, - "interviewId": { - "type": "integer", - "format": "int64" - }, - "fromStatus": { - "type": "string", - "enum": [ - "DRAFT", - "SUBMITTED" - ], - "nullable": true - }, - "toStatus": { - "type": "string", - "enum": [ - "DRAFT", - "SUBMITTED" - ] - }, - "actorRole": { - "type": "string", - "example": "COMPANY" - }, - "snapshot": { - "type": "object", - "additionalProperties": {} - }, - "createdAt": { - "type": "string", - "format": "date-time" - } - }, - "description": "评分历史条目(脱敏:不返回 actorUserId/手机号/邮箱)" - }, - "ApiResponseListInterviewScoreHistoryItem": { + "ApiResponseListCompanyChecklistItemView": { "type": "object", "properties": { "code": { @@ -9985,7 +11849,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/InterviewScoreHistoryItem" + "$ref": "#/components/schemas/CompanyChecklistItemView" } }, "requestId": { @@ -9997,338 +11861,227 @@ } } }, - "CreateReportRequest": { - "type": "object", - "properties": { - "targetType": { "type": "string", "enum": ["USER", "MESSAGE"], "description": "举报目标类型;USER 必填 targetUserId,MESSAGE 必填 messageId" }, - "targetUserId": { "type": "integer", "format": "int64", "description": "targetType=USER 时必填" }, - "messageId": { "type": "integer", "format": "int64", "description": "targetType=MESSAGE 时必填" }, - "category": { "type": "string", "maxLength": 40, "description": "举报分类,如 HARASSMENT" }, - "reason": { "type": "string", "minLength": 1, "maxLength": 1000 }, - "evidenceUrls": { "type": "array", "items": { "type": "string", "maxLength": 500 }, "description": "最多 5 个,仅 http/https,最多 500 字符" }, - "idempotencyKey": { "type": "string", "maxLength": 64, "description": "幂等键(可选)" } - } - }, - "ReportDecisionRequest": { - "type": "object", - "properties": { - "decision": { "type": "string", "enum": ["RESOLVED", "REJECTED"] }, - "note": { "type": "string", "maxLength": 1000, "description": "REJECTED 必填" } - } - }, - "ReportResponse": { - "type": "object", - "properties": { - "id": { "type": "integer", "format": "int64" }, - "targetType": { "type": "string", "enum": ["USER", "MESSAGE"] }, - "targetUserId": { "type": "integer", "format": "int64" }, - "messageId": { "type": "integer", "format": "int64" }, - "reportedUserId": { "type": "integer", "format": "int64" }, - "category": { "type": "string" }, - "reason": { "type": "string" }, - "evidenceUrls": { "type": "array", "items": { "type": "string" } }, - "status": { "type": "string", "enum": ["PENDING", "RESOLVED", "REJECTED"] }, - "decision": { "type": "string", "enum": ["RESOLVED", "REJECTED"] }, - "decisionNote": { "type": "string" }, - "decidedAt": { "type": "string", "format": "date-time" }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" }, - "messagePreview": { "type": "string", "description": "MESSAGE 举报的脱敏消息预览,不返回会话其他敏感内容" }, - "reportedUser": { "$ref": "#/components/schemas/ReportResponseReportedUser" }, - "history": { "type": "array", "items": { "$ref": "#/components/schemas/ReportResponseDecisionHistoryItem" } } - } - }, - "ReportResponseReportedUser": { - "type": "object", - "properties": { - "userId": { "type": "integer", "format": "int64" }, - "nickname": { "type": "string" }, - "role": { "type": "string" } - } - }, - "ReportResponseDecisionHistoryItem": { - "type": "object", - "properties": { - "id": { "type": "integer", "format": "int64" }, - "fromStatus": { "type": "string" }, - "toStatus": { "type": "string" }, - "actorUserId": { "type": "integer", "format": "int64" }, - "actorRole": { "type": "string" }, - "note": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" } - } - }, - "BlockRelationResponse": { - "type": "object", - "properties": { - "id": { "type": "integer", "format": "int64" }, - "blockedUserId": { "type": "integer", "format": "int64" }, - "nickname": { "type": "string" }, - "createdAt": { "type": "string", "format": "date-time" } - } - }, - "ApiResponseReportResponse": { - "type": "object", - "properties": { - "code": { "type": "integer", "format": "int32" }, - "message": { "type": "string" }, - "data": { "$ref": "#/components/schemas/ReportResponse" }, - "requestId": { "type": "string" }, - "timestamp": { "type": "string", "format": "date-time" } - } - }, - "ApiResponsePageResponseReportResponse": { + "EffectiveChecklistResponse": { "type": "object", "properties": { - "code": { "type": "integer", "format": "int32" }, - "message": { "type": "string" }, - "data": { - "type": "object", - "properties": { - "items": { "type": "array", "items": { "$ref": "#/components/schemas/ReportResponse" } }, - "totalElements": { "type": "integer", "format": "int64" }, - "page": { "type": "integer", "format": "int32" }, - "size": { "type": "integer", "format": "int32" } - } + "companyId": { + "type": "integer", + "format": "int64" }, - "requestId": { "type": "string" }, - "timestamp": { "type": "string", "format": "date-time" } - } - }, - "ApiResponseBlockRelationResponse": { - "type": "object", - "properties": { - "code": { "type": "integer", "format": "int32" }, - "message": { "type": "string" }, - "data": { "$ref": "#/components/schemas/BlockRelationResponse" }, - "requestId": { "type": "string" }, - "timestamp": { "type": "string", "format": "date-time" } - } - }, - "ApiResponsePageResponseBlockRelationResponse": { - "type": "object", - "properties": { - "code": { "type": "integer", "format": "int32" }, - "message": { "type": "string" }, - "data": { - "type": "object", - "properties": { - "items": { "type": "array", "items": { "$ref": "#/components/schemas/BlockRelationResponse" } }, - "totalElements": { "type": "integer", "format": "int64" }, - "page": { "type": "integer", "format": "int32" }, - "size": { "type": "integer", "format": "int32" } - } + "companyName": { + "type": "string" }, - "requestId": { "type": "string" }, - "timestamp": { "type": "string", "format": "date-time" } + "customized": { + "type": "boolean" + }, + "totalItems": { + "type": "integer", + "format": "int32" + }, + "completedItems": { + "type": "integer", + "format": "int32" + }, + "completionRate": { + "type": "number", + "format": "double" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChecklistItemView" + } + } } }, - "SkillCloudNode": { + "ChecklistItemView": { "type": "object", "properties": { - "id": { + "itemId": { "type": "integer", "format": "int64" }, - "code": { + "source": { "type": "string" }, - "name": { + "itemKey": { "type": "string" - } - } - }, - "SkillCloudItem": { - "type": "object", - "properties": { - "rank": { - "type": "integer", - "format": "int32", - "minimum": 1, - "description": "当前筛选范围内的稳定排名,从 1 开始。" }, - "skillId": { - "type": "integer", - "format": "int64" + "title": { + "type": "string" }, - "skillCode": { + "description": { "type": "string" }, - "nameZh": { + "category": { "type": "string" }, - "nameEn": { - "type": "string", - "nullable": true + "required": { + "type": "boolean" }, - "sourceType": { - "type": "string", - "enum": [ - "ESCO", - "O*NET" - ], - "description": "技能身份主来源。" + "sortOrder": { + "type": "integer", + "format": "int32" }, - "sourceCode": { + "active": { + "type": "boolean" + }, + "completed": { + "type": "boolean" + }, + "completedAt": { "type": "string", - "description": "ESCO URI 或 O*NET 三元组标识。" + "format": "date-time" }, - "categoryId": { + "progressVersion": { "type": "integer", "format": "int64" + } + } + }, + "ApiResponseEffectiveChecklistResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" }, - "categoryName": { - "type": "string" - }, - "heat": { - "type": "number", - "nullable": true, - "description": "O*NET Importance;无映射为 null,且排在非空热度之后。" + "message": { + "type": "string" }, - "heatSourceCode": { - "type": "string", - "nullable": true, - "description": "热度来源 SOC。" + "data": { + "$ref": "#/components/schemas/EffectiveChecklistResponse" }, - "heatElementId": { - "type": "string", - "nullable": true, - "description": "热度来源 O*NET Element ID。" + "requestId": { + "type": "string" }, - "heatProxy": { - "type": "boolean", - "description": "热度是否为代理映射。" + "timestamp": { + "type": "string", + "format": "date-time" } } }, - "SkillCloudResponse": { + "ProgressUpdateRequest": { "type": "object", "properties": { - "industry": { - "$ref": "#/components/schemas/SkillCloudNode" - }, - "occupation": { - "$ref": "#/components/schemas/SkillCloudNode" - }, - "category": { - "$ref": "#/components/schemas/SkillCloudNode", - "nullable": true + "completed": { + "type": "boolean" }, - "total": { + "version": { "type": "integer", - "format": "int32", + "format": "int64", "minimum": 0 - }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SkillCloudItem" - } } - } + }, + "required": [ + "completed" + ] }, - "SkillMatchResponse": { + "ProgressUpdateResponse": { "type": "object", "properties": { - "matchScore": { + "itemId": { "type": "integer", - "format": "int32", - "minimum": 0, - "maximum": 100, - "description": "round(matchedCount*100/min(10,total));total=0 时为 0。" + "format": "int64" }, - "matchedCount": { + "completed": { + "type": "boolean" + }, + "completedAt": { + "type": "string", + "format": "date-time" + }, + "progressVersion": { "type": "integer", - "format": "int32", - "minimum": 0 + "format": "int64" }, - "totalCount": { + "totalItems": { "type": "integer", - "format": "int32", - "minimum": 0, - "description": "实际匹配集合大小 min(10,total)。" + "format": "int32" }, - "matchedSkills": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SkillCloudItem" - } + "completedItems": { + "type": "integer", + "format": "int32" }, - "missingSkills": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SkillCloudItem" - } + "completionRate": { + "type": "number", + "format": "double" } } }, - "SkillLearningPathItem": { + "ApiResponseProgressUpdateResponse": { "type": "object", "properties": { - "priority": { - "type": "integer", - "format": "int32", - "minimum": 1 - }, - "skillId": { + "code": { "type": "integer", - "format": "int64" + "format": "int32" }, - "skillName": { + "message": { "type": "string" }, - "categoryId": { - "type": "integer", - "format": "int64" + "data": { + "$ref": "#/components/schemas/ProgressUpdateResponse" }, - "categoryName": { + "requestId": { "type": "string" }, - "suggestion": { + "timestamp": { "type": "string", - "description": "按技能分类生成的通用建议,不含课程、AI、链接、时长或进度。" + "format": "date-time" } } }, - "SkillLearningPathResponse": { + "CompanyKnowledgeUpdateRequest": { "type": "object", "properties": { - "total": { + "culture": { + "type": "string", + "maxLength": 2000, + "minLength": 0 + }, + "workPolicies": { + "type": "string", + "maxLength": 4000, + "minLength": 0 + }, + "onboardingGuide": { + "type": "string", + "maxLength": 4000, + "minLength": 0 + }, + "version": { "type": "integer", - "format": "int32", + "format": "int64", "minimum": 0 - }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SkillLearningPathItem" - } } } }, - "ApiResponseSkillCloudResponse": { + "CompanyKnowledgeView": { "type": "object", "properties": { - "code": { - "type": "integer", - "format": "int32" + "configured": { + "type": "boolean" }, - "message": { + "culture": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/SkillCloudResponse" + "workPolicies": { + "type": "string" }, - "requestId": { + "onboardingGuide": { "type": "string" }, - "timestamp": { + "updatedAt": { "type": "string", "format": "date-time" + }, + "version": { + "type": "integer", + "format": "int64" } } }, - "ApiResponseSkillMatchResponse": { + "ApiResponseCompanyKnowledgeView": { "type": "object", "properties": { "code": { @@ -10339,7 +12092,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/SkillMatchResponse" + "$ref": "#/components/schemas/CompanyKnowledgeView" }, "requestId": { "type": "string" @@ -10350,55 +12103,145 @@ } } }, - "ApiResponseSkillLearningPathResponse": { + "CompanyKnowledgeOverviewResponse": { "type": "object", "properties": { - "code": { + "company": { + "$ref": "#/components/schemas/CompanyView" + }, + "knowledgeState": { + "type": "string" + }, + "knowledge": { + "$ref": "#/components/schemas/KnowledgeView" + }, + "reviewSummary": { + "$ref": "#/components/schemas/ReviewSummaryView" + } + } + }, + "CompanyView": { + "type": "object", + "properties": { + "id": { "type": "integer", - "format": "int32" + "format": "int64" }, - "message": { + "name": { "type": "string" }, - "data": { - "$ref": "#/components/schemas/SkillLearningPathResponse" + "logoUrl": { + "type": "string" }, - "requestId": { + "description": { "type": "string" }, - "timestamp": { + "industry": { + "type": "string" + }, + "website": { + "type": "string" + }, + "address": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + } + }, + "KnowledgeView": { + "type": "object", + "properties": { + "culture": { + "type": "string" + }, + "workPolicies": { + "type": "string" + }, + "onboardingGuide": { + "type": "string" + }, + "updatedAt": { "type": "string", "format": "date-time" + }, + "version": { + "type": "integer", + "format": "int64" } } }, - "UnauthorizedResponse": { + "ReviewSummaryView": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "totalReviews": { + "type": "integer", + "format": "int32" + }, + "averageRating": { + "type": "number", + "format": "double" + }, + "ratingDistribution": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, + "topTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "dimensionSummary": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/DimensionSummaryView" + } + } + } + }, + "DimensionSummaryView": { + "type": "object", + "properties": { + "averageScore": { + "type": "number", + "format": "double" + }, + "sampleCount": { + "type": "integer", + "format": "int64" + }, + "dataState": { + "type": "string" + } + } + }, + "ApiResponseCompanyKnowledgeOverviewResponse": { "type": "object", - "additionalProperties": false, - "required": [ - "code", - "message", - "timestamp" - ], "properties": { "code": { "type": "integer", - "format": "int32", - "enum": [ - 401 - ] + "format": "int32" }, "message": { - "type": "string", - "enum": [ - "not authenticated" - ] + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/CompanyKnowledgeOverviewResponse" + }, + "requestId": { + "type": "string" }, "timestamp": { "type": "string", - "enum": [ - "" - ] + "format": "date-time" } } } diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 6f3b5ca..05b8472 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,9 +6,55 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 129, f"Expected 129 paths, got {len(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 = [ '/review-weight-templates', @@ -205,6 +251,65 @@ print(" Offer advance endpoints all present") print(" 26 operations all present") 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}' + ) + +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) assert '"" : "#/components/schemas/' not in data, "Found empty ref key" print("No empty ref keys found") From 310d931207991ef8ddd8132caacef3419add5cf8 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Thu, 17 Sep 2026 09:43:54 +0800 Subject: [PATCH 12/17] fix: align onboarding OpenAPI examples and defaults --- docs/openapi-rc.json | 7 +++++-- docs/verify_openapi.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index ee39142..cbc0f5f 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -7435,7 +7435,8 @@ "culture": "以用户价值为中心,保持透明和长期主义。", "workPolicies": "弹性上班;每周一、三线下办公。", "onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", - "updatedAt": "2026-09-17T01:33:00Z" + "updatedAt": "2026-09-17T01:33:00Z", + "version": 2 }, "requestId": "req-company-knowledge", "timestamp": "2026-09-17T01:33:00Z" @@ -11714,11 +11715,13 @@ "sortOrder": { "type": "integer", "format": "int32", + "default": 1000, "maximum": 9999, "minimum": 0 }, "required": { - "type": "boolean" + "type": "boolean", + "default": false }, "idempotencyKey": { "type": "string", diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 05b8472..753ea25 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -297,6 +297,18 @@ for schema_name, expected_fields in onboarding_schema_fields.items(): 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'), From 6e7d537f8aef97b659910425e642f297a774457d Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Thu, 17 Sep 2026 09:50:43 +0800 Subject: [PATCH 13/17] docs: record W3 onboarding verification status --- changes/n04-onboard/.spec-superflow.yaml | 3 +- .../.superpowers/sdd/execution-plan.yaml | 8 +- .../n04-onboard/.superpowers/sdd/progress.md | 3 + .../sdd/reviews/W3-T8-rereview.md | 83 +++++++++++++ .../.superpowers/sdd/reviews/W3-T8.md | 78 ++++++++++++ .../.superpowers/sdd/reviews/W3-wave.md | 113 ++++++++++++++++++ .../sdd/task-briefs/T8-repair-1.md | 35 ++++++ 7 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W3-T8-rereview.md create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W3-T8.md create mode 100644 changes/n04-onboard/.superpowers/sdd/reviews/W3-wave.md create mode 100644 changes/n04-onboard/.superpowers/sdd/task-briefs/T8-repair-1.md diff --git a/changes/n04-onboard/.spec-superflow.yaml b/changes/n04-onboard/.spec-superflow.yaml index 7c72627..68f7604 100644 --- a/changes/n04-onboard/.spec-superflow.yaml +++ b/changes/n04-onboard/.spec-superflow.yaml @@ -30,7 +30,7 @@ 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: 2 +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 @@ -38,3 +38,4 @@ resume_from: T7-review resume_mode: manual resumed_at: 2026-09-17T01:17:47Z resume_count: 1 +verification_blocked: true diff --git a/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml b/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml index 07fb15d..809634d 100644 --- a/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml +++ b/changes/n04-onboard/.superpowers/sdd/execution-plan.yaml @@ -2,7 +2,7 @@ plan_revision: 1 selected_mode: sdd contract_hash: 79e718e36a2fd349a8c26dfd080e74a1844c01a6e52363613414694bc75a74d1 created_at: 2026-09-16T07:58:31Z -current: W3 +current: verification waves: - id: W1 strategy: serial @@ -20,5 +20,7 @@ waves: strategy: serial tasks: [T6, T7, T8] depends_on: [W2] - state: eligible - receipt: T6 pass; T7 pass; T8 pending \ No newline at end of file + 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 diff --git a/changes/n04-onboard/.superpowers/sdd/progress.md b/changes/n04-onboard/.superpowers/sdd/progress.md index c9232d2..b2db2c6 100644 --- a/changes/n04-onboard/.superpowers/sdd/progress.md +++ b/changes/n04-onboard/.superpowers/sdd/progress.md @@ -18,3 +18,6 @@ - 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. diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W3-T8-rereview.md b/changes/n04-onboard/.superpowers/sdd/reviews/W3-T8-rereview.md new file mode 100644 index 0000000..0be808c --- /dev/null +++ b/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 +``` diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W3-T8.md b/changes/n04-onboard/.superpowers/sdd/reviews/W3-T8.md new file mode 100644 index 0000000..28a03c0 --- /dev/null +++ b/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 +``` \ No newline at end of file diff --git a/changes/n04-onboard/.superpowers/sdd/reviews/W3-wave.md b/changes/n04-onboard/.superpowers/sdd/reviews/W3-wave.md new file mode 100644 index 0000000..5dae57d --- /dev/null +++ b/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` diff --git a/changes/n04-onboard/.superpowers/sdd/task-briefs/T8-repair-1.md b/changes/n04-onboard/.superpowers/sdd/task-briefs/T8-repair-1.md new file mode 100644 index 0000000..5dabfbb --- /dev/null +++ b/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 +``` \ No newline at end of file From 50384e9e8533fa9452f7c7fee01c5973c85400f2 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Thu, 17 Sep 2026 09:53:47 +0800 Subject: [PATCH 14/17] docs: record N04 conditional verification --- changes/n04-onboard/.spec-superflow.yaml | 3 ++ changes/n04-onboard/decision-point-audit.md | 48 +++++++++++++++++++++ changes/n04-onboard/verification-report.md | 25 +++++++++++ 3 files changed, 76 insertions(+) create mode 100644 changes/n04-onboard/decision-point-audit.md create mode 100644 changes/n04-onboard/verification-report.md diff --git a/changes/n04-onboard/.spec-superflow.yaml b/changes/n04-onboard/.spec-superflow.yaml index 68f7604..30761d4 100644 --- a/changes/n04-onboard/.spec-superflow.yaml +++ b/changes/n04-onboard/.spec-superflow.yaml @@ -39,3 +39,6 @@ resume_mode: manual resumed_at: 2026-09-17T01:17:47Z resume_count: 1 verification_blocked: true +verification_outcome: conditional-pending-formal-waiver +dp_6_result: conditional: 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; real MySQL/Docker acceptance pending +dp_6_timestamp: 2026-09-17T01:53:38Z diff --git a/changes/n04-onboard/decision-point-audit.md b/changes/n04-onboard/decision-point-audit.md new file mode 100644 index 0000000..583ef34 --- /dev/null +++ b/changes/n04-onboard/decision-point-audit.md @@ -0,0 +1,48 @@ +# N04 Decision Point Audit + +审计时间:2026-09-17T01:53:38Z + +## 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 | conditional-pending-waiver | N04 专项、OpenAPI、打包通过;全量套件有既有非 N04 失败;真实 MySQL/Docker 待验收 | +| DP-7 | pending | 等待用户接受条件验证并批准归档 | + +## Contract Coverage + +- W1:V38/V39 三张表、通用清单种子、实体、Repository 和公开评价聚合。 +- W2:学生有效清单与完成状态、企业清单 CRUD、公司知识配置与聚合,66 个 wave 测试通过。 +- W3:权限、所有权、幂等、并发、端到端、隐私、OpenAPI 和文档,96 个 onboarding 回归通过。 +- 12 条 MUST/SHALL 行为均有实现、测试和批次证据。 +- 两份 delta specs 已准备好合并到根 specs/;尚未执行 merger。 + +## 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;全量测试外部失败已单独记录。 \ No newline at end of file diff --git a/changes/n04-onboard/verification-report.md b/changes/n04-onboard/verification-report.md new file mode 100644 index 0000000..e4297a1 --- /dev/null +++ b/changes/n04-onboard/verification-report.md @@ -0,0 +1,25 @@ +# N04 Verification Report + +验证时间:2026-09-17T01:53:38Z + +## Outcome + +条件通过,待用户正式接受豁免。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 黑盒验收:未执行。 +- 正式豁免与归档确认尚未记录。 + +## Required Approval + +用户需要明确确认:接受既有非 N04 全量测试失败作为正式豁免,并将真实 MySQL/Docker 验收保留为发布前门禁;确认后方可记录 DP-6 并进入 delta spec 合并和 DP-7。 \ No newline at end of file From 23e0fb96a5991c410c036550a6f555ecebc67d76 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Thu, 17 Sep 2026 10:48:46 +0800 Subject: [PATCH 15/17] docs: publish N04 specs and verification waiver --- changes/n04-onboard/.spec-superflow.yaml | 11 +- .../n04-onboard/.superpowers/sdd/progress.md | 2 + changes/n04-onboard/decision-point-audit.md | 9 +- changes/n04-onboard/spec-merge-report.md | 20 +++ changes/n04-onboard/verification-report.md | 6 +- specs/company-knowledge/spec.md | 129 +++++++++++++++ specs/onboarding-checklist/spec.md | 155 ++++++++++++++++++ 7 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 changes/n04-onboard/spec-merge-report.md create mode 100644 specs/company-knowledge/spec.md create mode 100644 specs/onboarding-checklist/spec.md diff --git a/changes/n04-onboard/.spec-superflow.yaml b/changes/n04-onboard/.spec-superflow.yaml index 30761d4..d3a54f1 100644 --- a/changes/n04-onboard/.spec-superflow.yaml +++ b/changes/n04-onboard/.spec-superflow.yaml @@ -38,7 +38,10 @@ resume_from: T7-review resume_mode: manual resumed_at: 2026-09-17T01:17:47Z resume_count: 1 -verification_blocked: true -verification_outcome: conditional-pending-formal-waiver -dp_6_result: conditional: 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; real MySQL/Docker acceptance pending -dp_6_timestamp: 2026-09-17T01:53:38Z +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 diff --git a/changes/n04-onboard/.superpowers/sdd/progress.md b/changes/n04-onboard/.superpowers/sdd/progress.md index b2db2c6..0ead64d 100644 --- a/changes/n04-onboard/.superpowers/sdd/progress.md +++ b/changes/n04-onboard/.superpowers/sdd/progress.md @@ -21,3 +21,5 @@ - 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. diff --git a/changes/n04-onboard/decision-point-audit.md b/changes/n04-onboard/decision-point-audit.md index 583ef34..45a620b 100644 --- a/changes/n04-onboard/decision-point-audit.md +++ b/changes/n04-onboard/decision-point-audit.md @@ -12,8 +12,8 @@ | 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 | conditional-pending-waiver | N04 专项、OpenAPI、打包通过;全量套件有既有非 N04 失败;真实 MySQL/Docker 待验收 | -| DP-7 | pending | 等待用户接受条件验证并批准归档 | +| DP-6 | pass with formal waiver | N04 专项、OpenAPI、打包通过;既有非 N04 全量失败由用户正式豁免;真实 MySQL/Docker 保留为发布前门禁 | +| DP-7 | pending | 等待归档确认 | ## Contract Coverage @@ -45,4 +45,7 @@ - N04 变更集中在 com.mydreamjob.onboarding、V38/V39、SecurityConfig、ErrorCode、GlobalExceptionHandler、OpenAPI、接口文档和 onboarding 测试。 - 未修改 V1-V38 历史迁移,未改变既有企业、岗位、Offer 或评价接口语义。 -- 所有 W3 任务和 wave 复审均 PASS;全量测试外部失败已单独记录。 \ No newline at end of file +- 所有 W3 任务和 wave 复审均 PASS;全量测试外部失败已单独记录。 +## Formal Waiver + +用户于 2026-09-17T02:47:50Z 明确批准:将与 N04 无关的既有全量测试失败作为正式豁免,并将真实 MySQL 8.4 / Docker 黑盒验收保留为发布前门禁。 diff --git a/changes/n04-onboard/spec-merge-report.md b/changes/n04-onboard/spec-merge-report.md new file mode 100644 index 0000000..383279b --- /dev/null +++ b/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 \ No newline at end of file diff --git a/changes/n04-onboard/verification-report.md b/changes/n04-onboard/verification-report.md index e4297a1..6e3a802 100644 --- a/changes/n04-onboard/verification-report.md +++ b/changes/n04-onboard/verification-report.md @@ -4,7 +4,7 @@ ## Outcome -条件通过,待用户正式接受豁免。N04 范围内验证通过,但仓库全量测试和真实 MySQL/Docker 验收尚未完成。 +通过并带正式豁免。N04 范围内验证通过;仓库全量测试中的既有非 N04 失败已由用户正式豁免;真实 MySQL/Docker 验收保留为发布前门禁。 ## Passed @@ -18,8 +18,8 @@ - mvn clean verify:1492 tests,2 failures,9 errors;失败均在 N04 范围外。 - 真实 MySQL 8.4 / Docker 黑盒验收:未执行。 -- 正式豁免与归档确认尚未记录。 +- 正式豁免已记录;归档确认仍待 DP-7。 ## Required Approval -用户需要明确确认:接受既有非 N04 全量测试失败作为正式豁免,并将真实 MySQL/Docker 验收保留为发布前门禁;确认后方可记录 DP-6 并进入 delta spec 合并和 DP-7。 \ No newline at end of file +用户已批准正式豁免。允许进入 delta spec 合并和 DP-7;真实 MySQL/Docker 验收仍需在发布前完成。 \ No newline at end of file diff --git a/specs/company-knowledge/spec.md b/specs/company-knowledge/spec.md new file mode 100644 index 0000000..08fff64 --- /dev/null +++ b/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 中既有路径的请求与响应语义不变 \ No newline at end of file diff --git a/specs/onboarding-checklist/spec.md b/specs/onboarding-checklist/spec.md new file mode 100644 index 0000000..8b140db --- /dev/null +++ b/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` \ No newline at end of file From f8b3c4040cc84d79285081c5aa0c4f4db3989f50 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Thu, 17 Sep 2026 16:01:00 +0800 Subject: [PATCH 16/17] =?UTF-8?q?category=E7=B1=BB=E5=9E=8B=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E8=A1=A5=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/onboarding-api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/onboarding-api.md b/docs/onboarding-api.md index 683c436..9c86ae8 100644 --- a/docs/onboarding-api.md +++ b/docs/onboarding-api.md @@ -70,6 +70,7 @@ X-Request-Id: ```text DOCUMENT | ADMIN | EQUIPMENT | TRAINING | LOGISTICS | OTHER +证件材料 | 行政手续 | 办公设备 | 培训学习 | 到岗安排 | 其他 ``` ### 3.2 学生读取有效清单 From aa472cf90bee8f0fdc41349852caf7913e25b8a8 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Thu, 17 Sep 2026 16:03:21 +0800 Subject: [PATCH 17/17] chore: close N04 onboarding --- changes/n04-onboard/.spec-superflow.yaml | 5 ++++- changes/n04-onboard/.superpowers/sdd/progress.md | 1 + changes/n04-onboard/decision-point-audit.md | 6 +++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/changes/n04-onboard/.spec-superflow.yaml b/changes/n04-onboard/.spec-superflow.yaml index d3a54f1..f24fcc5 100644 --- a/changes/n04-onboard/.spec-superflow.yaml +++ b/changes/n04-onboard/.spec-superflow.yaml @@ -1,5 +1,5 @@ version: 1 -state: executing +state: closing workflow: full change_name: n04-onboard artifact_language: zh-CN @@ -45,3 +45,6 @@ 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 diff --git a/changes/n04-onboard/.superpowers/sdd/progress.md b/changes/n04-onboard/.superpowers/sdd/progress.md index 0ead64d..323d53e 100644 --- a/changes/n04-onboard/.superpowers/sdd/progress.md +++ b/changes/n04-onboard/.superpowers/sdd/progress.md @@ -23,3 +23,4 @@ - 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. diff --git a/changes/n04-onboard/decision-point-audit.md b/changes/n04-onboard/decision-point-audit.md index 45a620b..be6f3b0 100644 --- a/changes/n04-onboard/decision-point-audit.md +++ b/changes/n04-onboard/decision-point-audit.md @@ -1,6 +1,6 @@ # N04 Decision Point Audit -审计时间:2026-09-17T01:53:38Z +审计时间:2026-09-17T08:03:00Z ## Decision Point Status @@ -13,7 +13,7 @@ | 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 | pending | 等待归档确认 | +| DP-7 | confirmed | 用户授权归档并手工合并 feat/n04-onboard 到 master | ## Contract Coverage @@ -21,7 +21,7 @@ - W2:学生有效清单与完成状态、企业清单 CRUD、公司知识配置与聚合,66 个 wave 测试通过。 - W3:权限、所有权、幂等、并发、端到端、隐私、OpenAPI 和文档,96 个 onboarding 回归通过。 - 12 条 MUST/SHALL 行为均有实现、测试和批次证据。 -- 两份 delta specs 已准备好合并到根 specs/;尚未执行 merger。 +- 两份 delta specs 已发布到根 specs/,共 12 条 Requirements,无冲突。 ## Verification Evidence