Browse Source

merge: add N03 skill cloud and learning path APIs

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
2db244e6b2
  1. 47
      changes/n03-skill-cloud/.spec-superflow.yaml
  2. 29
      changes/n03-skill-cloud/.superpowers/sdd/execution-plan.yaml
  3. 32
      changes/n03-skill-cloud/.superpowers/sdd/progress.md
  4. 54
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T1.md
  5. 98
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T2.md
  6. 136
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T3-final-review.md
  7. 207
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T3-rereview-2.md
  8. 158
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T3-rereview.md
  9. 155
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T3.md
  10. 130
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T4-rereview.md
  11. 150
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T4.md
  12. 94
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T5-rereview.md
  13. 94
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T5.md
  14. 98
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T6-rereview.md
  15. 71
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T6.md
  16. 80
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T7-rereview.md
  17. 72
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T7.md
  18. 175
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-wave.md
  19. 105
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T8-rereview.md
  20. 87
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T8.md
  21. 177
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T9.md
  22. 168
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave-final-review.md
  23. 178
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave-rereview.md
  24. 198
      changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave.md
  25. 151
      changes/n03-skill-cloud/.superpowers/sdd/reviews/runtime-startup-fix-review.md
  26. 14
      changes/n03-skill-cloud/.superpowers/sdd/runtime-startup-failure.txt
  27. 77
      changes/n03-skill-cloud/data/source-manifest.md
  28. 55
      changes/n03-skill-cloud/decision-point-audit.md
  29. 180
      changes/n03-skill-cloud/design.md
  30. 147
      changes/n03-skill-cloud/execution-contract.md
  31. 59
      changes/n03-skill-cloud/proposal.md
  32. 84
      changes/n03-skill-cloud/specs/learning-path/spec.md
  33. 98
      changes/n03-skill-cloud/specs/skill-cloud/spec.md
  34. 98
      changes/n03-skill-cloud/specs/skill-match/spec.md
  35. 38
      changes/n03-skill-cloud/tasks.md
  36. 19
      docs/frontend-integration.md
  37. 722
      docs/openapi-rc.json
  38. 275
      docs/skill-cloud-api.md
  39. 79
      docs/skill-taxonomy-sources.md
  40. 68
      docs/verify_openapi.py
  41. 2
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  42. 207
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java
  43. 160
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java
  44. 95
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java
  45. 152
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillMatchService.java
  46. 28
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillNameNormalizer.java
  47. 62
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/OccupationSkill.java
  48. 56
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/Skill.java
  49. 41
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillAlias.java
  50. 50
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillCategory.java
  51. 47
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillIndustry.java
  52. 56
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillOccupation.java
  53. 50
      mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillSourceVersion.java
  54. 20
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudItem.java
  55. 8
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudNode.java
  56. 15
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudResponse.java
  57. 21
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudSkillProjection.java
  58. 11
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathItem.java
  59. 12
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathResponse.java
  60. 16
      mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillMatchResponse.java
  61. 73
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/OccupationSkillRepository.java
  62. 15
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillAliasRepository.java
  63. 13
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillCategoryRepository.java
  64. 7
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillIndustryRepository.java
  65. 13
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillOccupationRepository.java
  66. 15
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillRepository.java
  67. 7
      mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillSourceVersionRepository.java
  68. 120
      mydreamjob-server/src/main/resources/db/migration/V35__skill_cloud_taxonomy.sql
  69. 413
      mydreamjob-server/src/main/resources/db/migration/V36__skill_cloud_seed.sql
  70. 12
      mydreamjob-server/src/main/resources/db/migration/V37__skill_cloud_boolean_type.sql
  71. 236
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java
  72. 430
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java
  73. 451
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java
  74. 325
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java
  75. 1036
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudSeedDataIntegrationTest.java
  76. 416
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java
  77. 482
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillMatchServiceTest.java
  78. 373
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java
  79. 294
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyRepositoryTest.java
  80. 93
      mydreamjob-server/src/test/resources/skill-cloud/onet-31-reference.csv
  81. 222
      mydreamjob-server/src/test/resources/skill-cloud/skill-cloud-expected-mappings.csv
  82. 88
      specs/learning-path/spec.md
  83. 102
      specs/skill-cloud/spec.md
  84. 102
      specs/skill-match/spec.md

47
changes/n03-skill-cloud/.spec-superflow.yaml

@ -0,0 +1,47 @@
version: 1
state: executing
workflow: full
change_name: n03-skill-cloud
artifact_language: zh-CN
dp_0_confirmed: true
dp_0_result: confirmed
dp_0_timestamp: 2026-09-14T07:30:33Z
dp_0_decisions: |
变更名称:n03-skill-cloud;一句话意图:基于 ESCO + O*NET 开源词库提供行业技能词云、个人 TOP10 技能匹配度和学习路径接口。
工作流:full;选择原因:新增模块、Flyway 数据结构、三个 API、权限与测试,属于跨模块行为变更。
约束:仅登录 STUDENT 可访问;行业和职位必填、技能分类默认全部;热度使用 O*NET Importance;无权重技能保留且排后;不调用 AI;不接课程平台;首版仅覆盖互联网、金融、教育及代表性职位;词库通过 Flyway 落库。
相关优化:保持聚焦,不纳入平台岗位热度聚合、后台词库维护、学习进度记录或前端开发。
沟通方式:按决策点审查,规划产物一次生成后统一确认。
artifact_language=zh-CN
dp_1_result: confirmed
dp_1_timestamp: 2026-09-14T07:30:33Z
dp_2_result: approved: proposal, three delta specs, design, and tasks validated
dp_2_confirmed: true
dp_2_timestamp: 2026-09-14T07:51:20Z
contract_hash: 819762ae94f2cb3d11820904d013947c367e4bbb32fe8773a3510a253be9ca97
dp_3_result: approved: execution contract B1-B3; revision 2 allows ESCO primary plus O*NET software_skills supplemental source with machine-traceable importance provenance
dp_3_confirmed: true
dp_3_timestamp: 2026-09-14T07:56:28Z
execution_mode: sdd
plan_revision: 1
dp_4_result: approved: SDD selected; W1 serial T1-T3, W2 serial T4-T7 depends W1, W3 serial T8-T10 depends W2
dp_4_confirmed: true
dp_4_timestamp: 2026-09-14T08:00:29Z
batches_completed: 3
dp_5_result: adjudicated: approved one focused repair for exact per-skill source and full proxy assertions
dp_5_timestamp: 2026-09-14T09:21:01Z
execution_paused: false
verification_blocked: false
verification_outcome: conditional-pass-with-formal-waiver
dp_6_result: pass: 74 Skill Cloud tests, OpenAPI 129 paths, package and startup fix verified; 3 unrelated pre-existing full-suite failures formally waived by user
dp_6_timestamp: 2026-09-15T08:40:05Z
test_result: pass: scoped verification with formal waiver
paused_at: 2026-09-14T09:44:45Z
resumed_at: 2026-09-15T01:18:50Z
resume_count: 1
paused_reason: user requested immediate pause; W2 may only resume at T4 on manual request
resume_from: T4
resume_mode: manual
artifacts:
order: [proposal, specs, design, tasks]
skip: []

29
changes/n03-skill-cloud/.superpowers/sdd/execution-plan.yaml

@ -0,0 +1,29 @@
plan_revision: 1
selected_mode: sdd
contract_hash: 819762ae94f2cb3d11820904d013947c367e4bbb32fe8773a3510a253be9ca97
created_at: 2026-09-14T08:00:29Z
paused: false
resumed_at: 2026-09-15T01:18:50Z
current: verification
waves:
- id: W1
strategy: serial
tasks: [T1, T2, T3]
depends_on: []
state: passed
receipt: pass (T1, T2, T3 final review)
- id: W2
strategy: serial
tasks: [T4, T5, T6, T7]
depends_on: [W1]
state: passed
receipt: pass (W2-wave; 50 tests)
- id: W3
strategy: serial
tasks: [T8, T9, T10]
depends_on: [W2]
state: passed_with_external_blocker
receipt: pass (W3 final review; 73 targeted tests, OpenAPI 129 paths)
verification_blocked: false
verification_outcome: conditional-pass-with-formal-waiver
closure_requires: DP-7 confirmation pending

32
changes/n03-skill-cloud/.superpowers/sdd/progress.md

@ -0,0 +1,32 @@
# N03 SDD Progress Ledger
- 2026-09-14T08:00:29Z Plan revision 1 created for mode SDD.
- W1 eligible: T1 → T2 → T3 (serial, no prerequisite).
- W2 blocked on W1: T4 → T5 → T6 → T7.
- W3 blocked on W2: T8 → T9 → T10.- 2026-09-14T08:05:32Z Task T1: complete (commit b45538d, review W1-T1 PASS, 10 representative positions fixed).
- 2026-09-14T08:19:38Z Task T2: complete (review W1-T2 PASS, RED->GREEN, 2 tests pass).
- 2026-09-14T08:28:32Z Task T2: complete (commit 93477bd, review W1-T2 PASS, migration tests 2/2).
- 2026-09-14T08:42:56Z Task T3: failed attempt 1/3 - critical source-model conflict; review W1-T3 FAIL. Hybrid O*NET software skills not authorized by current contract.
- 2026-09-14T09:07:23Z Task T3: failed attempt 2/3 - review W1-T3-rereview FAIL; data correct but tests only sampled provenance.
- 2026-09-14T09:17:50Z Task T3: failed attempt 3/3 - review W1-T3-rereview-2 FAIL; source data correct but test falsifiability still incomplete. Automatic dispatch stopped; human adjudication required.
- 2026-09-14T09:21:01Z DP-5 adjudication: continue with one focused test-falsifiability repair.
- 2026-09-14T09:38:16Z Task T3: complete after adjudicated repair (final review PASS; 10 tests green; 221 exact mappings; W1 complete).
- 2026-09-14T09:44:38Z User paused execution immediately. Active T4 agent was terminated before completion; partial uncommitted T4 files were removed. W1 remains passed. Resume manually from T4 only; do not start T5-T10 earlier.
- 2026-09-15T01:18:50Z Manual resume accepted. W1 remains passed; W2/T4 is eligible. T5-T10 remain blocked until predecessor reviews pass.
- 2026-09-15T01:29:44Z Task T4: failed attempt 1/3 - review W2-T4 FAIL; category join lacks same-occupation guard and test cannot falsify cross-position mismatch.
- 2026-09-15T01:37:17Z Task T4: complete (commits 0c77824, 52751e5; review W2-T4-rereview PASS; 16 tests). T5 eligible.
- 2026-09-15T01:48:56Z Task T5: failed attempt 1/3 - review W2-T5 FAIL; SkillCloudItem omitted sourceType/sourceCode.
- 2026-09-15T01:54:38Z Task T5: complete (commits e3eecfe, 1666162; review W2-T5-rereview PASS; 24 tests). T6 eligible.
- 2026-09-15T02:08:59Z Task T6: failed attempt 1/3 - review W2-T6 FAIL; tests do not isolate nameZh and alias matching branches.
- 2026-09-15T02:14:37Z Task T6: complete (commit 6eb30e7; review W2-T6-rereview PASS; 40 tests). T7 eligible.
- 2026-09-15T02:23:08Z Task T7: failed attempt 1/3 - review W2-T7 FAIL; missing-skill sequence test not falsifiable because names/IDs were already ascending.
- 2026-09-15T02:36:53Z W2 wave review PASS; 50 tests; W3/T8 eligible. Minor items carried to T8/T9.
- 2026-09-15T02:47:27Z Task T8: failed attempt 1/3 - review W3-T8 FAIL; OpenAPI examples inconsistent and role tests mocked ProfileService.
- 2026-09-15T02:59:54Z Task T8: complete (commit 3e353bd; review W3-T8-rereview PASS; 66 tests). T9 eligible.
- 2026-09-15T03:17:22Z Task T9: complete (commit 7873a42; review W3-T9 PASS; 71 targeted tests). External full-suite blocker recorded: 2 failures + 9 errors in unrelated CareerProgress/JobReview/LocalResume tests; no T9 impact. T10 docs may proceed, final verification remains blocked.
- 2026-09-15T03:29:18Z W3 wave review FAIL: duplicate operationId getMatch and 401 response contract mismatch; full-suite external blocker remains open.
- 2026-09-15T03:40:12Z W3 rereview FAIL: N-I1 existing cancel operationIds changed by global uniqueness check; restore legacy and scope check to new operations.
- 2026-09-15T03:46:09Z W3 final review PASS for T8-T10; 73 targeted tests and OpenAPI/package checks pass. W3 code complete. Final closure blocked by unrelated full-suite failures: 2 failures + 9 errors. Waiver or out-of-scope repair authorization required.
- 2026-09-15T05:48:03Z Runtime startup bug: Hibernate schema validation failed on occupation_skills.importance_is_proxy (found BIT, expected TINYINT). Root cause: forced @JdbcTypeCode(SqlTypes.TINYINT) unlike existing Boolean mappings. Fix dispatched with mapping regression test.
- 2026-09-15T06:03:54Z Runtime bug fix attempt 2: first fix removed forced TINYINT but H2 still stores TINYINT and expects BOOLEAN; 34 tests failed. New hypothesis: add V37 to normalize column to BOOLEAN (MySQL alias TINYINT(1)) and remove test-only preferred type override.
- 2026-09-15T06:20:09Z Runtime startup fix PASS: V37 normalizes importance_is_proxy to cross-dialect BOOLEAN, entity uses default Boolean mapping; 74 targeted tests and package pass without extra JVM properties.

54
changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T1.md

@ -0,0 +1,54 @@
# W1-T1 独立评审报告
## 结论
**PASS**
T1 文档交付满足已批准执行契约中 B1 对来源、覆盖边界、映射责任、导入规则和迁移安全的要求,未发现会阻断 T2 开始的 Critical 或 Important finding。
## 评审范围与证据
- `D:\1\zhi\zhilai\changes\n03-skill-cloud\data\source-manifest.md`
- `D:\1\zhi\zhilai\docs\skill-taxonomy-sources.md`
- 对照依据:`execution-contract.md`、`tasks.md`、`design.md` 及三份 spec。
独立检查结果:
1. 两份文件均存在且非空:manifest 为 8332 bytes/77 行,docs 为 8485 bytes/79 行。移除各自标题及 `docs` 的引言段后,事实正文逐行完全一致。
2. 固定来源完整:两份文件均明确 ESCO dataset v1.2.1(Last update 10/12/2025,Release v5.7.0)、O*NET 31.0 Database、对应许可、官方归属 URL 和 ESCO 指定署名语句。证据:manifest 第 7-10、47-52 行;docs 第 9-12、49-54 行。
3. 运行时离线约束明确:禁止运行时访问 ESCO、O*NET 或在线词库,首版数据随 Flyway 进入本地数据库。证据:manifest 第 10、43 行;docs 第 12、45 行。
4. 项目映射责任明确:项目维护中文名称、别名、细粒度技能分类及行业/职位映射;上游来源 ID 原样保存、不得重编号或伪造。证据:manifest 第 36-40、58-63 行;docs 第 38-42、60-65 行。
5. T2/T3 分工明确:T2 只建七张表、外键、唯一约束和索引;T3 才导入三类行业、代表性职位、分类、技能、别名及 O*NET Importance。证据:manifest 第 34 行;docs 第 36 行。
6. 中文名与缺权规则明确:入选技能必须有非空 `name_zh`;无 O*NET Importance 的技能必须保留并写 `importance=null`,查询映射为 `heat=null` 且排在有权重技能之后。证据:manifest 第 37-38 行;docs 第 39-40 行。
7. 迁移安全规则明确:计划使用 V35/V36;若编号被占用必须回退刷新;不得改写 V1-V34 或任何已执行迁移。当前迁移目录仍以 V34 为最新,V35/V36 均不存在,因此该规则与当前仓库状态一致。证据:manifest 第 42 行;docs 第 44 行。
8. 覆盖矩阵覆盖互联网、金融、教育三类行业,共 10 个实际列出的代表性职位,计数为互联网 4、金融 3、教育 3,每个职位均给出首批技能分类范围。证据:manifest 第 14-30 行;docs 第 16-32 行。
9. 未发现 `TBD`、`TODO`、“稍后确定”、“待定”或 placeholder;未发现与已批准 spec、design 或 execution contract 的行为冲突。
10. 未发现 T1 越界实现:`com.mydreamjob.skill` 主代码和测试目录均不存在,V35/V36 迁移也不存在,符合 T1 只交付来源文档的范围。
## Findings
### Critical
无。
### Important
无。
### Minor
1. **派发文字中的“11 个职位”与实际枚举的 10 个职位不一致。**
已批准 execution-contract、tasks.md、design.md 和 spec 均只规定“互联网、金融、教育及代表性职位”,没有规定硬性 11 个;本轮派发提示中的枚举实际为互联网 4、金融 3、教育 3,共 10 个。两份交付物覆盖全部实际枚举职位,并明确说明未自行新增未批准职位。该差异不影响 T1 验收,也不阻断 T2;建议后续派发统一按 10 个职位表述,除非人工补充第 11 个已批准职位名称。
2. **T1 固化的是技能分类范围和技能级来源规则,而不是逐个技能实例的 ESCO/O*NET 来源 ID 与中文标准名清单。**
这与 T1 的“可审查数据边界”目标一致,不构成当前阻断;具体技能级来源 ID、中文标准名及 Importance 映射仍须在 T3 种子数据中逐项落实,并由迁移测试验证。
## 未决问题
- 无阻断 T2 的未决问题。
- T3 必须履行文档已固定的技能级约束:稳定来源 ID 原样保存、`name_zh` 非空、缺失 Importance 写 `null`。
- 如后续验收仍坚持“11 个职位”,必须由人工提供第 11 个已批准职位名称;当前无任何已批准依据可推导该职位。
## 阻断项
无。

98
changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T2.md

@ -0,0 +1,98 @@
# W1-T2 独立评审报告
## 状态
完成。本次评审未修改任何实现文件,仅写入本报告。
## Verdict
**PASS**
T2 已建立执行契约要求的七张词库表、关键字段、外键、唯一约束、CHECK 约束和两个查询索引;迁移可执行,且未顺带实现 T3 种子数据或 Java 业务代码。未发现阻断 T3 的 Critical 或 Important finding。
## 评审对象与依据
评审对象:
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\resources\db\migration\V35__skill_cloud_taxonomy.sql`
- `D:\1\zhi\zhilai\mydreamjob-server\src\test\java\com\mydreamjob\skill\SkillTaxonomyMigrationTest.java`
对照依据:
- `D:\1\zhi\zhilai\changes\n03-skill-cloud\execution-contract.md`
- `D:\1\zhi\zhilai\changes\n03-skill-cloud\design.md`
- `D:\1\zhi\zhilai\changes\n03-skill-cloud\tasks.md`
- T2 RED/GREEN 日志
- `git diff --cached`、`git diff HEAD` 和 `git diff --check`
- JDK 17 下的独立测试执行结果
## 独立检查结果
1. **七张表及关键列存在。** V35 第 5、17、28、44、60、77、88 行分别创建 `skill_source_versions`、`skill_industries`、`skill_occupations`、`skill_categories`、`skills`、`skill_aliases`、`occupation_skills`。测试通过真实 `DatabaseMetaData` 查询七个表的全部预期列,见测试第 55-74、223-240 行,不是 SQL 文本断言。
2. **外键真实存在。** V35 第 40-41、57、74、85、105-108 行建立行业、来源版本、职位、分类、技能和别名之间的 9 条外键;测试第 86-94 行通过 `getImportedKeys` 逐条验证。
3. **唯一约束真实存在。** V35 第 14、25、39、53、71-72、83-84、98 行覆盖来源版本、行业编码、行业内职位编码、职位内分类编码、技能编码、技能来源、别名归一化值、技能别名和职位技能关系;测试第 76-84 行逐项验证唯一索引。
4. **CHECK 约束符合契约。** 分类模板限定为 `PROGRAMMING_LANGUAGE`、`FRAMEWORK`、`TOOL`、`GENERAL`,见 V35 第 54-56 行;技能来源限定为 `ESCO`、`O*NET`、`PROJECT`,见第 73 行;关系类型限定为 `ESSENTIAL`、`OPTIONAL`、`PROJECT`,见第 102-104 行。非法模板、非法来源和非法关系类型均通过实际失败插入验证,见测试第 159-169、182-186 行。
5. **`importance` 可空且无默认权重。** V35 第 93 行定义 `DECIMAL(4,2)` 且没有 `NOT NULL` 或 `DEFAULT`;第 99-101 行只约束为空或位于 0.00-5.00。测试插入时省略该列并断言查询结果为 `null`,随后验证 `4.25` 可保存,见测试第 133-153 行。
6. **中文名称非空。** 行业、职位、分类和技能的中文名称均定义为 `NOT NULL`,见 V35 第 20、32、48、63 行;测试第 176-181 行通过插入 `name_zh=null` 并断言失败。
7. **两个查询索引真实存在。** V35 第 109-110 行建立 `(occupation_id, category_id)` 与 `(occupation_id, importance)` 索引;测试第 96-97 行通过数据库元数据验证。`occupation_id + importance` 可被 MySQL 正向或反向扫描用于热度降序查询。
8. **迁移范围正确。** `git diff --name-status HEAD` 在迁移和主代码范围内仅显示新增 `V35__skill_cloud_taxonomy.sql` 及新测试文件;V1-V34 未修改,V36 尚不存在,`src/main/java` 无变更,未顺带实现 T3 或 Java 业务代码。
9. **测试失败有效性、隔离和清理。** RED 日志第 60-125 行显示 V35 缺失时两项测试分别因缺表失败;测试使用独立的 `integrationdb_skill_taxonomy` H2 数据库,见第 32-33 行;`@AfterEach` 按外键逆序清理插入数据,见第 196-221 行。未发现仅源码文本断言、共享默认 H2 库污染或明显漏清理。
10. **MySQL/H2 兼容性。** `AUTO_INCREMENT`、InnoDB、`utf8mb4_unicode_ci`、`DATETIME DEFAULT/ON UPDATE CURRENT_TIMESTAMP`、表内 `KEY`、CHECK 和命名外键均属于 MySQL 8.4 支持的语法;H2 在 `MODE=MySQL` 下已成功执行整份迁移。未发现仅 H2 成立的 DDL 结构。由于本机没有 Docker,且本地 MySQL 服务无可用测试凭据,本报告未进行真实 MySQL 样库执行;该验证限制不阻断仅要求 H2 迁移测试的 T2。
## 独立测试结果
执行环境与命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' -Dtest=SkillTaxonomyMigrationTest test
```
结果:
```text
Java version: 17.0.16
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 36.770 s
```
提供的 GREEN 日志与独立执行结果一致:JDK 17.0.16、2 项测试全部通过、`BUILD SUCCESS`。RED 日志也证明测试确实依赖真实 V35 迁移结果。
## Findings
### Critical
无。
### Important
无。
### Minor
1. **迁移测试只断言列存在,不直接断言全部列类型和可空性。** 测试第 223-240 行收集的是列名;当前 DDL 的类型和可空性经人工核对符合 design/契约,因此不阻断,但未来类型回归不会全部被测试发现。建议后续在数据库元数据断言中补充 `importance DECIMAL(4,2) nullable`、各 `name_zh NOT NULL` 及关键 ID 列类型检查。
2. **`importance` 的越界分支未行为测试。** 测试覆盖了 `null` 和 `4.25`,但未尝试 `-0.01` 或 `5.01`,因此当前 CHECK 上下界只经人工核对,见 V35 第 99-101 行。风险较低,可在 T3 或端到端回归中补充。
3. **约束失败断言捕获所有 `DataAccessException`,未锁定具体约束。** 测试第 330-332 行通过这一点;当前各失败用例的其余字段均合法,因此实际原因基本可确定,但测试信息量和诊断精度略低。建议后续断言约束名或 SQL 状态/错误码,进一步防止因无关错误而误通过。
4. **测试文件末尾存在多余空白行,`git diff --check` 报告 `new blank line at EOF`。** 证据为测试文件第 334 行。纯格式问题,不阻断 T3。
## 未决问题
- 本报告仅在 H2 `MODE=MySQL` 下实际执行了迁移;真实 MySQL 8.4 执行应在已有 MySQL 凭据或 Docker 环境中由后续发布级验证补充。静态检查未发现 MySQL 不兼容语法。
- 分类必须属于职位属于服务层校验责任,T2 只提供基础外键;该跨列一致性须在 T4/T8 按 design 第 117、170 行落实并验证。
## 阻断项
无。

136
changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T3-final-review.md

@ -0,0 +1,136 @@
# W1-T3 最终独立复审报告(DP-5 唯一复审)
## 状态
完成。本轮只读取实现、V35/V36、期望映射、证据快照和前序评审报告,并独立执行指定测试、CSV 来源审计与文件哈希核验;未修改任何实现或测试文件,仅写入本报告。
## Verdict
**PASS**
第三轮 findings 已全部关闭,允许进入 T4。未发现 Critical 或 Important 阻断项。
## 独立测试输出
环境:
- `JAVA_HOME=D:\java\jdk`
- Java:OpenJDK 17.0.16
- Maven:`D:\maven\apache-maven-3.9.7\bin\mvn.cmd`
- 工作目录:`D:\1\zhi\zhilai\mydreamjob-server`
命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest' test
```
结果:
```text
SkillCloudSeedDataIntegrationTest: Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
SkillTaxonomyMigrationTest: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
Total: Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 39.479 s
```
## 第三轮 Finding 逐项关闭状态
### 1. 47/47 条 O*NET 技能来源逐技能精确锁定:关闭
- `skill-cloud-expected-mappings.csv` 含 47 条 `SKILL_SOURCE` + `O*NET` 记录,47 个技能编码和 47 个来源三元组均唯一。
- `verifyExpectedMappings` 先通过 `assertSameMappingKeys` 精确比较期望文件与数据库的全部实体键;随后对每一条执行 `assertMappingMatches`,逐条比较 `source_type` 和 `source_code`。
- `verifySkillEvidence` 逐条将 47 个 `skill_code -> SOC|Workplace Example|Element ID` 与证据快照核对,并用 `assertEvidenceKeySet` 要求期望软件来源集合与快照 47 条集合完全相等。
- 交换任意两个 O*NET 技能来源时,至少一行会在逐字段断言处失败,不再存在仅验证“来源三元组集合合法”的缺口。
- `assertMappingVerifierRejectsTampering` 的交换来源自检通过,且它调用的是全量循环实际使用的同一个比较函数。
### 2. 137/137 条非空 Importance 全字段精确断言:关闭
- 期望文件包含 139 条职位技能关系,其中 137 条非空 Importance、2 条空 Importance。
- 137 条非空关系均包含非空 `expected_source_code`、`expected_element_id`、`expected_importance`,且 `expected_is_proxy=1`。
- 全量循环对每条关系精确比较 `source_type`、`source_code`、`element_id`、`importance` 和 `importance_is_proxy`;数值使用 `0.001` 容差,其余字段使用严格相等。
- 任意一条非空关系的 proxy 从 `1` 改为 `0`、来源 SOC/Element ID 被替换、或数值被修改,都会在对应关系上失败。
- 2 条空 Importance 关系均断言来源 SOC、Element ID、Importance 为空且 `importance_is_proxy=0`。
### 3. expected-mappings 覆盖且仅覆盖 10/10 occupation、72/72 skill、139/139 relation:关闭
- 期望文件共 221 条数据行:`OCCUPATION_SOURCE=10`、`SKILL_SOURCE=72`、`RELATION=139`。
- `record_type + entity_key` 重复数:0。
- `assertExpectedMappingCounts` 锁定三类记录数量;`assertSameMappingKeys` 进一步比较期望与数据库完整键集合,缺项和多余项都会失败。
- 独立测试通过证明 Flyway 实际生成的 10 个职位、72 个技能和 139 条关系与期望文件一一对应。
### 4. expected-mappings 与证据快照交叉一致,且 reference 匹配官方 O*NET 31.0 CSV:关闭
独立解析结果:
| 检查项 | 结果 |
| --- | ---: |
| `onet-31-reference.csv` 数据行 | 92 |
| SOFTWARE 证据 | 47,键无重复 |
| IMPORTANCE 唯一证据 | 45,键无重复 |
| 47/47 SOFTWARE 在官方 `software_skills.csv` 精确命中 | 通过 |
| 45/45 IMPORTANCE 在声明官方 CSV 中精确命中 SOC、Element ID、Scale ID=IM、Data Value | 通过 |
| 137/137 加权关系的证据键和值可回溯到 reference | 通过 |
| 期望文件引用的 SOFTWARE 与 reference SOFTWARE 集合相等 | 通过 |
| 期望文件引用的唯一 IMPORTANCE 键与 reference IMPORTANCE 集合相等 | 通过 |
`target/onet-31` 当前 CSV 哈希:
```text
software_skills.csv 6AABB96B464288DB849580E6510530EFFF3F80E25DDE2BB23F1FC36BB526B016
work_activities.csv 6C266205B2926C0C854A7A577AAE29FE492C71BB0DF7E197ADCA830B58FE4EA1
knowledge.csv 88092E17D16096CDEBE292FEE4389DEB460EDDA24AE8B4637212C69C71562F55
essential_skills.csv 68C4D31D8C55077238395DCA40CC4E6D32E6A608B5474BB8264B1F98AE415BD7
transferable_skills.csv F17D0419E818A3BF2F55076510C76C4AEF01CC8F5AEE2670D2F44F6F44AC60F7
occupation_data.csv A09EAE1D6609686E44E05B7290993A1C8B523D8CA224BC0EEDC194C855C3EE02
```
这些 CSV 哈希与前次独立审计记录的 O*NET 31.0 官方发布包解压文件哈希一致。本轮再次请求官方 ZIP 时收到 HTTP 200,但连接在完成前中断;因此未重复完成 ZIP 级哈希计算。该限制不影响本轮映射验证,因为 reference 已与哈希不变的官方 CSV 逐行精确匹配。
### 5. 测试独立性、无 target 依赖、可证伪性:关闭
- `SkillCloudSeedDataIntegrationTest` 只使用 `ClassPathResource` 读取 `skill-cloud/onet-31-reference.csv` 和 `skill-cloud/skill-cloud-expected-mappings.csv`。
- 测试源码未引用 `V36` 文件、`target/onet-31`、文件系统绝对路径或其他运行后才生成的期望数据。
- 期望映射是独立静态资源;数据库实际值通过 Flyway 迁移和 JDBC 读取,不是从 V36 文本反推后生成期望文件。
- 测试可在没有 `target/onet-31` 的前提下运行;`target/onet-31` 仅在本次人工来源审计中用于交叉核对。
- 自检覆盖来源交换、proxy 翻转和 Importance 篡改,且生产断言路径已对全部 221 条期望实体执行。
### 6. 68/68 分类、8 个唯一 ESCO occupation URI、混合来源契约及 V35/V36 约束:关闭
- 测试 `seedDataCoversIndustriesOccupationsAndEveryDeclaredCategory` 通过:3 个行业、10 个职位、68 个分类,10 个职位的分类清单逐项一致,每个分类至少关联 1 条技能关系,每个职位至少 10 个加权技能。
- 10 个职位均使用 ESCO 职位来源;8 个 URI 值唯一,均符合 `http://data.europa.eu/esco/occupation/...` 格式。DevOps 与量化开发按已批准契约复用软件开发职位 URI。
- 混合来源契约保持明确:25 个技能为 ESCO,47 个具体技术为 O*NET `software_skills` 补充;`skills.source_type`、来源三元组唯一约束和来源版本外键一致。
- V35 仍具备所需唯一约束、外键、查询索引、`importance` 0–5 CHECK、非空 Importance 来源完整性 CHECK 和 proxy `0/1` CHECK。
- V36 的 137 条非空 Importance 均保存 O*NET SOC、Element ID 和 proxy=1;2 条无可靠映射的关系保留技能并写入 NULL,proxy=0。
- H2 `MODE=MySQL` 集成迁移测试通过;本轮未新增真实 MySQL 8.4 执行要求。
## 文件哈希
```text
A9399B4C1D9525F3ED91694FE8F02433C1BF634B458C693D91A4014C1252183B V35__skill_cloud_taxonomy.sql
0D5D39A556B6D72CDB21422ED7020B1EAEC0BE8661635B497D12B40D9D104724 V36__skill_cloud_seed.sql
2A281B099AF4DB5E8117A99D8281191EBC60E68B4BC0E153551AD2013B0071A5 SkillCloudSeedDataIntegrationTest.java
4672287B3197784E4144EE36C322AD27AB3BD6955D6626D885E31349C9A19435 onet-31-reference.csv
F6D7545F01C18A6C13D17C00A051D757AED4A8BD2BEBA34757D5DEC1403A6C7B skill-cloud-expected-mappings.csv
```
## Findings
### Critical
无。
### Important
无。
### Minor
- 官方发布包 ZIP 本轮重下载未完成,reference 的官方身份依赖“官方 CSV 哈希不变 + 前次已建立的官方 ZIP 审计记录”。当前不影响 T3 正确性或 T4 放行。
- 测试不会联网重新校验官方 CSV 哈希;这是有意的离线设计,但后续若更新 reference,仍需人工保留来源审计记录。
## 是否允许进入 T4
**允许。** 第三轮两项 Important finding 均已关闭,10/10 测试通过,未发现新的 Critical 或 Important 问题。

207
changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T3-rereview-2.md

@ -0,0 +1,207 @@
# W1-T3 最终独立复审报告(第二轮修复)
## 状态
完成。本次复审只读取实现、迁移、来源清单、前两轮报告和本地/官方 CSV,并独立执行指定测试、来源审计及 ESCO URI 只读核验;未修改任何实现文件,仅写入本报告。
## Verdict
**FAIL**
第二轮唯一 Important finding 尚未完全关闭:
- 47 条 O*NET `software_skills` 来源当前数据全部真实,但测试只验证“每条 DB 三元组存在于快照集合中”,没有验证“每个技能代码对应唯一正确的三元组”;交换两个未被单点断言的技能来源后,测试仍会通过。
- 137 条非空 Importance 的值均已逐条核对,但测试对 `importance_is_proxy` 仍主要是 `0/1` 枚举检查,仅 10 条做 `proxy=1` 精确断言;将其余 127 条中的任一 `1` 改为 `0` 后,测试仍会通过。
以上属于回归保护缺口,不阻断当前种子数据的正确性,但按 TO-12 和本轮“篡改必须失败”的要求,仍阻断 T4。
## 独立测试结果
环境:
- `JAVA_HOME=D:\java\jdk`
- Maven:`D:\maven\apache-maven-3.9.7\bin\mvn.cmd`
- 工作目录:`D:\1\zhi\zhilai\mydreamjob-server`
执行:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest' test
```
结果:
```text
SkillCloudSeedDataIntegrationTest: Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
SkillTaxonomyMigrationTest: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
Total: Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 39.800 s
```
## 来源审计方法与结果
### 1. O*NET 31.0 官方来源核验
独立下载官方发布包:
- URL:`https://www.onetcenter.org/dl_files/database/db_31_0_csv.zip`
- HTTP:`200`
- 文件大小:`16,237,378` bytes
- HTTP `Last-Modified`:`Tue, 25 Aug 2026 03:46:00 GMT`
- 官方 ZIP SHA-256:`55033FC68B4C13EC23E7F74DC6378660F6E854E75D55D6E333AE0A761D3987CD`
从官方 ZIP 解压后与 `mydreamjob-server/target/onet-31` 逐文件比较,SHA-256 完全一致:
| 文件 | SHA-256 |
| --- | --- |
| `software_skills.csv` | `6AABB96B464288DB849580E6510530EFFF3F80E25DDE2BB23F1FC36BB526B016` |
| `work_activities.csv` | `6C266205B2926C0C854A7A577AAE29FE492C71BB0DF7E197ADCA830B58FE4EA1` |
| `knowledge.csv` | `88092E17D16096CDEBE292FEE4389DEB460EDDA24AE8B4637212C69C71562F55` |
| `essential_skills.csv` | `68C4D31D8C55077238395DCA40CC4E6D32E6A608B5474BB8264B1F98AE415BD7` |
| `transferable_skills.csv` | `F17D0419E818A3BF2F55076510C76C4AEF01CC8F5AEE2670D2F44F6F44AC60F7` |
| `occupation_data.csv` | `A09EAE1D6609686E44E05B7290993A1C8B523D8CA224BC0EEDC194C855C3EE02` |
因此,`target/onet-31` 中的本轮 CSV 来源与官方 O*NET 31.0 发布包一致,不是本地改写版本。
### 2. 证据快照与官方 CSV 对照
`mydreamjob-server/src/test/resources/skill-cloud/onet-31-reference.csv`:
- 总行数:92 条数据行。
- `SOFTWARE`:47 条,47 个唯一键,无重复。
- `IMPORTANCE`:45 条唯一 `SOC + Element ID` 组合,无重复。
- 47/47 条 `SOFTWARE` 三元组均精确命中 `software_skills.csv`。
- 45/45 条 `IMPORTANCE` 均命中声明的官方 CSV,`Data Value` 精确一致。
- 未发现无依据行、伪造三元组或伪造数值。
- `SOFTWARE` 的 `data_value` 均为空,符合 `software_skills` 没有 Importance 值的结构。
结论:当前证据快照确实来自 O*NET 31.0 官方 CSV,数据与官方 CSV 完全一致。
### 3. V36 数据全量审计
独立解析 V36 后确认:
| 项目 | 结果 |
| --- | ---: |
| O*NET 技能 | 47 |
| DB 使用的 SOFTWARE 三元组集合与快照集合相等 | 是 |
| 职位技能关系 | 139 |
| 非空 Importance | 137 |
| 空 Importance | 2 |
| 非空关系正确命中快照键和值 | 137/137 |
| 非空关系当前 `importance_is_proxy=1` | 137/137 |
| 空关系的 SOC/Element ID 为 NULL、proxy=0 | 2/2 |
| 137 条关系使用的唯一 Importance 键 | 45 |
| 快照 IMPORTANCE 键集合与实际使用集合相等 | 是 |
结论:当前数据层没有发现伪造来源、错误数值或错误代理标记。
### 4. Manifest 分类覆盖
从 `source-manifest.md` 独立解析 10 个职位及声明分类,并与 V36 对比:
- 职位:10/10 一致。
- 分类:68/68 存在。
- 每个声明分类至少关联 1 条技能关系;本轮独立统计最小值为 2、最大值为 3。
- 未发现分类遗漏或职位—分类清单不一致。
### 5. ESCO 职位 URI
V36 与测试中的 10 个职位映射一致,其中 8 个唯一 ESCO occupation URI。对 8 个 URI 调用 ESCO 官方只读 API,全部返回 HTTP `200`:
- 前端开发工程师 URI
- 后端开发工程师 URI
- 测试工程师 URI
- 金融数据分析师 URI
- 风控建模工程师 URI
- 教育产品经理 URI
- 教学设计师 URI
- 教育数据分析师 URI
DevOps、量化开发复用软件开发者 URI 的项目代理关系已在 V36 注释中明确,和当前批准清单一致。
## 上一轮 Finding 关闭状态
| 第一轮/第二轮 Finding | 状态 | 证据 |
| --- | --- | --- |
| C-1:混合来源未经批准 | 关闭 | 契约、design、source manifest 均已明确 ESCO 主来源 + O*NET `software_skills` 补充;V35/V36 字段语义与修订契约一致。 |
| I-1:代理 SOC/ESCO URI 不可追溯 | 关闭 | 10 个职位均有 ESCO source version 和 URI;8 个唯一 URI 独立返回 200;代理 SOC、Element ID、proxy 均落表。 |
| I-2:manifest 分类遗漏 | 关闭 | 68/68 分类存在,每个分类至少 1 条关系,清单与 V36 一致。 |
| I-3a:Importance 逐条可追溯数据 | 关闭 | V35 增加来源字段;V36 的 137/137 非空关系均匹配官方快照,当前数据真实。 |
| I-3b:测试全量覆盖 47/47 软件来源 | **部分关闭** | 47 条都做集合成员验证,但只有 5 条做 `skillCode -> sourceCode` 精确映射断言;未断言的两个技能交换来源仍可通过。 |
| I-3c:测试全量覆盖 137/137 非空 Importance | **部分关闭** | 137 条都验证来源键和数值;但 `proxy` 只对 10 条精确断言,其余 127 条可在 1 和 0 之间翻转而不失败。 |
## Findings
### Important-1:47 条 O*NET 软件来源未做完整的逐技能精确映射断言
位置:
- `mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudSeedDataIntegrationTest.java:160-183`
- 精确映射单点断言:`185-189`
- 辅助断言:`474-478`
问题:
当前循环对每条 DB `source_code` 只检查其三元组是否存在于快照中,再要求 DB 行数与快照行数相等。它验证了“集合完整”,但没有验证每个技能代码必须绑定哪个三元组。
可证伪反例:
- 任意交换两个未被 `assertOnetSkillSource` 覆盖的 O*NET 技能来源,例如 `SPRING_BOOT` 与 `DJANGO`。
- DB 仍然有 47 个唯一且合法的三元组,集合仍与快照相等。
- 现有 5 条单点断言不涉及这两个技能,因此测试仍通过,但技能溯源已经被错误交换。
影响:
`skills.source_code` 是来源追踪字段。来源交换会静默破坏“技能到上游来源 ID”的准确性,当前回归测试无法阻止。
修复要求:
- 在快照中增加 `skill_code`(仅 SOFTWARE 行使用),或新增完整的 `skillCode -> SOC|Workplace Example|Element ID` 映射资源。
- 对 47/47 条 O*NET 技能逐条断言精确来源,而不是仅做集合成员检查。
### Important-2:Importance Proxy 标记未做全量语义断言
位置:
- 枚举检查:`SkillCloudSeedDataIntegrationTest.java:201-203`
- 循环内代理检查:`229-230`
- 仅 10 条精确断言:`271-294`
- 精确断言辅助方法:`481-494`
问题:
`assertTrue(proxy == 0 || proxy == 1)` 只证明值处于合法范围,不能证明它符合种子数据的来源语义。V36 当前明确要求 137 条非空 Importance 全部是跨来源代理,必须为 `1`;测试只对 10 条做 `proxy=1` 断言。
可证伪反例:
- 将任意一个未被 `assertImportance` 覆盖的非空关系从 `importance_is_proxy=1` 改为 `0`。
- 该行仍满足数据库 CHECK 和测试的 `0/1` 判断,SQL 计数也不检查非空代理必须为 1,因此测试仍通过。
- `importance`、SOC、Element ID 和 Data Value 均未发生变化,但来源语义已经被篡改。
影响:
代理标记是来源审计字段。当前测试不能保证 137 条全部保持已批准的 proxy 语义,无法满足“篡改 Proxy 标记必须失败”的要求。
修复要求:
- 明确断言所有非空 Importance 的 `importance_is_proxy=1`,以及所有空 Importance 的 `importance_is_proxy=0`;或为 137 条关系提供逐关系期望映射。
- 若业务允许直接 O*NET Importance 与代理 Importance 并存,需要按关系保存期望值并逐条断言。
### Minor-1:快照的官方来源属性依赖评审时外部核验,未被自动化锁定
`loadEvidence` 只验证快照头部、列数、允许的 `source_file` 名称和键唯一性,不会读取官方 CSV 或校验官方发布包哈希。
当前快照已由本次复审用官方 ZIP 哈希确认,因此不阻断;但未来若快照的 `source_file` 被改成同一允许集合内的另一个文件,只要测试资源本身同步修改,现有测试无法识别来源文件标签变化。建议在来源文档或测试资源旁记录官方 ZIP/CSV 哈希,作为后续审计基线。
## 未决问题
- T3 当前数据层已通过,不能因为数据正确就忽略 Important-1 和 Important-2 的回归缺口。
- 真实 MySQL 8.4 尚未执行;本轮仅按要求执行 H2 `MODE=MySQL`。该限制沿用前轮,不构成本次新的阻断项。
- V35/V36 当前满足修订后的混合来源契约;未发现需要再次 Rewind 的契约冲突。
## 阻断项
T3 仍为 **FAIL**。在补齐 47/47 逐技能精确来源映射和 137/137 Importance 代理语义断言前,不应进入 T4。当前测试 9/9 通过只能证明现有数据与当前快照一致,不能证明上述两类篡改必然失败。

158
changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T3-rereview.md

@ -0,0 +1,158 @@
# W1-T3 独立复审报告(聚焦修复)
## 状态
完成。仅读取实现、契约、来源数据与本地 O*NET CSV,并独立执行测试和来源审计;未修改任何实现文件,仅写入本报告。
## Verdict
**FAIL**
第一轮 Critical 已关闭,I-1、I-2 的当前数据问题已关闭;I-3 的“逐条可追溯数据”已关闭,但对应的回归测试仍未关闭:当前测试只精确断言 5/47 条 O*NET 技能来源和 10/137 条非空 Importance,且没有任何 CSV 驱动的来源完整性测试。多数不存在或伪造的 O*NET 行、错误 Importance 仍会通过测试。该项属于 Important,阻断 T4。
## 评审对象与依据
实现文件:
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\resources\db\migration\V35__skill_cloud_taxonomy.sql`
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\resources\db\migration\V36__skill_cloud_seed.sql`
- `D:\1\zhi\zhilai\mydreamjob-server\src\test\java\com\mydreamjob\skill\SkillTaxonomyMigrationTest.java`
- `D:\1\zhi\zhilai\mydreamjob-server\src\test\java\com\mydreamjob\skill\SkillCloudSeedDataIntegrationTest.java`
依据:
- 修订后的 `execution-contract.md`、`design.md`、`tasks.md`
- `changes/n03-skill-cloud/data/source-manifest.md`
- `docs/skill-taxonomy-sources.md`
- 第一轮报告 `changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T3.md`
- 修复报告日志 `mydreamjob-server/target/skill-taxonomy-fix-green.log`
- 本地 O*NET 31.0 CSV 与 `changes/n03-skill-cloud/data/esco-skill-lookup.json`
- ESCO 官方只读 API(用于职位 URI 真实性核验)
## 独立测试结果
环境:
- `JAVA_HOME=D:\java\jdk`
- Maven:`D:\maven\apache-maven-3.9.7\bin\mvn.cmd`
- 工作目录:`D:\1\zhi\zhilai\mydreamjob-server`
执行:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest' test
```
结果:
```text
SkillCloudSeedDataIntegrationTest: Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
SkillTaxonomyMigrationTest: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
Total: Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 40.195 s
```
## 第一轮 Finding 关闭状态
| 第一轮 Finding | 状态 | 独立证据 |
| --- | --- | --- |
| C-1:混合来源未授权,职位与关系来源被替换 | **关闭** | `execution-contract.md:7,20,56` 已明确授权“ESCO 主来源 + O*NET `software_skills` 补充”;`design.md:32-43` 与 `source-manifest.md:35-43` 同步;V36 中 10 个职位均使用 ESCO occupation URI,25 条 ESCO 技能和 47 条 O*NET 补充技能分源导入。 |
| I-1:金融/风控/教育产品经理代理映射不明确或 URI 不真实 | **关闭** | V36:6-28 明确代理 SOC;V35:93-96 将目标来源与 Importance 来源拆列;V36:274-413 的 137 条非空热度均设置 `importance_is_proxy=1`。独立调用 ESCO 官方 API,10 个职位行对应的 8 个唯一 URI 全部返回 200,标题分别为 web developer、software developer、software tester、financial analyst、risk manager、ICT product manager、instructional designer、data analyst。 |
| I-2:manifest 分类遗漏,测试只要求至少一个分类 | **当前数据关闭** | manifest 10 个职位的分类清单与 V36:59-127 的实际 68 个分类逐项一致,测试也已改为精确比较完整清单;独立解析确认 68/68 分类至少关联 1 条技能。剩余测试弱点见 Minor-1。 |
| I-3:Importance 缺少逐技能来源映射和有效验证 | **部分关闭,仍为 Importance 阻断** | V35:94-110 已增加 SOC、Element ID、代理标记和完整性约束;V36:274-413 的 137/137 条非空热度均带完整字段,并可精确核对 CSV。但测试只覆盖少量样本,不能阻止多数来源或热度被篡改;详见 Important-1。 |
## 来源审计方法与数量
### 1. SQL 结构审计
独立解析 V36 的 `INSERT` 行,结果:
| 对象 | 数量 |
| --- | ---: |
| 来源版本 | 2 |
| 行业 | 3 |
| 职位 | 10 |
| 技能分类 | 68 |
| 技能 | 72(ESCO 25,O*NET 47) |
| 技能别名 | 72 |
| 职位技能关系 | 139 |
| 非空 Importance | 137 |
| 空 Importance | 2 |
| 每个职位非空 Importance 最小值 | 12 |
V36 仅有 7 条 `INSERT` 语句,没有 `CREATE`、`ALTER`、`DROP`、`UPDATE`、`DELETE`。`com.mydreamjob.skill` 主代码包不存在,未提前实现 T4;V1-V34 没有改动。
### 2. ESCO 来源审计
- 25/25 条 `source_type='ESCO'` 技能的 URI 都能在 `esco-skill-lookup.json` 中找到。
- 10/10 个职位行的 `source_version_id=1`,职位来源均为 ESCO occupation URI。
- 10 个职位使用 8 个唯一 ESCO occupation URI;`BACKEND_DEVELOPER`、`DEVOPS_ENGINEER`、`QUANTITATIVE_DEVELOPER` 复用 software developer URI,V36:47 已明确标注为项目代理。
- 对 8 个唯一 URI 独立调用 `https://ec.europa.eu/esco/api/resource/occupation?uri=...&language=en`,全部 HTTP 200,返回的 URI 与请求一致。
### 3. O*NET `software_skills` 审计
- 使用本地 `mydreamjob-server/target/onet-31/software_skills.csv`(31,821 行)。
- 47/47 条 O*NET 技能的 `(O*NET-SOC Code, Workplace Example, Element ID)` 三元组精确命中 CSV,无缺失或伪造组合。
- 当前测试对这 47 条来源只有正则格式检查,另精确断言的来源样本为 5 条。
### 4. Importance 审计
- 使用本地 `work_activities.csv`、`knowledge.csv`、`essential_skills.csv`、`transferable_skills.csv`,共解析出 99,231 个 `(SOC, Element ID, Data Value)` 的 `IM/Importance` 键。
- 137/137 条非空 Importance 均可按 `(importance_source_code, importance_element_id, importance)` 精确命中至少一个 CSV。
- 137/137 条均满足 `importance_source_code` 和 `importance_element_id` 非空,且 `importance_is_proxy=1`。
- 2/2 条空 Importance 的 SOC、Element ID 均为 NULL,`importance_is_proxy=0`。
- 当前测试对 137 条非空热度中的 10 条做精确值断言,其余 127 条只检查字段存在,不检查数值是否真实。
## 阻断项
### Important-1:回归测试不能覆盖大多数 O*NET 来源与 Importance 篡改
**位置**
- `SkillCloudSeedDataIntegrationTest.java:132-139`:47 条 O*NET 来源只验证正则格式。
- `SkillCloudSeedDataIntegrationTest.java:141-145`:仅精确断言 5 条 O*NET `software_skills` 行。
- `SkillCloudSeedDataIntegrationTest.java:152-163`:只验证非空字段是否存在及来源版本名称是否为 O*NET。
- `SkillCloudSeedDataIntegrationTest.java:165-187`:仅精确断言 10 条 Importance 及 2 条 NULL。
**独立量化**
- O*NET 技能总数为 47,精确来源断言为 5,剩余 42 条不存在“CSV 行必须存在”的回归保护。
- 非空 Importance 总数为 137,精确值断言为 10,剩余 127 条即使被改成任意 0-5 的数值,现有测试仍会通过。
- 测试文件未读取 `software_skills.csv`、`work_activities.csv`、`knowledge.csv`、`essential_skills.csv` 或 `transferable_skills.csv`,因此无法证明“当前值来自 O*NET 31.0 CSV”。
**影响**
当前数据库中的 47/47 技能来源和 137/137 Importance 已由本次复审全量核对为真实;但数据正确性只存在于当前 SQL 快照,没有被自动化测试保护。未来修改若把未抽样的来源替换为格式合法但不存在的三件套,或把未抽样的 Importance 改成错误值,测试仍会通过,随后直接影响词云排序和匹配结果。
**修复方向**
- 增加覆盖全部 47 条 O*NET 技能来源的精确来源清单或 CSV 驱动断言。
- 增加覆盖全部 137 条非空 Importance 的精确映射断言;若 CSV 不进入正常测试运行环境,至少提交一份受版本控制的完整映射基线,并校验行数、键集合和值。
- 对 137/137 条映射逐项验证 `SOC + Element ID + Data Value`,而不是只验证非空和应用层来源版本。
## 未决问题
### Minor-1:每个分类至少关联一个技能未进入断言
`SkillCloudSeedDataIntegrationTest.java:89-113` 验证完整分类清单,但只对职位的技能总数和非空热度总数要求 `>= 10`,没有验证每个分类的关联技能数 `>= 1`。当前 68/68 分类均通过独立数据审计,因此不阻断;后续若新增或删改分类,建议补一条分组断言。
### Minor-2:Importance 越界值测试仍缺失
V35:102-104 定义了 0-5 的 CHECK,但 T3 测试覆盖的是 provenance 缺失、代理标记非法和成功带来源更新,没有直接断言 `importance < 0` 或 `> 5` 在 H2 `MODE=MySQL` 下失败。MySQL 8.4 会执行 CHECK,当前没有发现生产不可兼容语法,但真实 MySQL 尚未在本次环境中执行。
### Minor-3:执行计划中的契约哈希未同步修订版
当前 `execution-contract.md` 的 SHA-256 为 `98d869bc3b7e29b6fefffd973e561be50ba21ef4f59f4146ccd47cf8164957da`,与 `.spec-superflow.yaml` 一致;但 `.superpowers/sdd/execution-plan.yaml` 仍记录旧哈希 `9ae4be4a8561d7f303804c84d75061bbf2c8b3be2e7c79495689c19ba09d028e`。不影响 T3 数据正确性,建议在 W1 复评通过后同步执行计划。
## 未阻断的确认项
- V35 的新增列、provenance CHECK、代理标记 CHECK 在 H2 `MODE=MySQL` 下通过测试。
- `importance_source_code`、`importance_element_id` 的字段长度足以容纳当前 O*NET SOC 和 Element ID。
- V36 保持纯数据迁移,未提前引入 T4 Java 业务代码。
- 未修改 V1-V34。
## 阻断项汇总
T3 当前不通过。第一轮 C-1、I-1、I-2 的阻断已关闭,I-3 的当前数据已清除,但对应的自动化回归保护仍不足,必须补齐 Important-1 后才能交给 T4。

155
changes/n03-skill-cloud/.superpowers/sdd/reviews/W1-T3.md

@ -0,0 +1,155 @@
# W1-T3 独立评审报告
## 状态
完成。本次评审只读取实现、来源数据、契约和日志,并独立执行测试;未修改任何实现文件,仅写入本报告。
## Verdict
**FAIL**
存在 1 项 Critical 和 3 项 Important。当前 V36 虽然能在 H2 下通过既有测试,但没有落实已批准的“ESCO 负责职位—技能—分类主关系”方案,且部分金融职位使用与目标职位不符的 O*NET SOC 替代来源;同时 T1 清单中的多个技能分类没有导入。上述问题均阻断 T4。
## 评审对象与依据
评审对象:
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\resources\db\migration\V36__skill_cloud_seed.sql`
- `D:\1\zhi\zhilai\mydreamjob-server\src\test\java\com\mydreamjob\skill\SkillCloudSeedDataIntegrationTest.java`
对照依据:
- `changes/n03-skill-cloud/execution-contract.md`
- `changes/n03-skill-cloud/design.md`
- `changes/n03-skill-cloud/tasks.md`
- `changes/n03-skill-cloud/data/source-manifest.md`
- `docs/skill-taxonomy-sources.md`
- `V35__skill_cloud_taxonomy.sql`
- 本地 ESCO lookup、O*NET 31.0 CSV、实现者 RED/GREEN 日志
## 独立检查结果
### 1. 数量与基本约束
独立解析和 H2 测试结果一致:
| 项目 | 结果 |
| --- | ---: |
| 行业 | 3 |
| 职位 | 10 |
| 技能分类 | 50 |
| 技能 | 53 |
| 技能别名 | 53 |
| 职位技能关系 | 137 |
| 非空 Importance | 133 |
| 空 Importance | 4 |
每个职位均有 10 至 17 条职位技能关系,非空 Importance 数量均不少于 10。全部技能有中文标准名、非空 `source_code` 和至少一个别名;`normalized_alias` 无重复。当前 137 条关系的 `category_id` 均与同一 `occupation_id` 对应,未发现现有种子内的跨职位分类错配。
### 2. ESCO 与 O*NET 来源核验
- V36 的 25 个 `source_type='ESCO'` URI 均可从 `changes/n03-skill-cloud/data/esco-skill-lookup.json` 找到,英文标题与 lookup 一致。
- V36 的 28 个 O*NET 技能来源三元组 `O*NET-SOC|Workplace Example|Element ID` 均能在 `software_skills.csv` 精确命中;未发现伪造组合。
- V36 注释声明的 43 组(SOC、Importance、语义元素)映射值均能在本地 O*NET CSV 中命中。其中 `Scikit-learn` 的当前工作区值已按 CSV 精确大小写修复。
- lookup 中本身存在明显误匹配,例如 Angular→computer programming、Spring Boot→install deicing boot、Node.js→apply electrolytes to cathodes and anodes、React→react to emergency situations in a live performance environment、Redis→redistribute wagered money、Microsoft Power BI→Microsoft Access。当前 V36 没有使用这些错误 URI,因此它们不是本批次已导入数据的直接错误,但该 lookup 不能作为未经复核的可信自动映射源。
### 3. 独立测试
执行命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest' test
```
结果:
```text
SkillCloudSeedDataIntegrationTest: Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
SkillTaxonomyMigrationTest: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
Total: Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
该结果与实现者 GREEN 日志一致。
## Findings
### Critical
#### C-1:V36 用 O*NET/项目映射替代了已批准的 ESCO 职位—技能—分类主关系
证据:
- 执行契约第 55 行要求“使用 ESCO 作为职位—技能—分类主关系,O*NET Importance 作为 heat”。
- 设计 D1 第 32-42 行同样规定 ESCO 负责职位—技能—分类关系;来源清单第 34-39、51、60-63 行要求保留原上游来源 ID,并由项目维护跨源重要性映射。
- V35 第 34 行将 `skill_occupations.source_code` 定义为 `ESCO 职位来源 ID`。
- V36 第 30-39 行将 10 个职位的 `source_code` 全部写成 O*NET SOC,并将 `source_version_id` 指向 O*NET 来源版本。
- V36 第 203-340 行 137 条职位技能关系的 `source_version_id` 全部为 O*NET。
- 本地只有 ESCO 技能 lookup,没有 ESCO 职位—技能关系数据;V36 已导入的 25 个 ESCO 技能只提供了技能 URI,未提供任何 ESCO 职业关系。
影响:这不是字段命名的小问题,而是数据治理来源发生实质变化:职位节点被标成 O*NET SOC,职位技能关系改由 O*NET software_skills、项目分类和代理 Importance 人工组装。下游接口即使运行正确,也无法证明返回的是契约批准的 ESCO 主关系。该变化命中执行契约第 136 行的 Rewind 条件“数据来源发生实质变化”。
### Important
#### I-1:两个金融职位使用不能代表目标职位的替代 SOC,且替代映射未获批准或写入来源清单
证据:
- V36 第 34 行将“金融数据分析师”写成 `13-2031.00`。本地 `occupation_data.csv` 中该 SOC 的标题是 **Budget Analysts**;原计划目标 `13-2051.00` 是 **Financial and Investment Analysts**。
- V36 第 35 行将“风控建模工程师”写成 `13-2053.00`。本地 CSV 中该 SOC 的标题是 **Insurance Underwriters**;原计划目标 `13-2054.00` 是 **Financial Risk Specialists**。
- V36 第 16-17 行仅以 SQL 注释说明替换;`source-manifest.md` 第 22-23 行没有批准代理 SOC,也没有记录替换后的目标语义。
- `13-2051.00` 和 `13-2054.00` 在本地四份 Importance CSV 中确实没有行,但“原目标无数据”不等于可以用语义不同的职业覆盖其来源 ID。
影响:Budget Analysts 与 Insurance Underwriters 的 O*NET 活动分布不能代表金融数据分析师和风控建模工程师。尤其是 Insurance Underwriters 作为风控建模工程师的热度来源,会导致技能热度与目标职位脱节。必须在来源清单和契约中明确代理规则与语义合理性,或重新选择可代表目标职位的来源;不能直接把替代 SOC 写入目标职位的 `source_code`。
#### I-2:T3 清单中的多个技能分类未落表,测试只要求至少一个分类因而漏检
证据:
- `source-manifest.md` 第 18-27 行给出了每个职位的首批技能分类范围,第 30 行明确这些分类是“每个职位首批应覆盖的能力域”。
- V36 第 42-91 行为每个职位固定导入 5 个分类,共 50 个。
- 前端开发缺少清单中的“浏览器与调试”“测试”。
- DevOps 缺少“配置管理与 IaC”“可观测性”“安全”等独立能力域。
- 风控建模缺少“模型验证”等能力域。
- 教学设计师缺少“无障碍与规范”等能力域。
- 测试第 70-71 行只断言 `category_count >= 1`,因此即使只导入一个分类也会通过。
影响:已批准的 T3 分类覆盖范围没有完整交付,后续接口会缺少部分可筛选分类;当前测试无法阻止该类回归。
#### I-3:Importance 缺少逐技能来源映射和有效验证,现有数据大量依赖同值代理
证据:
- V36 第 5-15 行说明所有技术技能使用“Working with Computers”等职业活动代理值,而不是 O*NET software_skills 自身的 Importance。
- V36 第 203-340 行的 133 条非空关系实际只形成 39 个不同的 `(occupation, importance)` 组合;多个技能共享同一代理值,例如同一职业内多数技术技能全部为同一个值。
- V35 第 93-95 行只保存 `importance` 和 `source_version_id`,没有保存用于推导热度的 O*NET Element ID;V36 的注释不是机器可追溯的数据。
- 测试第 106-127 行只精确校验前端 JavaScript 的 `4.88` 和 Figma 的 `null`,其余 132 条重要性记录或错误来源元素不会被发现。
影响:词云热度会因大量同值技能而主要退化为 `nameZh ASC` 排序,无法证明每个技能的热度确实来自对应 O*NET 来源。若继续该口径,应新增可追溯的 Importance 映射记录并扩大测试,或者重新定义可接受的代理规则和排序语义。
### Minor
1. **“45 个 Importance 原值”的核验数量不实。** V36 第 5-15 行声明了 43 个职业/能力域映射,133 条非空关系只有 39 个不同 `(occupation, importance)` 组合;报告中“45 个 Importance 原值”没有可复现证据。这是核验报告计数问题,不代表已发现任意 CSV 伪造值。
2. **最终提交可能漏掉 Scikit-learn 大小写修复。** 当前工作区是 `Scikit-learn`,与 CSV 一致;但 `git status` 显示 V36 为 `AM`,即该修复尚未 staged。提交前必须确保修正包含在最终 T3 提交中。
3. **测试主要验证行数、非空值和两个单点值,不是完整来源契约测试。** 当前即使将除 JavaScript 外的 Importance 改错、将非空 ESCO/O*NET source code 改成任意非空字符串,或只保留每个职位一个分类,测试仍可能通过。分类跨职位错配也不会被测试发现,因为数据库没有覆盖 `(occupation_id, category_id)` 的复合外键。
## 已确认没有问题的部分
- V36 是纯数据迁移:只有 7 个 `INSERT`,未包含 `UPDATE/DELETE/DROP/ALTER/CREATE`。
- `git diff HEAD` 在本任务实现范围内只显示 V36 和 T3 测试;V1-V35 未修改,`src/main/java/com/mydreamjob/skill` 不存在,未提前实现 T4 业务代码。
- 当前 137 条关系的技能分类均与所属 occupation 对应;所有技能、别名、来源版本和关系均满足 V35 现有外键及唯一约束。
- 28 个 O*NET 技能来源组合和 25 个 ESCO URI 均可从本地来源文件核实,没有发现伪造 O*NET software row 或伪造 ESCO URI。
## 未决问题
- 必须决定是恢复 ESCO 职位—技能—分类主关系,还是正式修改 design、source manifest 和执行契约,承认以 O*NET SOC 与项目映射作为主关系。后者属于数据来源实质变化,应走 Rewind。
- 必须确认“代理 Importance”是否属于已批准方案;若属于,需记录每个技能或能力域的 O*NET Element ID、代理理由和质量规则,并重新定义同值排序的产品语义。
- 若继续使用 `13-2031.00` 和 `13-2053.00`,需要提供可审查的职位语义映射依据并更新来源清单;否则应更换代表 SOC。
- 本机未进行真实 MySQL 迁移验证,仅验证 H2 `MODE=MySQL`。该限制沿用 T2 的未决项。
## 阻断项
T3 不通过。C-1、I-1、I-2、I-3 均必须在开始 T4 前处理;其中 C-1 和 I-1 优先触发规划/契约 Rewind 或获得明确的来源变更批准。既有 7 项测试通过不能覆盖这些来源和分类语义问题。

130
changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T4-rereview.md

@ -0,0 +1,130 @@
# W2-T4 修复复审报告
## 状态
完成。本轮仅复审修复提交 `52751e5`,对照第一轮报告 `W2-T4.md`,未修改任何实现、测试、迁移或配置文件;仅新增本报告。
## Verdict
**PASS**
第一轮阻断项 `W2-T4-I1` 已关闭。两个投影查询均已保证技能分类的 `occupation_id` 与职位技能关系的 `occupation_id` 一致,新增测试真实构造了跨职位分类错配数据并验证查询排除该数据。规定回归集全部通过,未发现新的 Critical、Important 或阻断项。
## Finding 关闭状态
### W2-T4-I1:分类过滤查询未强制 category 与 occupation 属于同一职位
状态:**已关闭**。
关闭证据:
- 整职位查询在分类关联后增加了 `c.occupationId = os.occupationId`:
`D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\repository\OccupationSkillRepository.java:36-40`
- 职位加分类查询同样增加了该约束,并保留 `os.occupationId = :occupationId` 与 `os.categoryId = :categoryId`:
`D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\repository\OccupationSkillRepository.java:63-69`
- 两个查询均继续通过 `os.skillId = s.id` 和 `os.categoryId = c.id` 连接技能及分类,未改变投影字段或原过滤条件。
## 独立测试结果
环境:
- `JAVA_HOME=D:\java\jdk`
- Java:OpenJDK 17.0.16
- Maven:`D:\maven\apache-maven-3.9.7\bin\mvn.cmd`
- 工作目录:`D:\1\zhi\zhilai\mydreamjob-server`
命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest' test
```
结果:
```text
SkillCloudSeedDataIntegrationTest: Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
SkillTaxonomyMigrationTest: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
SkillTaxonomyRepositoryTest: Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
Total: Tests run: 16, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 40.769 s
```
## 必须检查项逐项结论
### 1. 两个投影查询均约束 category 所属 occupation,且保留过滤:通过
两个查询都在连接 `os.categoryId = c.id` 后增加:
```sql
AND c.occupationId = os.occupationId
```
整职位查询继续只按目标 `occupationId` 过滤;职位加分类查询继续同时按目标 `occupationId` 和 `categoryId` 过滤。因此:
- 不会再通过职位 A 的关系读取仅属于职位 B 的分类;
- 分类参数仍必须与关系中的分类 ID 完全相等;
- 职位参数语义未改变。
位置:
- `OccupationSkillRepository.java:36-43`
- `OccupationSkillRepository.java:63-72`
### 2. 新增测试真实插入跨职位错配关系,且可证伪修复:通过
新增测试位于 `D:\1\zhi\zhilai\mydreamjob-server\src\test\java\com\mydreamjob\skill\SkillTaxonomyRepositoryTest.java:123-152`。
测试通过 `JdbcTemplate` 向真实 `occupation_skills` 表插入:
- `occupation_id = 1`
- `category_id = 201`
- `skill_id = 1`
- `source_version_id = 2`
其中分类 `201` 在 V36 中明确属于职位 `2`,见 `V36__skill_cloud_seed.sql:67`;技能 `1` 存在,见 `V36__skill_cloud_seed.sql:129`。插入使用合法外键值和完整来源字段,因此构造的是数据库允许保存、但职位与分类不属于同一节点的逻辑错配关系。
该测试没有断言 SQL 文本,而是调用真实 Repository 方法并检查返回结果。修复前的查询缺少同职位约束,插入关系会满足 `os.skillId = s.id`、`os.categoryId = c.id` 和 `os.occupationId = 1`,因此:
- 整职位查询会包含技能 `1`,在第 `142` 行 `doesNotContain(1L)` 失败;
- `occupation=1, category=201` 查询会包含该关系,在第 `144-146` 行失败。
修复后该关系被 `c.occupationId = os.occupationId` 排除,测试通过,可证伪性成立。
测试方法带 `@Transactional`,见 `SkillTaxonomyRepositoryTest.java:123-124`,插入数据在测试结束后回滚,不污染其他测试或 V36 计数。
### 3. 三项断言均正确覆盖:通过
`SkillTaxonomyRepositoryTest.java:136-151` 分别验证:
- 整职位查询仍包含正常技能 `3`,且不包含错配技能 `1`;当前断言在 `139-142` 行。
- 错误分类组合 `occupation=1, category=201` 返回空列表;当前断言在 `144-146` 行。
- 正确组合 `occupation=2, category=201` 仍返回技能 `1`,证明修复没有误伤合法数据;当前断言在 `148-151` 行。
三项断言覆盖了第一轮要求的安全边界和正常路径。
### 4. 正常查询、空 Importance、来源字段和计数:通过
- 原整职位和分类过滤测试继续验证职位 `1` 返回 15 项、分类 `101` 返回精确技能集合,以及错误职位分类返回空:`SkillTaxonomyRepositoryTest.java:95-120`。
- O*NET 来源字段、`importance`、`importanceSourceCode`、`importanceElementId`、代理标记和排序字段的原断言仍通过:`SkillTaxonomyRepositoryTest.java:154-188`。
- Figma 的空 Importance 场景仍通过:`SkillTaxonomyRepositoryTest.java:176-188`。
- V36 计数仍为来源版本 2、行业 3、职位 10、分类 68、技能 72、别名 72、职位技能关系 139:`SkillTaxonomyRepositoryTest.java:72-81`。
- 指定回归集 16 项全部通过,未观察到由新增约束造成的正常查询、投影字段或种子数据回归。
### 5. 提交范围与 T5 边界:通过
`git diff --name-only 0c77824..52751e5` 仅列出:
- `mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/OccupationSkillRepository.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyRepositoryTest.java`
未修改 V35/V36、生产配置、安全配置或其他模块,也未实现任何 T5 排名、匹配或学习路径代码。`git diff --check 0c77824..52751e5` 通过。
## 剩余阻断项
无。
## 非阻断备注
V35 仍允许数据库层面写入 `occupation_id` 与 `category_id` 逻辑不一致的关系,因为当前外键是单列外键;本次修复保证 Repository 查询不会返回此类错配数据。是否需要增加数据库复合完整性约束属于独立的数据模型变更,不影响 T4 通过。

150
changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T4.md

@ -0,0 +1,150 @@
# W2-T4 独立评审报告
## 状态
完成。本轮只评审提交 `0c77824`、修订后的契约与规划产物、V35/V36,并独立执行指定测试;未修改实现、测试、迁移或配置文件,仅写入本报告。
## Verdict
**FAIL**
未发现 Critical。发现 1 项 Important:技能投影查询没有保证 `occupation_skills.category_id` 对应的分类属于同一个 `occupation_id`,在数据库出现逻辑错配关系时会返回跨职位数据。该问题会阻断 T4 通过,修复并复审后可进入 T5。
## 独立测试输出
环境:
- `JAVA_HOME=D:\java\jdk`
- Java:OpenJDK 17.0.16
- Maven:`D:\maven\apache-maven-3.9.7\bin\mvn.cmd`
- 工作目录:`D:\1\zhi\zhilai\mydreamjob-server`
命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest' test
```
结果:
```text
SkillCloudSeedDataIntegrationTest: Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
SkillTaxonomyMigrationTest: Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
SkillTaxonomyRepositoryTest: Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
Total: Tests run: 15, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 44.358 s
```
## 必须检查项逐项结论
### 1. 实体与 V35 映射:通过
7 个实体均映射到 V35 对应表,主键为 `Long` + `GenerationType.IDENTITY`。已逐表核对表名、列名、长度、可空性和类型:
- `SkillSourceVersion` 与 `skill_source_versions`:字段、长度和可空性一致。
- `SkillIndustry` 与 `skill_industries`:字段、长度和可空性一致。
- `SkillOccupation` 与 `skill_occupations`:字段、长度和可空性一致。
- `SkillCategory` 与 `skill_categories`:字段、长度和可空性一致。
- `Skill` 与 `skills`:字段、长度和可空性一致。
- `SkillAlias` 与 `skill_aliases`:字段、长度和可空性一致;该表无 `updated_at`,实体也未映射该列。
- `OccupationSkill` 与 `occupation_skills`:`DECIMAL(4,2)`、来源字段长度、`TINYINT(1)` 代理标记及可空性匹配;`importanceIsProxy` 使用 `@JdbcTypeCode(SqlTypes.TINYINT)`。
本次测试在 `ddl-auto=validate` 下执行通过,证明 V35 七张表实际参与了 Hibernate 校验。
### 2. Schema Filter 与生产配置:通过
`SkillTaxonomyRepositoryTest` 的 validate filter 明确包含全部七个 V35 表:
`D:\1\zhi\zhilai\mydreamjob-server\src\test\java\com\mydreamjob\skill\SkillTaxonomyRepositoryTest.java:182-231`
因此该 filter 隔离的是仓库历史中与本任务无关的表,不会跳过 V35 实体的列类型或可空性校验。测试通过 `@TestPropertySource` 覆盖测试数据库和 `ddl-auto`,提交未修改生产 `application.yml`、`SecurityConfig` 或其他配置。
### 3. 行业、职位、分类关联校验:通过
以下 Repository 方法正确表达了单节点关系约束:
- `SkillOccupationRepository.existsByIdAndIndustryId` / `findByIdAndIndustryId`
- `SkillCategoryRepository.existsByIdAndOccupationId` / `findByIdAndOccupationId`
测试覆盖了正确组合、跨行业职位、跨职位分类和不存在的 ID:
`D:\1\zhi\zhilai\mydreamjob-server\src\test\java\com\mydreamjob\skill\SkillTaxonomyRepositoryTest.java:78-88`
### 4. occupation + 可选 category 查询的同职位约束:不通过
两个投影查询都只连接 `os.category_id = c.id`,没有断言 `c.occupation_id` 与 `os.occupation_id` 相同:
`D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\repository\OccupationSkillRepository.java:36-40`
`D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\repository\OccupationSkillRepository.java:62-66`
V35 对 `occupation_skills.category_id` 只有单列外键,不阻止一条关系的 `occupation_id` 指向职位 A、`category_id` 指向职位 B 的分类:
`D:\1\zhi\zhilai\mydreamjob-server\src\main\resources\db\migration\V35__skill_cloud_taxonomy.sql:114-117`
这违反了契约要求“分类必须属于职位”:
`D:\1\zhi\zhilai\changes\n03-skill-cloud\execution-contract.md:59`
也违反了设计中的“category_id 必须属于 occupation_id”:
`D:\1\zhi\zhilai\changes\n03-skill-cloud\design.md:119`
现有测试 `findSkillProjectionsByOccupationIdAndCategoryId(2L, 101L)` 只证明当前种子数据中不存在 `职位 2 + 分类 101` 关系,不能发现上述缺列条件;即使插入一条 `occupation_id=2, category_id=101` 的逻辑错配关系,该查询仍会返回它:
`D:\1\zhi\zhilai\mydreamjob-server\src\test\java\com\mydreamjob\skill\SkillTaxonomyRepositoryTest.java:113-115`
修复方向明确:两个查询均增加分类所属职位约束,例如 `AND c.occupationId = os.occupationId`;同时增加可证伪测试,用跨职位分类关系验证查询不会返回数据。
### 5. 投影 14 个字段:通过
`SkillCloudSkillProjection` 包含 14 个字段,覆盖技能身份、名称、来源、分类、`importance`、`importanceSourceCode`、`importanceElementId`、`importanceIsProxy`、技能排序值和分类排序值。构造表达式与字段顺序一致,`importance` 使用 `BigDecimal`,代理标记使用 `Boolean`。
`D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\dto\SkillCloudSkillProjection.java:5-20`
### 6. 未提前实现 T5–T8:通过
提交只新增 7 个实体、1 个投影、7 个 Repository 和专项测试;未新增排名服务、匹配服务、学习路径服务、Controller、安全规则或 OpenAPI 变更。Repository 查询明确未实现最终排序,符合 T5 边界。
### 7. 测试数据覆盖与可证伪性:除跨职位查询外通过
测试使用了 V36 种子数据,覆盖计数、关联校验、整职位查询、可选分类过滤、O*NET 来源、空 Importance、代理标记、别名和按 ID 查询。查找均为精确断言,不是仅计数。
但第 4 项的跨职位分类错配缺少可证伪用例;即使 Repository 查询遗漏同职位条件,当前 15 项测试仍全部通过。
### 8. 提交范围与历史迁移:通过
提交只包含允许的 16 个文件。独立哈希核对确认 V35/V36 在 `0c77824` 中未发生变化,提交未修改其他模块、已有迁移、生产配置或安全配置。
## Findings
### Critical
无。
### Important
#### W2-T4-I1:分类过滤查询未强制 category 与 occupation 属于同一职位
证据:
- `OccupationSkillRepository` 两个查询均只有 `os.categoryId = c.id`,缺少 `c.occupationId = os.occupationId`:
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\repository\OccupationSkillRepository.java:36-40`
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\repository\OccupationSkillRepository.java:62-66`
- V35 外键不提供复合 `(occupation_id, category_id)` 完整性:
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\resources\db\migration\V35__skill_cloud_taxonomy.sql:114-117`
- 契约明确要求分类必须属于职位:
- `D:\1\zhi\zhilai\changes\n03-skill-cloud\execution-contract.md:59`
- `D:\1\zhi\zhilai\changes\n03-skill-cloud\design.md:119`
影响:一旦数据写入出现逻辑错配,整职位查询会返回其他职位的分类技能;带分类查询也可能在对应关系存在时返回跨职位数据,破坏词云筛选边界,并影响后续匹配和学习路径。当前种子数据恰好一致,因此测试未暴露问题。
### Minor
无额外阻断项。
## 未决问题
无。修复语义明确:查询必须同时绑定 category 所属 occupation;修复后应增加一条跨职位分类关系的失败用例并重新评审。

94
changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T5-rereview.md

@ -0,0 +1,94 @@
# W2-T5 聚焦修复独立复审报告
- **评审对象**:T5 I-1 聚焦修复工作区差异(基于 `e3eecfe`)
- **前次评审**:`changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T5.md`,结论 `FAIL`
- **复审范围**:仅检查 `SkillCloudItem.java`、`SkillCloudQueryService.java`、`SkillCloudQueryServiceTest.java` 及其对既有行为的影响
- **结论**:**PASS**
- **剩余阻断项**:无
## Finding 关闭状态
### [I-1] 响应项遗漏技能来源标识:已关闭
修复已同时补齐 DTO 字段和 Service 映射:
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\dto\SkillCloudItem.java:11-12`
- 新增 `sourceType`
- 新增 `sourceCode`
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\SkillCloudQueryService.java:102-117`
- `toItem(...)` 在 `nameEn` 后依次映射 `projection.sourceType()` 和 `projection.sourceCode()`(第 109-110 行)
- 热度来源仍独立映射为 `heatSourceCode`、`heatElementId`、`heatProxy`(第 114-116 行)
字段语义未混淆:技能自身来源是 `sourceType/sourceCode`,O*NET Importance 来源是 `heatSourceCode/heatElementId/heatProxy`。
### 双来源测试与可证伪性:已关闭
`D:\1\zhi\zhilai\mydreamjob-server\src\test\java\com\mydreamjob\skill\SkillCloudQueryServiceTest.java:198-236` 同时锁定两类来源:
- O*NET 技术来源:第 219-220 行精确断言
- `sourceType = "O*NET"`
- `sourceCode = "15-1254.00|React|2.E.7.h"`
- ESCO 技能来源:第 232-234 行精确断言
- `sourceType = "ESCO"`
- `sourceCode = "http://data.europa.eu/esco/skill/3cd569a2-4f88-4c1e-9995-8dce8c5e51a7"`
- 来源隔离:第 235 行额外断言 JavaScript 的 `heatSourceCode = "15-1254.00"`,与 ESCO `sourceCode` 明显不同。
因此:
- 若 `sourceType` 或 `sourceCode` 映射被遗漏并传为 `null`,上述精确断言会失败。
- 若二者被交换,O*NET 与 ESCO 的精确值断言会失败。
- 若技能来源与热度来源混用,React 的完整 O*NET 三元组断言及 JavaScript 的 URI/SOC 对照断言会失败。
## 原有行为回归
独立执行指定环境与命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest' test
```
结果:
```text
Tests run: 24, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
Surefire 分类计数:
- `SkillTaxonomyMigrationTest`:2
- `SkillCloudSeedDataIntegrationTest`:8
- `SkillTaxonomyRepositoryTest`:6
- `SkillCloudQueryServiceTest`:8
已核验原有行为:
- 全量查询、连续排名:`SkillCloudQueryServiceTest.java:62-81`
- 热度降序、同热度名称排序、null heat 末位:`SkillCloudQueryServiceTest.java:84-107`
- 同值排序的 `skillId` 稳定性:`SkillCloudQueryServiceTest.java:109-145`
- 分类过滤及缺省分类:`SkillCloudQueryServiceTest.java:166-179`
- 合法空分类返回空列表:`SkillCloudQueryServiceTest.java:181-196`
- 必填/正整数/未知节点/跨行业职位/跨职位分类校验:`SkillCloudQueryServiceTest.java:238-264`
- provenance 字段映射:`SkillCloudQueryServiceTest.java:198-236`
本次修复没有改变排序、过滤、校验或热度来源映射逻辑,仅扩展响应字段与测试断言。
## 变更范围
自 `e3eecfe` 起,T5 实现差异仅包含:
- `SkillCloudQueryService.java`
- `SkillCloudItem.java`
- `SkillCloudQueryServiceTest.java`
共 `20 insertions, 3 deletions`。未修改 V35/V36、Repository、安全配置或应用配置;未发现 T6/T7/T8 的 Controller、匹配度 Service、学习路径 Service 或权限配置实现。
## 非阻断备注
- **M-1 仍为 Minor**:设计文档中的 `heatSource` 嵌套形态与当前三个扁平热度来源字段尚未统一,按前次评审要求留待 T8 明确 OpenAPI 和接口文档。
- **M-2 仍为 Minor**:Service 层未单独断言无 heat 技能的 `sourceType/sourceCode`;Repository 层已在该场景断言 Figma 保留来源(`SkillTaxonomyRepositoryTest.java:183-188`),且 Service 映射无条件执行,不构成阻断。
## 最终裁决
`PASS`。I-1 已关闭,指定联合回归全部通过,未发现新的 Critical 或 Important 问题;T5 可进入后续任务门禁。

94
changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T5.md

@ -0,0 +1,94 @@
# W2-T5 独立评审报告
- **评审对象**:`e3eecfe feat: add skill cloud query service`
- **评审基线**:`52751e5`(T4 复审通过提交)
- **结论**:**FAIL**
- **评审范围**:仅检查提交 `e3eecfe` 的 5 个文件;未修改实现文件。
## 独立测试结果
环境:`JAVA_HOME=D:\java\jdk`,Maven `D:\maven\apache-maven-3.9.7\bin\mvn.cmd`。
执行:
```powershell
mvn -Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest test
```
结果:
```text
Tests run: 24, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
测试数量拆分为:迁移 2、种子数据 8、Repository 6、词云 Service 8。
## Important
### [I-1] 响应项遗漏技能自身的来源标识,违反 T5 的“来源字段”和可追溯要求
`SkillCloudSkillProjection` 已查询并携带技能来源字段:
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\dto\SkillCloudSkillProjection.java:10-11`
- `sourceType`
- `sourceCode`
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\repository\OccupationSkillRepository.java:25-26`
- 查询投影包含 `s.sourceType` 和 `s.sourceCode`
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\repository\OccupationSkillRepository.java:52-53`
- 分类查询同样包含这两个来源字段
但 Service 转换成响应 DTO 时丢弃了这两个字段:
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\SkillCloudQueryService.java:102-115`
- `toItem(...)` 没有传入 `projection.sourceType()` 或 `projection.sourceCode()`
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\dto\SkillCloudItem.java:5-17`
- Item 只有 `heatSourceCode`、`heatElementId`、`heatProxy`,没有技能来源类型或技能来源编码
这会使返回结果无法在接口层区分某个技能来自 ESCO 还是 O*NET `software_skills`,只剩通过数据库内部 ID 才能反查来源。T5 明确要求返回“来源字段”:
- `D:\1\zhi\zhilai\changes\n03-skill-cloud\tasks.md:21`
- `D:\1\zhi\zhilai\changes\n03-skill-cloud\execution-contract.md:41`
- `D:\1\zhi\zhilai\changes\n03-skill-cloud\specs\skill-cloud\spec.md:86-93`
当前测试也没有覆盖该缺口:`SkillCloudQueryServiceTest` 只断言了 React 的 `heatSourceCode`、`heatElementId` 和 `heatProxy`(`SkillCloudQueryServiceTest.java:198-223`),没有断言 Item 中应携带 `sourceType`/`sourceCode`。因此该遗漏是可复现但不可被现有测试发现的。
**修复要求**:在 `SkillCloudItem` 中补齐技能来源字段,在 `toItem` 中映射,并增加至少一个 ESCO 技能和一个 O*NET `software_skills` 技能的精确断言;同时确认无 heat 的技能仍保留其技能来源。
## Minor
### [M-1] `heatSource` 的响应形态与设计文档不一致,需在 T8 前统一
设计文档将词云项描述为包含 `heatSource`:
- `D:\1\zhi\zhilai\changes\n03-skill-cloud\design.md:131`
当前实现使用三个扁平字段表达同一语义:
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\dto\SkillCloudItem.java:13-16`
- `heatSourceCode`
- `heatElementId`
- `heatProxy`
这不是当前 T5 的运行时错误,但 T8 必须明确 OpenAPI 和接口文档采用扁平字段还是嵌套 `heatSource` 对象,避免契约漂移。
### [M-2] T5 服务测试对 null heat 的来源字段覆盖不足
`SkillCloudQueryServiceTest` 对 React 的非空 heat 来源做了精确断言,但没有对无 heat 技能(如当前种子数据中的 Figma)断言 `heatSourceCode`、`heatElementId` 和 `heatProxy` 的最终 DTO 值。T4 投影测试覆盖了该场景,因此暂不阻断;修复 I-1 时应顺带补齐服务层断言。
## 已通过检查
- 行业存在性、职位存在性、行业—职位归属校验均使用 `BusinessException(ErrorCode.BAD_REQUEST)`。
- 非法 `industryId`、`occupationId`、`categoryId`(null、0、负数)均返回 400 语义;未知节点和跨行业/跨职位节点同样返回 400。
- `categoryId` 缺省时调用整职位查询;传入时调用职位+分类查询,并保留 Repository 的职位—分类归属约束。
- 排序实现为 `heat DESC`、null heat 最后、`nameZh ASC`、`skillId ASC`;当前种子数据测试能捕获名称比较器和 skillId 比较器写错,也覆盖了 null heat 末位和连续 rank。
- 空分类返回成功、`total=0`、空 items。
- `SkillCloudResponse` 对列表执行 `List.copyOf`,响应列表不可变。
- 提交范围仅包含 T5 的 Service、3 个 DTO 和测试文件,没有实现 T6/T7/T8,也没有修改 V35/V36、安全配置或既有接口。
- 未发现事务、空指针、可变集合、随机性或明显性能问题;当前静态词库规模下三次校验查询加一次投影查询是合理的。
## 未决问题
1. 技能来源字段的最终 JSON 名称应为 `sourceType`/`sourceCode`,还是采用带 `skillSource` 前缀的名称;需在修复 I-1 和 T8 OpenAPI 时统一。
2. `heatSource` 是否需要改成嵌套对象;若保持扁平字段,应同步修订设计文档和后续接口文档。
3. I-1 修复后必须重新执行本报告使用的 24 项联合回归,并重新评审;T5 评审通过前不得进入 T6。

98
changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T6-rereview.md

@ -0,0 +1,98 @@
# W2-T6 测试隔离修复独立复审报告
## Verdict
**PASS**
T6 原 Review 中唯一 Important finding 已关闭。新增的三个隔离测试均通过服务返回结果进行断言,能够分别证伪 `nameZh` 分支、alias 匹配链路和 alias 归属隔离的相关回归;类级事务确保测试数据回滚。未发现新的阻断项,T6 可进入下一任务。
## Finding 关闭状态
### T6-I1:`nameZh` 与 alias 匹配遗漏无法证伪
**状态:已关闭。**
1. **nameZh-only 隔离成立**
- 测试:`matchesOnlyNameZhForV36JavaWhenOtherIdentityFieldsDoNotMatch`
- 位置:`D:\1\zhi\zhilai\mydreamjob-server\src\test\java\com\mydreamjob\skill\SkillMatchServiceTest.java:237-252`
- 第 239 行先将 V36 Java 的 `code` 改为 `JAVA_NAME_ZH_ISOLATED`。
- Java 的 V36 `nameZh` 为 `Java`,`nameEn` 为 `Java (computer programming)`,alias 为 `Java (computer programming)`;学生输入仅为 `Java`。
- 因此学生输入只能匹配 `nameZh`。删除生产代码的 `nameZh` 分支后,Java 必然进入 `missingSkills`,方法第 246-251 行会失败。
2. **alias-only 隔离成立**
- 测试:`matchesOnlyInsertedAliasForV36JavaWhenAliasIsTheOnlyIdentityMatch`
- 位置:`SkillMatchServiceTest.java:254-272`
- 第 256-259 行插入 Java 专属 alias `ONLY.ALIAS-JVM-XYZ`,与目标技能的 `nameZh`、`nameEn`、`skillCode` 均不相同。
- 学生输入只有该 alias,并直接断言服务返回的 `matchedSkills` 包含 Java、`missingSkills` 包含 Python。
- 删除 alias 查询或 alias 匹配分支后,Java 不会命中,测试必然失败。
3. **alias 归属隔离成立**
- 测试:`doesNotApplyAliasOwnedByAnotherTopSkillToV36Java`
- 位置:`SkillMatchServiceTest.java:274-292`
- 第 276-279 行将唯一 alias 关联到技能 2(Python),学生只输入该 alias。
- 断言要求技能 2 命中、技能 1 缺失;若 alias 被错误共享给其他 TOP 技能,技能 1 也会命中,分数的断言会失败。
### 事务与回滚
**状态:满足要求。**
- `SkillMatchServiceTest` 在类级使用 `@Transactional`:`SkillMatchServiceTest.java:51-52`。
- 未发现 `@Commit` 或 `@Rollback(false)`。
- 三个新增测试的 JDBC 插入/更新和 `service.match(...)` 调用均在测试事务内执行,测试结束后由 Spring Test 默认回滚,不会污染共享 H2 或 V36 数据。
### 服务结果断言
**状态:满足要求。**
- 三个新增测试均直接对 `SkillMatchResponse` 的 `matchScore()`、`matchedSkills()` 和 `missingSkills()` 做断言,不是仅验证 helper。
- alias-only 测试还会在第 265-271 行同时校验命中与缺失列表,能够发现“匹配分数正确但技能集合错误”的实现偏差。
## 独立测试结果
工作目录:`D:\1\zhi\zhilai\mydreamjob-server`
环境:
- `JAVA_HOME=D:\java\jdk`
- Maven:`D:\maven\apache-maven-3.9.7\bin\mvn.cmd`
执行命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest,SkillMatchServiceTest' test
```
结果:
```text
Tests run: 40, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 47.828 s
```
测试组成:
| 测试类 | 数量 | Failures | Errors |
|---|---:|---:|---:|
| `SkillTaxonomyMigrationTest` | 2 | 0 | 0 |
| `SkillCloudSeedDataIntegrationTest` | 8 | 0 | 0 |
| `SkillTaxonomyRepositoryTest` | 6 | 0 | 0 |
| `SkillCloudQueryServiceTest` | 8 | 0 | 0 |
| `SkillMatchServiceTest` | 16 | 0 | 0 |
## 回归与范围核验
- T6 原有 13 项行为测试全部保留,新增 3 项隔离测试,专项测试总数为 16。
- `nameEn`、`skillCode`、去重、TOP10、分母、非法 JSON、空数据、排序和跨职位隔离等既有测试均通过。
- `git diff --name-only -- mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillMatchService.java mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillNameNormalizer.java` 无输出,生产实现相对当前审阅基线未被本次测试修复修改。
- 源码搜索未发现 `LearningPath`、`SkillCloudController`、`SkillMatchController` 或相关接口实现;T7/T8 未实现。
- `git diff --check` 未报告代码差异错误;输出中的无关 `.trae` 文档警告不属于 T6 范围。
## 剩余阻断项
无。
## 结论
T6-I1 已关闭,T6 测试隔离修复满足可证伪性、事务隔离和服务结果断言要求。允许 T6 进入后续流程,但在流程门禁正式记录 `PASS` 前不得启动 T7。

71
changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T6.md

@ -0,0 +1,71 @@
# W2-T6 独立评审报告
## Verdict
**FAIL**
T6 生产实现的主流程与当前契约基本一致,但测试未满足本次评审明确要求的可证伪性,尤其无法发现 `nameZh` 匹配或别名匹配被删除的回归。必须先补齐隔离型测试并通过复审,才能进入 T7。
## 独立测试结果
环境:
- `JAVA_HOME=D:\java\jdk`
- Maven:`D:\maven\apache-maven-3.9.7\bin\mvn.cmd`
- 工作目录:`D:\1\zhi\zhilai\mydreamjob-server`
命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest,SkillMatchServiceTest' test
```
结果:
```text
Tests run: 37, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 58.642 s
```
测试组成:迁移 2、种子数据 8、Repository 6、词云查询 8、个人匹配 13。
## 阻断项
### Important:无法证伪 `nameZh` 与 alias 匹配遗漏
文件:
- `D:\1\zhi\zhilai\mydreamjob-server\src\test\java\com\mydreamjob\skill\SkillMatchServiceTest.java:212-235`
- `D:\1\zhi\zhilai\mydreamjob-server\src\main\java\com\mydreamjob\skill\SkillMatchService.java:137-142`
问题:
- 测试方法 `matchesNameZhNameEnSkillCodeAndAliasAndDeduplicatesStudentSkills` 试图一次覆盖 `nameZh`、`nameEn`、`skillCode` 和别名,但没有隔离各候选来源。
- 技能 1 的 `nameZh` 为“数据库”,同时注册别名 `JVM Language`。学生技能数组同时包含“数据库”和 `jvm-language`。
- 如果删除 `SkillMatchService.matches(...)` 中的 `nameZh` 判断,技能 1 仍可通过别名命中,测试继续通过。
- 如果删除 alias 查询、alias 分支或整个别名匹配链路,技能 1 仍可通过 `nameZh` 命中,测试同样继续通过。
- 因此现有测试不能证明 REQ-SM-3 和 TO-5 中“标准名称 + 别名”匹配能力存在;这是本次评审明确要求验证的回归保护,不能仅依赖实现代码的静态正确性。
要求的修复方向:
- 增加独立技能/独立场景,确保某个标准技能只能通过 `nameZh` 命中,另一个只能通过 alias 命中。
- 保留现有 `nameEn` 和 `skillCode` 隔离断言。
- 建议再断言 alias 必须归属于目标 `skillId`,避免别名被错误应用于其他标准技能时测试仍通过。
## 已核验且未发现阻断的行为
- 匹配集合直接对 `SkillCloudQueryService.query(...)` 的结果取 `subList(0, min(10, size))`,服务内未重新排序。
- 无 profile、空白 JSON、空数组和语法损坏 JSON 均按无技能处理;损坏 JSON 测试验证不抛 500 且日志不含原始 JSON。
- 归一化覆盖 NFKC、trim、`Locale.ROOT` 小写、Unicode 连字符、空白折叠、句点移除,并保留 `+`、`#`。
- `nameEn` 和 `skillCode` 在现有测试中有可区分断言;归一化后的学生技能使用 `Set` 去重。
- 分数使用 `Math.round(matched * 100.0 / actualTopSize)`,`actualTopSize=0` 返回 0,`1/3=33`,少于十条和恰好十条均有断言。
- 命中/缺失列表按 TOP 顺序构建,并无额外排序;无匹配场景的并集顺序与词云 TOP10 对齐。
- 响应 DTO 只包含技能投影,不包含原始 `skills_json`、手机号、邮箱或简历内容。
- T6 为只读事务,无持久化;未实现 T7/T8。
## 未决问题
- 无设计或接口语义待裁决。
- 待修复 T6 测试可证伪性后,必须重新执行同一回归命令并进行独立复审;复审 `PASS` 前不得启动 T7。

80
changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T7-rereview.md

@ -0,0 +1,80 @@
# W2-T7 顺序测试修复复审报告
## Verdict
**PASS**
- 复审对象:`mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java`
- 对照报告:`changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T7.md`
- 生产服务基线:`fca1160 feat: add skill learning path service`
- 结论:T7-I1 已关闭;当前顺序测试可证伪,原有 T7 行为测试未回归,未发现 T8 实现。
## Finding 关闭状态
### T7-I1:测试无法阻止学习路径按名称重排
**已关闭。**
1. Mockito 场景的输入名称和 ID 均不等于自然升序:
- `SkillLearningPathServiceTest.java:135-139`
- 名称输入为 `Skill Z -> Skill A -> Skill M`,自然升序为 `Skill A -> Skill M -> Skill Z`。
- ID 输入为 `30 -> 10 -> 20`,自然升序为 `10 -> 20 -> 30`。
- `SkillLearningPathServiceTest.java:155-156` 显式断言输入名称、ID 都不等于自然排序结果。
- `SkillLearningPathServiceTest.java:157-165` 读取服务返回对象,并严格断言返回 ID 为 `30, 10, 20`、名称为 `Skill Z, Skill A, Skill M`。
- 因此,生产实现若按名称或 ID 重排,现有测试必然失败。
2. 断言读取的是服务返回结果,而不是只验证 Mockito 输入:
- `SkillLearningPathServiceTest.java:157-165` 使用 `response.items()` 的 `skillId` 和 `skillName` 做精确顺序断言。
- `SkillLearningPathServiceTest.java:160-162` 同时断言优先级严格为 `1, 2, 3`。
- 满足“返回顺序、priority 和技能 ID”均被验证的要求。
3. 新增 V36 真实数据场景覆盖不同热度分组和名称顺序:
- `SkillLearningPathServiceTest.java:89-121`
- 使用教育行业 `3`、教学设计职位 `9` 调用真实 `SkillMatchService` 和 `SkillLearningPathService`。
- `SkillLearningPathServiceTest.java:102-110` 断言缺失技能共 10 项、存在多个不同热度值,且名称和 ID 顺序均不等于自然升序。
- `SkillLearningPathServiceTest.java:112-120` 将服务返回的 `skillId`、`skillName` 与 `matchResponse.missingSkills()` 的顺序做严格比较,并断言优先级为 `1..10`。
- 因此,真实数据场景不是仅验证输入或数量,而是验证了学习路径严格复用匹配结果的缺失技能顺序。
4. 原有 T7 行为测试未回归:
- 四类模板测试:`SkillLearningPathServiceTest.java:176-265`,包括编程语言、框架、工具、通用能力、未知/空模板及分类不存在回退。
- 空路径测试:`SkillLearningPathServiceTest.java:268-280`,断言 `total=0`、空列表且不访问分类仓储。
- 重复调用测试:`SkillLearningPathServiceTest.java:282-309`,断言顺序、优先级和文本一致。
- 本次修复仅修改测试文件;`SkillLearningPathService.java` 相对 `fca1160` 的生产差异为空。
5. 未发现 T8 实现:
- 当前 HEAD 仍为 `fca1160`。
- 源码和测试目录未发现 `SkillCloudController`、`SkillLearningPathController`、`/skills` 词云或学习路径接口及对应安全配置变更。
- 未发现超出 T7 范围的生产实现。
## 独立测试结果
环境:JDK 17.0.16、Maven 3.9.7。
执行命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest,SkillMatchServiceTest,SkillLearningPathServiceTest' test
```
结果:
```text
Tests run: 50, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
分类统计:
```text
SkillTaxonomyMigrationTest: 2 passed
SkillCloudSeedDataIntegrationTest: 8 passed
SkillTaxonomyRepositoryTest: 6 passed
SkillCloudQueryServiceTest: 8 passed
SkillMatchServiceTest: 16 passed
SkillLearningPathServiceTest: 10 passed
```
## 剩余阻断项
无。T7-I1 已关闭,允许进入 T8 评审前置流程。

72
changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-T7.md

@ -0,0 +1,72 @@
# W2-T7 独立评审报告
## Verdict
**FAIL**
- 提交:`fca1160 feat: add skill learning path service`
- 评审对象:`SkillLearningPathService`、`SkillLearningPathItem`、`SkillLearningPathResponse`、`SkillLearningPathServiceTest`
- 阻断项:1 项 Important
- 结论:生产实现整体符合 T7 行为契约,但核心顺序行为缺少可证伪的回归测试,不能放行 T8。
## 独立测试
执行环境:JDK 17.0.16、Maven 3.9.7。
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest,SkillMatchServiceTest,SkillLearningPathServiceTest' test
```
结果:
```text
Tests run: 49, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 1:00 min
```
## Important
### T7-I1:测试无法阻止学习路径按名称重排
- 文件:`mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java:98-117`
- 相关实现:`mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java:35-38`
现有单测的 `missing` 顺序为:
- `First` / `skillId=31` / `heat=1.00`
- `Second` / `skillId=32` / `heat=10.00`
- `Third` / `skillId=33` / `heat=5.00`
期望输出保持该输入顺序。但输入的名称和 `skillId` 本身均为升序,因此把实现改成:
```java
missingSkills.stream()
.sorted(Comparator.comparing(SkillCloudItem::nameZh))
.map(...)
```
后,单测仍会通过。真实数据集成测试 `SkillLearningPathServiceTest.java:62-76` 使用职位 1,其当前 TOP10 全部为相同热度,名称顺序也已符合词云次级排序,因此同样无法发现该重排缺陷。
T7 的要求是严格复用 `SkillMatchService` 返回的 `missingSkills` 顺序,不自行排序;该回归会破坏 REQ-LP-2。测试需要使用不会被名称排序或 `skillId` 排序偶然保持一致的缺失技能顺序,最好增加多热度分组的真实数据场景。
## 已通过检查
- 学习路径只遍历 `matchResponse.missingSkills()`,未消费已命中技能;优先级从 1 连续生成。
- 编程语言、框架、工具、通用四类模板均已实现;未知、空模板编码及分类不存在时回退通用模板。
- 建议包含技能名,精确断言中未出现 URL、课程链接、讲师、价格、时长、完成度或 AI 内容。
- 无缺口时返回 `total=0` 和空列表,且不访问分类仓储。
- 重复调用返回相同顺序、优先级和建议。
- `SkillLearningPathItem` 仅包含技能 ID、技能名、分类 ID、分类名、优先级和建议,不含隐私字段。
- 服务使用只读事务;未发现持久化或外部服务调用。
- 提交只新增 4 个指定文件,未包含 T8 及后续实现。
## Minor
- `SkillLearningPathServiceTest.java:195-221` 覆盖了 null 模板编码、未知模板和分类不存在,但未直接覆盖 `skill.categoryId()==null`;实现已在 `SkillLearningPathService.java:57-58` 回退 GENERAL。
- 外部内容主要通过精确模板断言和禁用词断言约束;生产类没有外部客户端依赖,因此本项不构成阻断。
## 未决问题
无。修复测试后需重新执行指定回归并复审。

175
changes/n03-skill-cloud/.superpowers/sdd/reviews/W2-wave.md

@ -0,0 +1,175 @@
# N03 SDD W2 整批独立评审报告
## 评审对象
- 范围:`b960036..f296df4`
- Wave:W2
- 批次:B2 / T4–T7
- 提交:
- `0c77824 feat: add skill taxonomy repositories`
- `52751e5 fix: constrain skill projections by occupation category`
- `e3eecfe feat: add skill cloud query service`
- `1666162 fix: expose skill source metadata in cloud items`
- `6eb30e7 feat: add personal skill match service`
- `fca1160 feat: add skill learning path service`
- `f296df4 test: harden learning path ordering regression`
- 评审依据:修订后的 `execution-contract.md`、`design.md`、`tasks.md`、三份 spec、V35/V36,以及 `W2-T4*`、`W2-T5*`、`W2-T6*`、`W2-T7*` 报告。
- 环境:`JAVA_HOME=D:\java\jdk`,Maven `D:\maven\apache-maven-3.9.7\bin\mvn.cmd`,工作目录 `D:\1\zhi\zhilai\mydreamjob-server`。
## Verdict
**PASS**
W2/T4–T7 的领域与查询实现完整覆盖 B2 范围;词云、个人匹配、学习路径确实共享同一排名管道;未发现 Critical 或 Important 阻断项。W3/T8 可以开始,但必须先处理报告中的 Minor 项,并在 B3 补齐 HTTP、权限、响应序列化、隐私和真实 MySQL 验证。
## 测试结果
独立执行:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest,SkillMatchServiceTest,SkillLearningPathServiceTest' test
```
结果:
```text
Tests run: 50, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
Surefire 分类:
| 测试类 | 通过数 |
| --- | ---: |
| `SkillTaxonomyMigrationTest` | 2 |
| `SkillCloudSeedDataIntegrationTest` | 8 |
| `SkillTaxonomyRepositoryTest` | 6 |
| `SkillCloudQueryServiceTest` | 8 |
| `SkillMatchServiceTest` | 16 |
| `SkillLearningPathServiceTest` | 10 |
补充静态检查:
- `git diff --check b960036..f296df4` 通过。
- `git log --oneline b960036..f296df4` 只包含上述 7 个 W2/T4–T7 提交。
- 范围内实现文件集中在 `mydreamjob-server/src/main/java/com/mydreamjob/skill/**` 与对应测试;未发现 `SkillCloudController`、`SecurityConfig` 或 OpenAPI 变更。
## 实现与需求覆盖矩阵
| 需求 | W2 结论 | 主要实现与证据 |
| --- | --- | --- |
| REQ-SC-1 | 不属于 W2,待 B3 | 未实现 Controller/SecurityConfig;由 T8 负责。W2 只提供可被 Controller 调用的服务。 |
| REQ-SC-2 | 通过(服务层) | `SkillCloudQueryService.query` 校验必填、正整数、行业—职位关系、分类—职位关系,非法组合抛 `BusinessException(BAD_REQUEST)`,见 `SkillCloudQueryService.java:57-79`、`150-158`;测试见 `SkillCloudQueryServiceTest.java:238-264`。 |
| REQ-SC-3 | 通过 | `heat DESC, nameZh ASC, skillId ASC`,`null` heat 末位,见 `SkillCloudQueryService.java:28-40`、`86-99`、`120-144`;测试覆盖空 heat、同 heat、同名称 skillId,见 `SkillCloudQueryServiceTest.java:84-145`。 |
| REQ-SC-4 | 通过(B2) | 词云返回完整列表;匹配直接取 `min(10, actualSize)`,未另建排序,见 `SkillMatchService.java:53-55`;测试见 `SkillMatchServiceTest.java:85-118`。 |
| REQ-SC-5 | B2 通过,B3 待补 | B2 已返回 rank、技能身份、分类、heat、技能来源及热度来源字段,见 `SkillCloudItem.java:5-19`、`SkillCloudQueryService.java:102-117`;`ApiResponse` 包装属于 T8。 |
| REQ-SM-1 | 通过 | 匹配服务复用 `SkillCloudQueryService.query` 的结果前 `min(10, actualSize)` 项,见 `SkillMatchService.java:53-55`;测试见 `SkillMatchServiceTest.java:85-137`。 |
| REQ-SM-2 | B2 通过,B3 待补 | 服务只通过 `studentUserId` 查询 `student_profiles.skills_json`,缺失、空、损坏 JSON 均按空技能处理,见 `SkillMatchService.java:81-116`;JWT 身份提取必须由 T8 Controller 完成。 |
| REQ-SM-3 | 通过 | NFKC、trim、Locale.ROOT、Unicode 连字符、连续空白、句点、`+`/`#` 规则见 `SkillNameNormalizer.java:12-27`;nameZh/nameEn/skillCode/alias 和别名归属有隔离测试,见 `SkillMatchServiceTest.java:237-292`。 |
| REQ-SM-4 | 通过 | `round(matched * 100.0 / actualTopSize)`,空集合返回 0,重复学生技能通过 `Set` 去重,见 `SkillMatchService.java:54-77`、`100-110`;测试覆盖 1/3=33、全命中、空集合。 |
| REQ-SM-5 | 通过 | 命中/缺失列表按 TOP 顺序分别构造,并集等于实际 TOP10,见 `SkillMatchService.java:59-78`;测试见 `SkillMatchServiceTest.java:294-370`。 |
| REQ-LP-1 | 通过 | 学习路径完全由 `SkillMatchResponse.missingSkills()` 驱动,不重新加入已命中或 TOP10 外技能,见 `SkillLearningPathService.java:29-40`;测试见 `SkillLearningPathServiceTest.java:131-174`。 |
| REQ-LP-2 | 通过 | 直接沿用 missingSkills 顺序,优先级从 1 连续递增,见 `SkillLearningPathService.java:35-38`;真实 V36 多热度分组及名称/ID 乱序测试见 `SkillLearningPathServiceTest.java:88-121`。 |
| REQ-LP-3 | 通过 | 四类确定性模板及未知模板回退见 `SkillLearningPathService.java:56-93`;模板精确断言见 `SkillLearningPathServiceTest.java:176-265`。 |
| REQ-LP-4 | 通过(B2) | 路径项包含技能 ID、名称、分类、优先级和建议,建议文本不含链接、课程、价格、时长、完成度或 AI 内容,见 `SkillLearningPathItem.java:3-10`、`SkillLearningPathServiceTest.java:358-362`。 |
## 关键检查结论
### 1. 三服务共享同一排名管道:通过
`SkillCloudQueryService` 是唯一排序实现:
- 过滤:`SkillCloudQueryService.java:57-84`
- 排序:`SkillCloudQueryService.java:28-40`、`86-88`
- 排名:`SkillCloudQueryService.java:89-91`
- 来源映射:`SkillCloudQueryService.java:102-117`
`SkillMatchService` 直接调用词云服务并截取前 `min(10, actualSize)` 项,没有复制排序逻辑,见 `SkillMatchService.java:53-55`。`SkillLearningPathService` 直接消费匹配服务的 `missingSkills`,没有重新排序,见 `SkillLearningPathService.java:33-38`。因此 TOP10、匹配缺口和学习路径的顺序语义一致。
### 2. 跨任务接口一致性:通过,存在命名待收口项
DTO 字段和顺序语义互相衔接:
- `SkillCloudItem` 提供词云和匹配共用的完整技能项,见 `SkillCloudItem.java:5-19`。
- `SkillMatchResponse` 返回 `matchScore`、`matchedCount`、`totalCount`、`matchedSkills`、`missingSkills`,见 `SkillMatchResponse.java:5-15`。
- `SkillLearningPathResponse` 返回 `total`、`items`;`SkillLearningPathItem` 返回 `priority`、技能、分类和建议,见 `SkillLearningPathResponse.java:5-11`、`SkillLearningPathItem.java:3-10`。
Minor M1:设计文档 `design.md:131` 使用概念字段名 `heatSource`,而实现采用扁平字段 `heatSourceCode`、`heatElementId`、`heatProxy`,见 `SkillCloudItem.java:15-18`。这不是 B2 行为错误,但 T8 必须在 OpenAPI、接口文档和前端契约中明确最终 JSON 命名,避免文档与运行时漂移。
### 3. 领域边界、事务、空值和异常:通过
- 三个业务服务均为 `@Transactional(readOnly = true)`,没有写操作,见三个服务类的类级注解。
- 分类—职位约束同时存在于服务校验和 Repository 投影查询中;跨职位分类错配测试见 `SkillTaxonomyRepositoryTest.java:123-152`。
- 空 heat 保留并末位排序:`SkillCloudQueryService.java:120-131`。
- 空结果:词云返回 `total=0, items=[]`;匹配返回 `matchScore=0`、空命中/缺失;学习路径返回空列表,见对应测试。
- 损坏 JSON 被捕获并仅记录异常类型和 profile ID,不输出原始 JSON,见 `SkillMatchService.java:91-114`。
- 学习路径未知/空/缺失模板回退通用模板,见 `SkillLearningPathService.java:56-65`、`84-93`。
### 4. 确定性与性能:通过,Minor N+1
- 排序使用 `BigDecimal.compareTo`、`nameZh`、`skillId` 全序比较,重复请求结果确定。
- 每个缺失技能最多一次分类模板查询,构建学习路径时最多 10 次查询,见 `SkillLearningPathService.java:61-64`。
Minor M2:这是有界的 N+1。当前任务是代表性小词库、最多 10 个路径项,不构成 W2 阻断;如果后续词库或 TOP 上限扩大,应在 T8/T10 或后续优化中改为一次批量读取模板。
### 5. 测试可证伪性:通过,有少量 Minor 缺口
已覆盖用户要求的关键反例:
- 跨职位分类错配:`SkillTaxonomyRepositoryTest.java:123-152`、`SkillMatchServiceTest.java:347-370`。
- 排序错误:`SkillCloudQueryServiceTest.java:84-145` 覆盖热度、同名/同热度的 skillId 稳定排序。
- 名称、别名和别名归属遗漏:`SkillMatchServiceTest.java:237-292`。
- 分母错误:`SkillMatchServiceTest.java:120-137`。
- 优先级重排:`SkillLearningPathServiceTest.java:88-174`。
- 模板遗漏:`SkillLearningPathServiceTest.java:176-265`。
- 空路径:`SkillLearningPathServiceTest.java:268-280`。
Minor M3:B2 测试未直接覆盖 `SkillLearningPathService.resolveTemplate` 在 `skill.categoryId()==null` 时的通用模板分支;也未通过完整序列化验证所有响应对象不含原始 `skills_json`。生产 DTO 当前不含隐私字段,且 `SkillLearningPathServiceTest.java:358-362` 已约束建议文本;T8/T9 的 MockMvc/端到端测试必须补齐响应级隐私断言。
### 6. W2 未越过 T8 边界:通过
`git diff --name-only b960036..f296df4` 未出现:
- `SkillCloudController` 或任何 `/skills/**` Controller;
- `SecurityConfig` 变更;
- OpenAPI 文件;
- `/api/v1/skills/*` 运行时端点。
因此 W2 没有实现 T8 的接口、权限或 OpenAPI 工作,边界符合 `tasks.md:24-25` 的后续安排。
## 严重级别与发现
### Critical
无。
### Important
无。
### Minor
- **M1:设计中的 `heatSource` 与实际扁平来源字段未最终对齐。**
- 位置:`changes/n03-skill-cloud/design.md:131`;`mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudItem.java:15-18`。
- 处理方向:T8 明确选择扁平 `heatSourceCode`/`heatElementId`/`heatProxy` 或嵌套 `heatSource`,并同步 OpenAPI、接口文档和前端示例。
- **M2:学习路径模板查询是有界 N+1。**
- 位置:`mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java:61-64`。
- 处理方向:当前最多 10 次查询,不阻断;若词库或 TOP 上限扩大,应批量读取 `(categoryId, occupationId)` 模板映射。
- **M3:B2 测试未直接覆盖空 `categoryId` 模板回退和完整响应序列化隐私断言。**
- 位置:`mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java:240-265`、`358-362`。
- 处理方向:T8 Controller 测试必须验证三个接口的响应不含 `skills_json`、手机号、邮箱和简历内容;可同时补 `categoryId=null` 的 Service 单测。
## 未决问题
1. `SkillCloudItem` 热度来源字段的最终 JSON 形态尚未在设计中收口,需在 T8 同时对齐实现、OpenAPI 和前端联调文档。
2. B2 只验证 H2 `MODE=MySQL` 与 Service 层异常类型;真实 MySQL 迁移、HTTP 401/403/400、JWT 到 `studentUserId` 的绑定和 `ApiResponse` 包装仍属于 B3。
3. B2 没有执行 `mvn clean verify` 或静态 OpenAPI 校验;这些属于 T9/T10 的退出条件,当前不作为 W2 阻断。
## 是否放行 W3
**放行。**
W2 的领域行为、排名管道、个人匹配、学习路径和可证伪测试已达到 B2 完成定义。允许进入 W3/T8;W3 开始前必须在实现 Controller/SecurityConfig/OpenAPI 时处理 M1,并在 T8–T10 验证上述 HTTP、权限、隐私和真实数据库边界。

105
changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T8-rereview.md

@ -0,0 +1,105 @@
# W3-T8 独立复审报告
## 复审结论
- Verdict:**PASS**
- 复审范围:
- `mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java`
- 对照前次报告 `changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T8.md`
- 复审方式:静态核对实现、测试断言、DTO、JWT/角色链路与测试清理;独立运行指定 8 个测试类。未修改任何实现或测试文件。
## 原 Important finding 关闭状态
### T8-I1:CLOSED
前次问题为三个 OpenAPI 200 示例的 `total`、计数与数组长度矛盾,且测试未读取示例内容。当前修复已关闭该问题:
- `SkillCloudController.java:56`:词云示例改为 `total=1` 且 `items` 为 1 项。
- `SkillCloudController.java:103`:匹配示例改为 `matchScore=50`、`matchedCount=1`、`totalCount=2`,命中 1 项、缺失 1 项。
- `SkillCloudController.java:150`:学习路径示例改为 `total=1` 且 `items` 为 1 项。
- `SkillCloudControllerTest.java:267-307`:测试读取 `/v3/api-docs` 中的 `responses.200.content.application/json.examples.success.value`,使用 Jackson 解析字符串示例或对象示例。
- `SkillCloudControllerTest.java:278-280`:词云 data 字段集合精确匹配,`total == items.size()`,技能项字段集合与 `SkillCloudItem` 一致。
- `SkillCloudControllerTest.java:282-292`:匹配字段集合精确匹配,`matchedSkills.size == matchedCount`、`missingSkills.size == totalCount - matchedCount`,并按计数校验 `matchScore`。
- `SkillCloudControllerTest.java:294-299`:学习路径字段集合精确匹配,`total == items.size()`,路径项字段集合与 `SkillLearningPathItem` 一致。
- 对照 DTO:
- `SkillCloudResponse.java`:`industry`、`occupation`、`category`、`total`、`items`。
- `SkillCloudItem.java`:`rank`、`skillId`、`skillCode`、`nameZh`、`nameEn`、`sourceType`、`sourceCode`、`categoryId`、`categoryName`、`heat`、`heatSourceCode`、`heatElementId`、`heatProxy`。
- `SkillMatchResponse.java`:`matchScore`、`matchedCount`、`totalCount`、`matchedSkills`、`missingSkills`。
- `SkillLearningPathResponse.java`:`total`、`items`。
- `SkillLearningPathItem.java`:`priority`、`skillId`、`skillName`、`categoryId`、`categoryName`、`suggestion`。
可证伪性:单独篡改 `matchScore` 会在 `SkillCloudControllerTest.java:289-290` 失败;篡改 `total` 会在 `:279`、`:288` 或 `:297` 失败;缩短或扩长数组会因元素数量不一致失败;直接删除示例或改坏 JSON 会在 `:302-306` 失败。
### T8-I2:CLOSED
前次问题为角色测试通过 Mock `ProfileService` 按 `userId` 决定结果,未证明真实数据库角色集合。当前修复已关闭该问题:
- `SkillCloudAuthorizationIntegrationTest.java:42-46`:使用 `@SpringBootTest`、MockMvc、真实 `integration-test` profile 和独立 H2 数据库。
- `:55-74`:测试注入真实 `UserRepository`、真实 `JwtTokenProvider`;只 Mock 三个技能服务及 Redis,不 Mock `ProfileService`。
- `:95-108`:真实保存 `STUDENT`、`COMPANY`、`ADMIN`、`STUDENT+COMPANY`、`COMPANY+ADMIN` 五种角色集合,并在三个端点上分别断言 200、403、403、200、403。
- `SkillCloudController.java:185-198`:身份只取认证主体 `userId`,随后调用真实 `profileService.assertStudentRole(userId)`。
- `ProfileService.java:50-57`:真实读取 `UserRepository` 并按 `User.roles` 中是否包含 `STUDENT` 判断。
- `JwtAuthenticationFilter.java:34-40`:JWT 仅用于解析 subject 并建立认证主体;测试 token 即使统一携带 `roles=STUDENT` claim,生产链路仍按数据库角色拒绝 COMPANY/ADMIN,因此断言真实可证。
可证伪性:若实现改为信任 JWT 的 `roles` claim,COMPANY/ADMIN 场景会因测试 token 的 `STUDENT` claim 返回 200 而失败;若改回 Mock 按 userId 放行,数据库角色集合测试在本类中无法满足,且真实 `ProfileService` 未替换。
## 额外身份查询参数
- `SkillCloudAuthorizationIntegrationTest.java:112-130`:对三个端点同时传入公司用户的 `userId` 和 `studentId`,但使用学生 JWT;全部返回 200,并验证 `SkillMatchService` 与 `SkillLearningPathService` 收到 JWT 学生 ID,`SkillCloudQueryService` 只收到筛选参数。
- `SkillCloudController.java:73-83`、`:120-130`、`:167-177`:三个接口均未声明 `userId` 或 `studentId` 请求参数,身份只来自 `resolveAuthenticatedStudent()`。
- 结论:额外查询参数不能覆盖 JWT 主体。
## 端点、参数与安全回归
- `SkillCloudController.java:73`、`:120`、`:167`:三个 GET 路由保持为 `/skills/word-cloud`、`/skills/match`、`/skills/learning-path`。
- 三个方法均要求正整数 `industryId`、`occupationId`,可选正整数 `categoryId`;`@Validated` 与 `@Positive` 生效。
- `SecurityConfig.java:77-78`:`GET /skills/**` 要求 authenticated,匿名请求无法进入 Controller。
- `SkillCloudControllerTest.java:156-165`:三个端点匿名请求均断言 401。
- `SkillCloudControllerTest.java:168-206`:覆盖必填参数缺失、非正整数和可选参数非法返回 400。
- `SkillCloudControllerTest.java:110-154`:三个 STUDENT 成功路径均返回统一 `ApiResponse` 和对应 Service DTO 载荷。
- 未发现 T8 修改导致三个端点、参数校验或 SecurityConfig 行为回归。
## 测试清理与范围
- `SkillCloudControllerTest.java:50-51` 与 `SkillCloudAuthorizationIntegrationTest.java:45-46` 使用不同的 H2 数据库名,避免类别间共享数据。
- `SkillCloudControllerTest.java:105-108`、`SkillCloudAuthorizationIntegrationTest.java:89-92` 均在每个测试后执行 `userRepository.deleteAll()`。
- 指定 8 个测试类完成后全部通过,未观察到外键残留或后续测试污染。
- 当前工作区中未出现 `docs/skill-cloud-api.md`,未修改 `docs/openapi-rc.json`、`docs/frontend-integration.md`,未实现 T9/T10。
## 独立测试结果
环境:JDK 17.0.16,Maven 3.9.7。
执行命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest,SkillMatchServiceTest,SkillLearningPathServiceTest,SkillCloudControllerTest,SkillCloudAuthorizationIntegrationTest' test
```
结果:
```text
Tests run: 66, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 53.008 s
```
分类结果:
- `SkillTaxonomyMigrationTest`:2
- `SkillCloudSeedDataIntegrationTest`:8
- `SkillTaxonomyRepositoryTest`:6
- `SkillCloudQueryServiceTest`:8
- `SkillMatchServiceTest`:16
- `SkillLearningPathServiceTest`:10
- `SkillCloudControllerTest`:14
- `SkillCloudAuthorizationIntegrationTest`:2
## 剩余阻断项
无 Critical、Important 阻断项。T8-I1、T8-I2 均已关闭,T8 可进入后续流程。
非阻断备注:200 响应目前通过示例和测试断言锁定字段及计数,未显式绑定具体响应 schema;本轮要求已满足,不阻断 T8。

87
changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T8.md

@ -0,0 +1,87 @@
# W3-T8 独立评审报告
## 评审结论
- Verdict:**FAIL**
- 评审对象:
- `mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java`
- `mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java`
- `mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java`
- 依据:修订后的 `execution-contract.md`、`design.md`、`tasks.md`,三份 spec,以及现有 JWT/Security/Controller 约定。
- 评审方式:静态核对实现与契约,独立运行指定回归,检查 Git 工作区范围;未修改实现文件。
## 独立测试结果
执行命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest,SkillMatchServiceTest,SkillLearningPathServiceTest,SkillCloudControllerTest' test
```
结果:
```text
Tests run: 69, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
其中 `SkillCloudControllerTest` 为 19 项通过。`git diff --check HEAD` 对 T8 三个文件无空白错误。`docs/openapi-rc.json`、`docs/frontend-integration.md` 无改动,`docs/skill-cloud-api.md` 不存在;未发现 T9/T10 实现。
## 阻断项
### T8-I1(Important):OpenAPI 成功示例与 DTO/运行时契约矛盾
文件与行号:
- `SkillCloudController.java:56`:词云示例中 `data.total=15`,但 `items` 只有 1 项。
- `SkillCloudController.java:103`:匹配示例中 `matchedCount=8`、`totalCount=10`,但同时返回 `matchedSkills=[]`、`missingSkills=[]`。
- `SkillCloudController.java:150`:学习路径示例中 `data.total=2`,但 `items` 只有 1 项。
- `SkillCloudControllerTest.java:247-269`:OpenAPI 测试只检查路径存在、`bearerAuth`、状态码和参数必填性,没有校验示例内容或 200 响应 schema,因此上述矛盾不会使测试失败。
- `changes/n03-skill-cloud/execution-contract.md:120`:明确要求“OpenAPI 注解、示例和实际响应必须一致”。
影响:客户端联调、接口文档和运行时契约可能按错误示例理解 TOP10、命中/缺失列表与总数关系。应修正三个示例,使计数、数组长度和 DTO 字段一致,并在 T8 的 OpenAPI 测试中锁定关键示例字段;同时建议为 200 响应声明对应 `SkillCloudResponse`、`SkillMatchResponse`、`SkillLearningPathResponse` schema,而非只有示例。
### T8-I2(Important):角色测试通过 Mock 按 userId 放行,未证明真实 STUDENT 校验和多角色集合
文件与行号:
- `SkillCloudControllerTest.java:93-97`:`ProfileService` 被 `@MockBean` 替换,并按 `100L/200L/300L` 直接设置成功或抛 `FORBIDDEN`。
- `SkillCloudControllerTest.java:161-171`:测试传入 `COMPANY`/`ADMIN` 角色声明,但实际 403 取决于 mock 对 `200L` 的硬编码行为,而非数据库角色集合。
- `SkillCloudControllerTest.java:296-301`:JWT 的 `roles` claim 只作为测试辅助值传入。
- `SkillCloudController.java:185-198`:Controller 确实从 `SecurityContext` 取身份并调用 `profileService.assertStudentRole(userId)`。
- `ProfileService.java:50-57`:生产实现从数据库读取用户,并使用 `anyMatch(STUDENT)`,因此单角色和包含 STUDENT 的多角色在代码层面是正确的。
- 现有 `ProfileControllerTest.java:167-185` 覆盖了数据库单角色 COMPANY/ADMIN 的 403,但没有覆盖 STUDENT+COMPANY 或 STUDENT+ADMIN 多角色集合,也没有覆盖本组 `/skills/**` 端点的真实角色链路。
影响:T8 的 Controller 测试无法证明 `COMPANY/ADMIN` 是被真实服务端角色检查拒绝,也无法证明多角色用户中只要包含 STUDENT 就能通过;它可能在 `ProfileService.assertStudentRole` 被替换或角色逻辑回归时仍通过。应增加使用真实 `ProfileService`、真实数据库用户的集成场景,至少覆盖:
- COMPANY、ADMIN:预期 403。
- STUDENT+COMPANY:预期允许。
- 无 STUDENT 的多角色集合:预期 403。
## 非阻断项
### T8-M1:身份注入测试只覆盖 `/skills/match`
文件与行号:`SkillCloudControllerTest.java:214-225`。
当前实现三个端点都调用同一个 `resolveAuthenticatedStudent()`,代码层面正确;但测试只验证 `match` 端点会忽略 `userId`/`studentId`,没有对词云和学习路径端点做同样断言。建议参数化覆盖三个路径,并 `verify` 服务收到的是 JWT 中的 `100L`。
### T8-M2:隐私断言只匹配 `skills_json`
文件与行号:`SkillCloudControllerTest.java:227-244`。
测试没有覆盖驼峰形式 `skillsJson` 或其他可能的新增个人技能字段。当前返回 DTO 为受控记录类型,因此非阻断;建议后续端到端测试使用真实服务响应,并对 DTO schema/序列化字段做更严格断言。
## 检查结论
- 三个路径和参数语义:**通过**。`industryId`、`occupationId` 必填,`categoryId` 可选;实测缺参/0/负数返回 400。
- 身份来源:**通过**。Controller 只从 `SecurityContext` 取主体,不绑定 `userId`/`studentId`。
- 权限行为:**实现通过,测试证明不完整**。匿名 401、COMPANY/ADMIN 403 的 MockMvc 场景通过;但角色拒绝依赖 mock,多角色未覆盖。
- `ApiResponse` 与成功 DTO:**通过**。成功响应统一包装,字段与 Service DTO 一致。
- OpenAPI:**不通过**。注解具备 Tag、Operation、SecurityRequirement、参数说明和 200/400/401/403,但成功示例与 DTO/运行时数据矛盾,且 200 响应缺少明确 schema。
- 改动范围:**通过**。未修改 `docs/openapi-rc.json`,未实现 T9/T10。
## 未决问题
- 当前安全层的 401/403 JSON 由既有 `SecurityConfig` 直接写出,不是 `ApiResponse` 实体序列化结果;是否在本变更中统一该历史约定,需由项目维护者决定。本次未将其列为 T8 阻断项,因为它与仓库既有安全配置约定一致。

177
changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T9.md

@ -0,0 +1,177 @@
# W3-T9 独立评审报告
## 评审结论
- Verdict:**PASS**
- T9 是否允许提交:**允许**
- 是否允许继续 T10:**允许继续实现与文档阶段,但不得在本次全量回归未全绿前判定 T10/B3 验证通过**
- 评审范围:
- `mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java`
- `target/skill-cloud-t9-red.log`
- `target/skill-cloud-t9-green.log`
- `target/skill-cloud-t2-t9-regression.log`
- `target/skill-cloud-t9-full-regression.log`
- `target/skill-cloud-t9-unrelated-failures-isolated.log`
- 修订后的 contract、design、tasks、三份 spec,以及 T8 Controller
- 评审方式:静态核对测试与生产链路,使用 JDK 17 / Maven 3.9.7 独立执行指定 T2–T9 测试组、四个失败测试类及完整 `mvn test`。未修改实现、测试或其他源码文件。
## T9 独立结论
### 真实链路
T9 使用 `@SpringBootTest`、`@AutoConfigureMockMvc`、`@ActiveProfiles("integration-test")` 和独立 H2 数据库,实际经过 Controller、Security、ProfileService、Skill 查询/匹配/学习路径 Service、Repository、Flyway 与 V36 种子数据。仅 `RedisTemplate` 被 Mock,核心业务链路没有被替换。
可证伪证据:
- `SkillCloudEndToEndIntegrationTest.java:48-55`:真实 Spring 上下文、MockMvc、H2 和生产数据库 URL/Profile。
- `:71-90`:注入真实 MockMvc、JdbcTemplate、JwtTokenProvider、Repository;唯一 `@MockBean` 为 RedisTemplate。
- `:100-134`:真实 STUDENT JWT 和真实 StudentProfile 调用三个端点,断言后端职位返回 16 项真实 V36 技能、rank 连续、heat 降序。
- `:269-304`:匹配 union、命中/缺失顺序、学习路径 priority/顺序、建议非空均有断言。
- 无 Flyway 或种子数据时,依赖的固定节点、16 项技能和 V36 来源字段无法成立;测试已独立通过,证明不是纯 Mock 测试。
### 词云 TOP10 与匹配一致性
T9 从真实词云结果直接取 `allSkills.subList(0, min(10, allSkills.size()))` 作为 TOP10,再验证:
- `totalCount` 等于实际 TOP10 大小。
- `matchedCount` 等于 matchedSkills 大小。
- matchedSkills 与 missingSkills 的并集严格等于词云 TOP10。
- matchedSkills 保持词云顺序,missingSkills 保持词云顺序。
- `matchScore` 等于 `round(matchedCount * 100 / expectedTop10.size())`。
- 3 项技能场景验证 `1/3 = 33`,并验证空热度末位。
该设计可证伪“匹配服务另起排序”“TOP10 与实际列表不一致”“missingSkills 丢项或重排”等缺陷。
### 学习路径
T9 将学习路径逐项与 `match.missingSkills` 对比:
- 总数为缺失技能数。
- 每项 `priority` 从 1 连续递增。
- `skillId`、`skillName` 与缺失技能逐项一致。
- `suggestion` 非空。
- 已命中的技能不会出现在学习路径中。
### 确定性、空场景、身份与隐私
- `:123-133`:三个端点重复调用后 `data` 完全相等,并断言 8 张相关表的行数不变。
- `:179-225`:事务内插入空分类,验证词云、匹配、学习路径分别返回空列表和 0 值。
- `:227-240`:学生 JWT 额外传入 COMPANY 的 `userId`、`studentId`,三个端点结果均不被覆盖。
- `:242-267`:成功响应不包含 `skills_json`、手机号、邮箱、简历 URL、简历原始文件名和 JWT;错误响应不包含异常类、Spring 内部路径、SQL 或堆栈。
### T9 findings
- **Critical**:无。
- **Important**:无。
- **Minor T9-M1**:无写入断言以 8 张表行数前后不变为依据,可发现 INSERT/DELETE,但不能发现仅 UPDATE 同一行而不改变行数的情况。当前三个 Service 均为只读事务且测试通过,因此不阻断;后续可增加事务/数据库快照或只读约束断言。
- **Minor T9-M2**:隐私测试未单独断言 `realName`、`bio` 的哨兵值,也未显式检查 `skillsJson` 驼峰名;已检查字段覆盖敏感号码、邮箱、简历标识、原始 `skills_json` 和 Token,风险为受控 DTO 下的低风险,不阻断。
- **Minor T9-M3**:T9 的匹配一致性验证以结果中的 matchedIds 反推期望,因此主要证明“匹配结果与 TOP10 一致”,不独立证明具体学生技能命中哪一项标准技能;别名和匹配判定本身已由 T6 测试覆盖,不阻断。
### 测试隔离与假绿风险
- T9 类级 `@Transactional`,创建的 Student、StudentProfile 和测试用空分类在方法结束后回滚。
- T9 使用独立 H2 URL,不与其他测试类共享数据库名。
- 空分类使用固定测试 ID 并依赖事务回滚,没有观察到跨方法或跨类污染。
- `@BeforeEach` 仅重置 Redis Mock,不依赖测试执行顺序。
- 未发现共享 H2、事务传播或顺序依赖导致的假绿。
## 独立测试结果
### T2–T9 指定测试组
执行命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest,SkillMatchServiceTest,SkillLearningPathServiceTest,SkillCloudControllerTest,SkillCloudAuthorizationIntegrationTest,SkillCloudEndToEndIntegrationTest' test
```
独立结果:
```text
Tests run: 71, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 01:08 min
```
分类统计:
- SkillTaxonomyMigrationTest:2
- SkillCloudSeedDataIntegrationTest:8
- SkillTaxonomyRepositoryTest:6
- SkillCloudQueryServiceTest:8
- SkillMatchServiceTest:16
- SkillLearningPathServiceTest:10
- SkillCloudControllerTest:14
- SkillCloudAuthorizationIntegrationTest:2
- SkillCloudEndToEndIntegrationTest:5
合计:71。
### 全量测试
执行命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' test
```
独立结果:
```text
Tests run: 1393, Failures: 2, Errors: 9, Skipped: 0
BUILD FAILURE
Total time: 01:57 min
```
失败集合与实现者日志一致。
## 全量失败归因
### T9 阻断
无。全量失败集合中没有任何 Skill Cloud 测试或依赖 T9 测试数据的用例。
### 项目既有失败
#### 1. CareerProgressServiceTest.fullAggregation_returnsCorrectSummaries
- 位置:`CareerProgressServiceTest.java:213`。
- 现象:期望 pendingOfferResponseCount=1,实际=0。
- 根因:测试构造的 Offer 固定过期时间 `2026-09-05T23:59:59`;当前日期为 `2026-09-15`,生产 `CareerProgressService.buildTodo()` 使用 `expireAt.isAfter(now)` 过滤,因此该 Offer 已过期。属于时间依赖的既有测试问题。
- 与 N03 关系:该测试和生产类自 `bf75fc0` 后未在 `d00a630..HEAD` 中修改,且在不运行任何 Skill Cloud 测试时独立复现。
#### 2. JobReviewDimensionControllerTest.listReviews_shouldReturnDimensionScores
- 位置:`JobReviewDimensionControllerTest.java:160-162`。
- 现象:HTTP 200 期望,实际 500;`JobReviewController.list` 收到 null page。
- 根因:Controller 已调用 7 参数 `listReviews(jobId, ratingType, createdFrom, createdTo, unreplied, page, size)`,测试仍 stub 旧的 3 参数 `listReviews(JOB_ID, 0, 20)`,Mockito 未命中导致 null。
- 与 N03 关系:相关 Controller、Service 和测试均未在 `d00a630..HEAD` 中修改,且独立复现。
#### 3. JobReviewDimensionServiceTest.listReviews_shouldReturnDimensionScoresWithoutN1
- 位置:`JobReviewDimensionServiceTest.java:206-210`。
- 现象:`reviewRepository.findPublicReviews(...)` 返回 null,触发 `JobReviewService.java:169` NPE。
- 根因:测试仍 stub 旧方法 `findByJobIdAndStatusAndModerationStatusInOrderByCreatedAtDesc(...)`,与当前实现调用的 `findPublicReviews(...)` 不一致。
- 与 N03 关系:相关 Repository、Service 和测试均未受 N03 变更修改,且独立复现。
### 环境阻塞
#### 4. LocalResumeStorageServiceTest
- 现象:8 项测试在 JUnit extension context 清理阶段报 `AccessDeniedException`,路径为 Windows 临时目录下的 `junit-*`。
- 根因:`WindowsPath.toRealPath()` 在测试 `@TempDir` 清理时访问被拒,测试业务逻辑本身未报断言失败。
- 与 N03 关系:属于 Windows 临时目录权限/沙箱环境问题;相关类和测试自初始提交后未受 N03 修改,且独立复现。
## 门禁决定
- **T9**:PASS。T9 测试具备真实持久化链路、TOP10 一致性、学习路径顺序、确定性、空场景、身份和隐私断言,并已通过 T2–T9 指定测试组。
- **提交 T9**:允许仅提交 `SkillCloudEndToEndIntegrationTest.java` 及本评审报告;不得把当前工作区其他无关改动混入该提交。
- **继续 T10**:允许进入 T10 的实现与文档工作,但必须先明确处理全量 `mvn test` 的 11 个非 T9 失败。可以将其登记为项目既有/环境阻断并单独修复,或在项目层面正式接受并记录豁免;在此之前不得宣称 TO-11、B3 或发布级验证通过,也不得执行最终收尾。
- **剩余阻断**:仅存在项目级全量回归阻断,不存在 T9 阻断。
## 证据限制
- 本次验证使用 H2 `MODE=MySQL` 和 Flyway,未执行真实 MySQL 黑盒迁移;MySQL 验证仍应在 T10/发布级验证门禁中完成。
- 本次评审未修改任何实现或测试源码。

168
changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave-final-review.md

@ -0,0 +1,168 @@
# N03 SDD W3 最终独立复审报告
## 复审对象
- 变更:`n03-skill-cloud`
- 基线:`df7238b`
- 复审焦点:`W3-N-I1` 修复及其对 `W3-I1`、`W3-I2` 的回归影响。
- 工作区对象:`docs/openapi-rc.json`、`docs/verify_openapi.py`,并核对 `SkillCloudController` 和权限测试中的既有修复。
- 环境:JDK 17(`D:\java\jdk`)、Maven 3.9.7(`D:\maven\apache-maven-3.9.7\bin\mvn.cmd`)。
- 前轮报告:`changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave-rereview.md`。
## Verdict
**PASS**
`W3-N-I1` 已关闭。两个既有 `cancel` operationId 已恢复,静态 OpenAPI 不再受无关历史契约变更影响;Skill Cloud 三个 operationId 的冲突检查仍有效。`W3-I1`、`W3-I2` 保持关闭。W3 代码可以进入收尾和提交阶段。
该 PASS 仅针对 W3/N-I1 代码与契约修复,不代表完整项目回归通过。完整 `mvn test` 仍有 2 failures + 9 errors,变更最终关闭或 TO-11 前仍需修复或正式豁免。
## N-I1 关闭证据
### 既有 cancel 契约已恢复
当前 `docs/openapi-rc.json`:
- `POST /interviews/{interviewId}/cancel`:`operationId = cancel`
- `POST /profile/me/deletion/cancel`:`operationId = cancel`
对三个 Git 状态进行了全量 operationId 对比:
```text
df7238b -> HEAD
新增:
/skills/word-cloud getWordCloud
/skills/match getMatch
/skills/learning-path getLearningPath
HEAD -> 当前工作区
仅:
/skills/match getMatch -> getSkillMatch
df7238b -> 当前工作区
仅三个新增 Skill Cloud operationId,其中 /skills/match 为 getSkillMatch
```
因此:
- 两个历史 `cancel` 值已恢复,没有再次被改名为 `cancelInterview` 或 `cancelAccountDeletion`。
- 除三个 Skill Cloud operationId 外,没有其他 operationId 在基线 `df7238b` 到当前工作区之间发生变化。
- `docs/verify_openapi.py` 已移除全文档唯一性要求,只对三个 Skill Cloud operationId 逐个执行全文档“恰好出现于对应路径”的断言。
- 历史重复值 `cancel` 不再阻断校验,但 Skill Cloud 的新 operationId 仍不能与其他 operation 冲突。
结论:**N-I1 已关闭。**
## W3-I1 保持关闭
当前契约与运行时测试保持一致:
- `/skills/word-cloud` -> `getWordCloud`
- `/skills/match` -> `getSkillMatch`
- `/skills/learning-path` -> `getLearningPath`
`SkillCloudControllerTest` 在运行时 `/v3/api-docs` 上校验三个 operationId 的精确值及其唯一归属;`docs/verify_openapi.py` 对静态 OpenAPI 执行同等约束。前轮的 `getMatch` 重复冲突没有回退。
结论:**W3-I1 保持关闭。**
## W3-I2 保持关闭
401 契约未回退:
- 运行时匿名请求和非法 JWT 均返回 `code=401`、`message=not authenticated`、`timestamp=""`。
- 响应不包含 `requestId` 或 `data`。
- `SkillCloudAuthorizationIntegrationTest` 同时覆盖匿名和 `Bearer invalid-token`。
- 静态 OpenAPI 中三个 Skill Cloud 接口的 401 均引用 `UnauthorizedResponse`,示例与运行时一致。
- `docs/verify_openapi.py` 继续校验 401 schema、精确响应字段和示例内容。
结论:**W3-I2 保持关闭。**
## 独立验证结果
### 静态 OpenAPI 校验
命令:
```powershell
python docs\verify_openapi.py
```
结果:
```text
paths: 129
All checks passed!
```
通过。
### T2-T9 指定测试组
命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest,SkillMatchServiceTest,SkillLearningPathServiceTest,SkillCloudControllerTest,SkillCloudAuthorizationIntegrationTest,SkillCloudEndToEndIntegrationTest' test
```
结果:
```text
Tests run: 73, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 54.628 s
```
通过。
### 打包
命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' -DskipTests package
```
结果:
```text
BUILD SUCCESS
Total time: 2.387 s
```
通过。
## 项目级 blocker
完整回归命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' test
```
结果:
```text
Tests run: 1395, Failures: 2, Errors: 9, Skipped: 0
BUILD FAILURE
Total time: 01:50 min
```
失败项:
- `CareerProgressServiceTest.fullAggregation_returnsCorrectSummaries`:期望 1,实际 0,时间依赖问题。
- `JobReviewDimensionControllerTest.listReviews_shouldReturnDimensionScores`:期望 200,实际 500,旧 Mock 签名问题。
- `JobReviewDimensionServiceTest.listReviews_shouldReturnDimensionScoresWithoutN1`:返回结果 null 导致 NPE,旧 Repository stub 问题。
- `LocalResumeStorageServiceTest`:8 项错误,Windows JUnit 临时目录清理阶段异常。
Skill Cloud 目标测试在完整套件中均通过,但完整 `mvn test` 未全绿。上述 2 failures + 9 errors 仍是项目级 blocker;在修复或正式豁免前,不得声称完整回归通过,也不得进入最终变更关闭或 TO-11。
## 收尾结论
- W3-N-I1:**关闭**
- W3-I1:**保持关闭**
- W3-I2:**保持关闭**
- 是否允许 W3 代码收尾:**允许**
- 是否允许最终变更关闭:**不允许**,完整 `mvn test` 仍有 2 failures + 9 errors,需修复或正式豁免。
- 提交范围要求:仓库中存在与本复审无关的暂存、修改和未跟踪文件;W3 收尾提交时必须只包含 N03 范围内的文件,避免混入无关变更。

178
changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave-rereview.md

@ -0,0 +1,178 @@
# N03 SDD W3 聚焦复审报告
## 复审对象
- 代码基线:`cd9220b`,修复差异仍位于工作区,未提交。
- 复审范围:W3-I1、W3-I2 两项 Important finding 的聚焦修复。
- 环境:`JAVA_HOME=D:\java\jdk`,Maven `D:\maven\apache-maven-3.9.7\bin\mvn.cmd`。
- 对照报告:`changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave.md`。
## Verdict
**FAIL**
W3-I1 的原始 `/skills/match` operationId 冲突已修复,W3-I2 要求的运行时 401 契约、Markdown 文档和静态 OpenAPI 已对齐;但本次为满足全局 operationId 唯一性而修改了两处既有 `cancel` 契约,新增一项 Important 问题。
因此 W3 仍不能判定通过,不能进入收尾或 TO-11。
## W3-I1:operationId 冲突
### 关闭状态
**原始 finding 已关闭。**
- `SkillCloudController.java:44` 显式设置 `operationId=getWordCloud`。
- `SkillCloudController.java:92` 显式设置 `operationId=getSkillMatch`。
- `SkillCloudController.java:140` 显式设置 `operationId=getLearningPath`。
- 静态 OpenAPI 三个 Skill Cloud operationId 与上述值完全一致:
- `/skills/word-cloud` = `getWordCloud`
- `/skills/match` = `getSkillMatch`
- `/skills/learning-path` = `getLearningPath`
- `docs/verify_openapi.py:165-168` 对三个 operationId 做精确断言。
- `SkillCloudControllerTest.skillOperationIdsAreExactAndGloballyUnique` 通过运行时 `/v3/api-docs` 校验三个接口的 operationId,并确认每个目标 operationId 只对应目标路径。
### 新增问题 N-I1(Important):修改了既有 OpenAPI 契约
`docs/verify_openapi.py:27-31` 新增了对全文档 operationId 的无条件唯一性断言。为让该断言通过,本次修复还修改了与 N03 无关的两个既有接口:
- `POST /interviews/{interviewId}/cancel`:`cancel` → `cancelInterview`
- `POST /profile/me/deletion/cancel`:`cancel` → `cancelAccountDeletion`
问题如下:
1. `operationId` 属于 OpenAPI 公共契约,常用于客户端和 SDK 生成。修改既有 operationId 属于兼容性破坏,即使 HTTP 路径和运行时行为不变,也可能导致生成客户端的方法名变化或升级失败。
2. 两个 `cancel` 重复值是本次变更前已经存在的历史问题,不是 N03 引入的问题,也不在 W3-I1 的修复范围内。
3. `InterviewController.java:132-136` 和 `AccountDeletionController.java:112-115` 仍使用方法名 `cancel`,且没有显式 operationId;因此静态 OpenAPI 被改为两个新名称后,静态契约与运行时 SpringDoc 文档发生漂移。
4. `SkillCloudControllerTest.skillOperationIdsAreExactAndGloballyUnique` 的名称虽然包含 “GloballyUnique”,但实际只验证三个 Skill Cloud operationId 各自只出现一次,并没有验证全文档唯一性。真正强制全文档唯一性的是 `verify_openapi.py`,而它迫使本次变更修改历史契约。
**严重级别:Important。**
**最小修复建议:**
- 将 `docs/openapi-rc.json` 中两个既有接口的 operationId 恢复为 `cancel`,不要在本变更中修改既有接口契约。
- 将 `docs/verify_openapi.py` 的检查改为“新增接口不得产生新冲突”,而不是“修复全部历史冲突”。例如:
- 保留已知历史重复集合 `{'cancel'}`;
- 断言除该允许集合外没有其他重复 operationId;
- 断言 `getWordCloud/getSkillMatch/getLearningPath` 各出现且仅出现一次;
- 断言它们不与历史或未来任何非允许 operationId 冲突。
- 如果项目要求彻底消除全局 operationId 重复,应另开变更,同时在两个既有 Controller 上显式设置 operationId,并评估客户端兼容性与迁移方案。
## W3-I2:401 运行时与文档契约
### 关闭状态
**本次要求范围内的 finding 已关闭。**
- 运行时实现仍由全局 Security 入口返回:
- `code=401`
- `message="not authenticated"`
- `timestamp=""`
- 不包含 `requestId`
- 不包含 `data`
- `SkillCloudControllerTest.anonymousRequestUsesSecurityEntryPointContract` 对三个接口执行上述字段级断言,并额外断言响应字段集合恰好为 `code/message/timestamp`。
- `SkillCloudAuthorizationIntegrationTest.anonymousAndInvalidJwtUseSecurityEntryPointContract` 同时覆盖匿名请求和 `Bearer invalid-token`。
- `docs/skill-cloud-api.md` 已明确区分 `401` 裸 JSON 与统一 `ApiResponse`,示例与运行时一致。
- 静态 OpenAPI 三个 Skill Cloud `401` 响应均引用 `UnauthorizedResponse`,示例均为:
- `code=401`
- `message="not authenticated"`
- `timestamp=""`
- 无 `requestId`、无 `data`
- `docs/verify_openapi.py:173-185` 精确校验上述静态 401 契约。
### 非阻断观察(Minor)
`SkillCloudController.java:66、114、162` 的 401 注解仍使用 `@Schema(implementation = ApiResponse.class)`。因此运行时 `/v3/api-docs` 的 401 schema 仍可能展示通用 `ApiResponse`,而静态 OpenAPI 已改为 `UnauthorizedResponse`。本次要求明确检查的是 Markdown 文档和静态 OpenAPI,且该问题不阻碍运行时行为或本 finding 的既定修复;如运行时 API 文档也被视为发布契约,应另行聚焦修正控制器注解并补充运行时 OpenAPI 断言。
## 独立验证结果
### T2–T9 目标测试
命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest,SkillMatchServiceTest,SkillLearningPathServiceTest,SkillCloudControllerTest,SkillCloudAuthorizationIntegrationTest,SkillCloudEndToEndIntegrationTest' test
```
结果:
```text
Tests run: 73, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 55.399 s
```
分类统计:
- SkillTaxonomyMigrationTest:2
- SkillCloudSeedDataIntegrationTest:8
- SkillTaxonomyRepositoryTest:6
- SkillCloudQueryServiceTest:8
- SkillMatchServiceTest:16
- SkillLearningPathServiceTest:10
- SkillCloudControllerTest:15
- SkillCloudAuthorizationIntegrationTest:3
- SkillCloudEndToEndIntegrationTest:5
### OpenAPI 静态校验
命令:
```powershell
python docs\verify_openapi.py
```
结果:
```text
paths: 129
All checks passed!
```
结论:静态校验通过,但该通过依赖于 N-I1 所描述的既有 operationId 修改,不能据此判定 W3 通过。
### 打包
命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' -DskipTests package
```
结果:
```text
BUILD SUCCESS
```
### 完整 `mvn test` 项目级回归
命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' test
```
结果:
```text
Tests run: 1395, Failures: 2, Errors: 9, Skipped: 0
BUILD FAILURE
```
所有 Skill Cloud 目标测试在全量套件中通过;失败仍集中在既有或环境相关测试:
- `CareerProgressServiceTest.fullAggregation_returnsCorrectSummaries`:时间依赖失败。
- `JobReviewDimensionControllerTest.listReviews_shouldReturnDimensionScores`:旧 Mock 签名导致 500。
- `JobReviewDimensionServiceTest.listReviews_shouldReturnDimensionScoresWithoutN1`:旧 Repository stub 导致 NPE。
- `LocalResumeStorageServiceTest`:Windows JUnit 临时目录 `AccessDeniedException`,8 个错误。
因此不能声称完整 `mvn test` 通过。上述 2 failures + 9 errors 仍是项目级 blocker,未形成正式豁免前不能关闭变更或进入 TO-11。
## 剩余阻断项
1. **N-I1(Important)**:恢复既有 `cancel` operationId,并调整校验脚本,使唯一性检查只拦截新增冲突,不以修改历史契约为代价。
2. **项目级验证 blocker**:完整 `mvn test` 仍有 2 failures + 9 errors,需修复或正式豁免。
3. **Minor**:运行时 `/v3/api-docs` 的 401 schema 仍可能需要从通用 `ApiResponse` 切换为 `UnauthorizedResponse`,以避免静态/运行时 OpenAPI 漂移。

198
changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave.md

@ -0,0 +1,198 @@
# N03 SDD W3 整批独立评审报告
## 评审对象
- 范围:`df7238b..cd9220b`
- Wave:W3
- 批次:B3 / T8、T9、T10
- 提交:
- `3e353bd feat: expose student skill cloud endpoints`
- `7873a42 test: add skill cloud end-to-end regression`
- `cd9220b docs: publish skill cloud api contract`
- 评审依据:修订后的 `execution-contract.md`、`design.md`、`tasks.md`、三份 spec,以及 `W3-T8*`、`W3-T9.md`。
- 环境:`JAVA_HOME=D:\java\jdk`,Maven `D:\maven\apache-maven-3.9.7\bin\mvn.cmd`。
## Verdict
**FAIL**
W3 的运行时接口、STUDENT 权限、参数校验、DTO 序列化、端到端一致性和打包均通过独立验证;但 T10 发布的 OpenAPI 契约存在两项 Important 问题:
1. 新增 `/skills/match` 的 `operationId=getMatch` 与既有 `/applications/{id}/match` 冲突。
2. 匿名或无效 JWT 的运行时 401 响应为 `code=401` 的裸 JSON,而文档和静态 OpenAPI 声明 `2001` 及统一 `ApiResponse` 语义。
因此 T10 不能判定通过,且本 wave 不允许进入收尾或 TO-11。
## T8:Controller、权限、参数与 ApiResponse
### 通过项
- 三个路由存在且均为 GET:
- `/skills/word-cloud`
- `/skills/match`
- `/skills/learning-path`
- `industryId`、`occupationId` 为必填正整数;`categoryId` 为可选正整数。
- 成功后统一返回 `ResponseEntity<ApiResponse<T>>`,并保留 `X-Request-Id`。
- `SecurityConfig` 仅放行已认证请求到 `/skills/**`;Controller 再通过真实 `ProfileService` 按数据库角色校验 STUDENT。
- `SkillCloudAuthorizationIntegrationTest` 使用真实 `UserRepository`、数据库角色集合、JWT 过滤器和 `ProfileService`,没有 mock 核心权限链路。
- 已覆盖 STUDENT=200、COMPANY=403、ADMIN=403、STUDENT+COMPANY=200、COMPANY+ADMIN=403。
- 额外传入 `userId`、`studentId` 不能覆盖 JWT 主体。
- `SkillCloudControllerTest` 共 14 项,授权集成测试共 2 项,独立执行均通过。
- 未发现 B3 修改已有接口语义或引入无关模块变更。提交范围仅涉及新增 Controller、SecurityConfig 一条 GET 规则、技能测试和文档/OpenAPI。
### 阻断项
#### W3-I1:`operationId=getMatch` 重复
- `docs/openapi-rc.json:1186`:既有 `/applications/{id}/match` GET。
- `docs/openapi-rc.json:6460`:新增 `/skills/match` GET。
- 两处均使用 `operationId: "getMatch"`。
- `SkillCloudController.java:120-121` 的方法名也是 `getMatch`,运行时生成的 operationId 很可能同样冲突;现有测试没有检查全局 operationId 唯一性。
- `docs/verify_openapi.py:146` 反而明确断言新增接口的 operationId 必须是 `getMatch`,把冲突值固化为通过条件。
- OpenAPI 规范要求 operationId 全局唯一;重复值会导致客户端/文档代码生成器出现歧义或相互覆盖。
修复方向:给 `SkillCloudController.getMatch` 显式设置唯一 operationId,例如 `getSkillMatch`,同步静态 OpenAPI 和 verify 脚本,并增加全局 operationId 唯一性断言。
#### W3-I2:401 运行时响应与文档/OpenAPI 不一致
- 运行时匿名请求由 `SecurityConfig.java:50-54` 直接写出 `{"code":401,...}`,且不包含 `requestId`。
- `SkillCloudControllerTest.java:158-164` 明确断言匿名 401 的 `$.code=401`。
- `docs/skill-cloud-api.md:14` 声明使用统一 `ApiResponse`。
- `docs/skill-cloud-api.md:230-240` 声明 401 的典型业务码为 `2001`,示例也返回 `code=2001` 和 `requestId`。
- `docs/openapi-rc.json` 的三个新增 401 响应示例均使用 `code=2001`。
- 因此静态契约与真实运行时对相同的“未登录/无效 JWT”场景给出了不同的业务码和响应语义。
修复方向:二选一并做可证伪测试:
- 保持现有全局安全行为,更新接口文档和静态 OpenAPI 为真实 `code=401` 响应;或
- 在授权范围内调整安全入口,使 401 使用统一的 `ApiResponse` 和 `2001`,同时评估对所有既有接口的兼容性。
## T9:端到端一致性
### 通过项
`SkillCloudEndToEndIntegrationTest` 共 5 项,使用真实 H2/Flyway、Security、Controller、Service、Repository 和 V36 数据,仅 Mock `RedisTemplate` 以隔离基础设施依赖,独立执行通过。
已验证:
- 词云返回完整排序列表,`rank` 连续,`heat DESC`,空热度末位。
- 匹配集合严格等于 `min(10,total)`,`matchedSkills + missingSkills` 等于同一 TOP10,且各自保持顺序。
- 学习路径严格复用 `missingSkills` 顺序,`priority` 从 1 连续递增。
- 少于 10 项时按实际大小计算,`1/3` 返回 33;空范围返回 0 和空列表。
- 重复调用三个接口返回完全相同的数据。
- 合法空分类覆盖 `total=0`、`matchScore=0`、`learningPath.total=0`。
- 成功响应未发现手机号、邮箱、简历文件名/URL 或原始技能 JSON。
- 额外身份查询参数不能覆盖学生 JWT 主体。
### 非阻断测试缺口
- “无写入”目前只比较 8 张表的行数,只能发现 INSERT/DELETE,不能发现仅 UPDATE 的写操作。
- 隐私断言未显式覆盖 `realName`、`bio`、`skillsJson` 等驼峰或实际值。
- 端到端测试主要证明三个接口复用同一 TOP10,具体名称/别名匹配分支由 T6 测试覆盖,这一点可以接受。
以上均为可加强项,不单独阻断 T9 运行时实现。
## T10:文档与 OpenAPI
### 通过项
- 静态 OpenAPI path 数从 126 增至 129,三个新路径均存在。
- 三个新路径均声明 `bearerAuth`、200/400/401/403,以及必填/可选参数。
- 新增 Schema 字段与 Java DTO 字段在静态检查中一致。
- `docs/skill-cloud-api.md` 和 `docs/frontend-integration.md` 覆盖 TOP10 截取、匹配公式、排序、空数据、权限、无 AI 和无持久化语义。
- `python docs\verify_openapi.py` 独立执行成功,输出 `paths: 129` 和 `All checks passed`。
- 未发现三个新接口之外的接口语义被改变。
### 阻断项
见上方 `W3-I1`、`W3-I2`。T10 当前不能判定通过。
### verify 脚本的不足
- `docs/verify_openapi.py:164` 使用 `expected_fields <= properties`,只验证字段至少存在,不验证字段集合完全相等、类型、nullable、required 或静态契约与运行时的对应关系;新增多余字段仍可通过。
- 脚本没有全局 operationId 唯一性断言,因此 `W3-I1` 未被发现。
- 脚本检查静态 OpenAPI,而 `SkillCloudControllerTest` 检查运行时 `/v3/api-docs`,两者没有做等价性比较;当前静态文件使用 `example`,运行时注解生成使用 `examples`,进一步说明两者可能各自漂移。
- 精确 path 数断言可以用于回归基线,但不能替代 operationId 唯一性、Schema 完整性和错误响应一致性验证。
## 独立执行结果
### T2–T9 指定测试组
命令:
```powershell
$env:JAVA_HOME='D:\java\jdk'
& 'D:\maven\apache-maven-3.9.7\bin\mvn.cmd' '-Dtest=SkillTaxonomyMigrationTest,SkillCloudSeedDataIntegrationTest,SkillTaxonomyRepositoryTest,SkillCloudQueryServiceTest,SkillMatchServiceTest,SkillLearningPathServiceTest,SkillCloudControllerTest,SkillCloudAuthorizationIntegrationTest,SkillCloudEndToEndIntegrationTest' test
```
结果:
```text
Tests run: 71, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 59.133 s
```
分类:
- SkillTaxonomyMigrationTest:2
- SkillCloudSeedDataIntegrationTest:8
- SkillTaxonomyRepositoryTest:6
- SkillCloudQueryServiceTest:8
- SkillMatchServiceTest:16
- SkillLearningPathServiceTest:10
- SkillCloudControllerTest:14
- SkillCloudAuthorizationIntegrationTest:2
- SkillCloudEndToEndIntegrationTest:5
### OpenAPI 校验
```text
python docs\verify_openapi.py
paths: 129
All checks passed!
```
该命令通过,但不覆盖上述 operationId 冲突、401 运行时差异和静态/运行时 Schema 等价性,因此不能抵消 W3-I1、W3-I2。
### 打包
```text
mvn -DskipTests package
BUILD SUCCESS
Total time: 2.401 s
```
### 完整 `mvn test`
独立运行结果:
```text
Tests run: 1393, Failures: 2, Errors: 9, Skipped: 0
BUILD FAILURE
Total time: 01:58 min
```
Skill Cloud 全部测试类在该套件中均通过。
## 全量 blocker 归类
以下失败均不在 `df7238b..cd9220b` 修改范围内,不能归因于 W3 引入的行为回归:
- `CareerProgressServiceTest.fullAggregation_returnsCorrectSummaries`:固定 Offer 过期时间 `2026-09-05` 已早于当前日期,属于时间依赖测试问题。
- `JobReviewDimensionControllerTest.listReviews_shouldReturnDimensionScores`:仍 stub 旧服务方法签名。
- `JobReviewDimensionServiceTest.listReviews_shouldReturnDimensionScoresWithoutN1`:仍 stub 旧 Repository 方法,导致 NPE。
- `LocalResumeStorageServiceTest`:8 项均在 Windows JUnit 临时目录清理阶段因 `AccessDeniedException` 报错,属于环境/临时目录问题。
归类结论:
- **不是 W3 代码阻断。**
- **仍然是项目级验证阻断,应阻止 `closing`/TO-11**,除非先修复,或由项目层面正式记录并批准豁免;合同退出条件不能被视为满足。
- 本次独立验证未运行真实 MySQL 黑盒迁移,仍应保留为发布级验证风险。
## 最终放行结论
- **是否允许 T8 运行时实现进入后续:允许。** 权限、参数和运行时响应链路通过。
- **是否允许 T9 端到端实现进入后续:允许。** 端到端行为通过,仅有非阻断覆盖缺口。
- **是否允许 W3 整批通过:不允许。** T10 的 OpenAPI/文档契约存在 Important 阻断。
- **是否允许进入收尾/TO-11:不允许。** 需要先修复 W3-I1、W3-I2 并进行聚焦复审;全量 `mvn test` 还需全绿或形成正式豁免记录。

151
changes/n03-skill-cloud/.superpowers/sdd/reviews/runtime-startup-fix-review.md

@ -0,0 +1,151 @@
# N03 运行时启动缺陷修复独立复审报告
## Verdict
**PASS**
运行时启动缺陷的本轮修复通过独立复审:实体不再强制 `TINYINT` 映射,V37 将 H2 侧列规范化为 `BOOLEAN`,MySQL 侧保持 `BOOLEAN/TINYINT(1)` 语义;标准 T2–T10 回归和跳过测试的打包均通过。完整 `mvn test` 仍有既有、非本次修复引入的失败,不能据此声称全量回归通过。
## 审查对象
- `mydreamjob-server/src/main/resources/db/migration/V37__skill_cloud_boolean_type.sql`
- `mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/OccupationSkill.java`
- `SkillTaxonomyRepositoryTest.java`
- `SkillTaxonomyMigrationTest.java`
- `SkillCloudSeedDataIntegrationTest.java`
- `SkillCloudQueryServiceTest.java`
- 已有证据日志:`target/skill-cloud-v37-red.log`、`skill-cloud-v37-final-green.log`、`skill-cloud-v37-final-package.log`
## 根因闭环
原始启动失败为:
```text
Schema-validation: wrong column type encountered in column [importance_is_proxy]
found [bit (Types#BIT)], but expecting [tinyint (Types#TINYINT)]
```
闭环判断:
1. `OccupationSkill.importanceIsProxy` 原先使用显式 `@JdbcTypeCode(SqlTypes.TINYINT)`,把 Hibernate 的 JDBC 期望固定为 `TINYINT`。
2. 本轮已移除该显式注解及其 import;字段仍是 `Boolean`,列名、可空性和其他字段映射未改变。
3. 新回归测试同时断言:
- Hibernate 属性返回类型为 `Boolean.class`;
- 字段不再存在显式 `JdbcTypeCode` 注解。
4. V37 将 `occupation_skills.importance_is_proxy` 规范化为 `BOOLEAN NOT NULL DEFAULT FALSE`,并重建原 CHECK 约束。H2 `MODE=MySQL` 下迁移测试确认元数据为 `BOOLEAN / FALSE / NO`。
5. MySQL 8.0.43 临时实例实际执行同一 DDL 后,物理元数据为 `tinyint / tinyint(1) / default 0 / NO`,读写结果为默认 `0`、`FALSE=0`、`TRUE=1`。
因此根因修复成立:实体不再强制 TINYINT;H2 使用真正的 BOOLEAN;MySQL 保留 BOOLEAN 别名对应的 TINYINT(1) 物理语义。
## V35/V36 与 Flyway 安全检查
独立比较结果:
- `V35__skill_cloud_taxonomy.sql`:工作区与 `HEAD` 的 Git hash 均为 `db81f934...`,无差异。
- `V36__skill_cloud_seed.sql`:工作区与 `HEAD` 的 Git hash 均为 `e4a03a40...`,无差异。
- V35/V36 均未改写,已应用环境的 Flyway checksum 不受影响。
- V37 为追加迁移,未修改历史迁移内容。
H2 `MODE=MySQL` 兼容性由实际迁移与测试上下文启动验证;MySQL 兼容性由 MySQL 8.0.43 实例实际执行 `DROP CONSTRAINT`、`MODIFY COLUMN BOOLEAN`、`ADD CONSTRAINT` 验证。MySQL 8.4 未在本机安装,未做 8.4 二进制验证;所用 DDL 语法属于 MySQL 8.0/8.4 的通用受支持语法。
## 布尔语义、默认值与 NULL 检查
V37 的顺序为:
1. 删除旧的 `ck_occupation_skills_importance_proxy`;
2. 将列改为 `BOOLEAN NOT NULL DEFAULT FALSE`;
3. 重建 `CHECK (importance_is_proxy IN (FALSE, TRUE))`。
经验证:
- 默认值:新行未显式赋值时为 `FALSE/0`。
- 非空:迁移测试断言 `IS_NULLABLE=NO`,并将 NULL 更新验证为失败。
- 布尔读写:`FALSE`、`TRUE` 可读写,查询结果与布尔比较均正确。
- JDBC 断言:查询测试和种子数据测试已兼容 H2 返回 `Boolean` 以及旧驱动可能返回数值两种形式,不会因方言差异误判。
- `preferred_boolean_jdbc_type=TINYINT`:源代码与配置中未发现该属性;标准测试命令不需要额外 `-D` 参数。
## 独立测试与打包结果
环境:
```text
JAVA_HOME=D:\java\jdk
Maven=D:\maven\apache-maven-3.9.7\bin\mvn.cmd
```
### T2–T10 指定测试组
在 `mydreamjob-server` 下执行,未附加任何布尔类型属性:
```text
mvn -Dtest=SkillCloudAuthorizationIntegrationTest,SkillCloudControllerTest,SkillCloudEndToEndIntegrationTest,SkillCloudQueryServiceTest,SkillCloudSeedDataIntegrationTest,SkillLearningPathServiceTest,SkillMatchServiceTest,SkillTaxonomyMigrationTest,SkillTaxonomyRepositoryTest test
```
结果:
```text
Tests run: 74, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 55.561 s
```
### 打包
```text
mvn -DskipTests package
BUILD SUCCESS
Total time: 2.439 s
```
### MySQL 8.0.43 冒烟
实际执行结果:
```text
id importance_is_proxy
1 0
2 0
3 1
DATA_TYPE COLUMN_TYPE COLUMN_DEFAULT IS_NULLABLE
tinyint tinyint(1) 0 NO
```
说明:MySQL 将 `BOOLEAN` 规范化为 `tinyint(1)` 属正常行为;这与 V37 目标及 H2 的 `BOOLEAN` 元数据要求一致。
## 完整 `mvn test` 状态
独立执行完整命令:
```text
mvn test
Tests run: 1396, Failures: 2, Errors: 9, Skipped: 0
BUILD FAILURE
```
失败项仍为与本功能无关的既有问题:
- `CareerProgressServiceTest.fullAggregation_returnsCorrectSummaries`:固定过期时间导致期望 1、实际 0。
- `JobReviewDimensionControllerTest.listReviews_shouldReturnDimensionScores`:测试仍使用旧服务方法签名,返回 500。
- `JobReviewDimensionServiceTest.listReviews_shouldReturnDimensionScoresWithoutN1`:测试仍 stub 旧 Repository 方法,结果为 null。
- `LocalResumeStorageServiceTest`:Windows JUnit 临时目录清理的 8 个 `AccessDeniedException` 错误。
Skill Cloud 相关测试在完整套件中通过;但按退出条件,完整 `mvn test` 仍未全绿,不能声称全量回归通过。
## 范围与遮蔽检查
- 本次修复的生产代码变更仅涉及 `OccupationSkill` 和新增 V37;其余变更为相应专项测试的布尔兼容与回归断言。
- 未修改 `SecurityConfig`、接口、DTO、业务查询逻辑或其他 schema 映射。
- `SkillTaxonomyRepositoryTest` 原有的 schema filter 仍只限制 H2 测试环境的旧表验证范围,本轮没有扩大或借助该 filter 掩盖 V37 相关 schema 问题;V37 专项迁移测试直接检查 `information_schema` 元数据和约束。
- 工作区中 `.trae/*` 等文件属于本任务之外的既有未提交内容,不构成本次运行时修复差异。
## 非阻断观察
1. `V37__skill_cloud_boolean_type.sql` 当前索引版本与工作区版本存在一个仅涉及列注释的文本差异;两者不影响 DDL 语义,但提交前应统一内容,避免最终提交与独立测试的具体文本不一致。
2. V37 文件工作区版本末尾缺少换行;非功能问题。
3. MySQL 仅完成 8.0.43 二进制冒烟,未在实际 8.4 二进制上复跑;判断为语法兼容,但如需严格的 8.4 认证应补充运行环境验证。
## 剩余阻断
- 本次运行时启动修复:无阻断。
- 项目最终关闭:完整 `mvn test` 仍有 2 failures + 9 errors;需要修复这些既有测试,或由项目按流程正式记录豁免后,才能推进全量回归结论和最终关闭。

14
changes/n03-skill-cloud/.superpowers/sdd/runtime-startup-failure.txt

@ -0,0 +1,14 @@
Runtime failure evidence
=======================
Task: manual application startup from IntelliJ
Symptom: Hibernate SessionFactory build failed
Column: occupation_skills.importance_is_proxy
Found: bit (Types#BIT)
Expected: tinyint (Types#TINYINT)
Root cause: OccupationSkill.importanceIsProxy is explicitly forced to SqlTypes.TINYINT, unlike existing Boolean mappings.
Attempt 2 evidence
==================
Removing @JdbcTypeCode(TINYINT) alone fixed the MySQL expectation but broke H2 schema validation:
found tinyint, expected boolean.
Conclusion: the column must be normalized to a cross-dialect BOOLEAN/BIT form, without modifying already-applied V35.

77
changes/n03-skill-cloud/data/source-manifest.md

@ -0,0 +1,77 @@
# N03 行业技能词云来源与覆盖清单
## 1. 固定来源
| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 |
| --- | --- | --- | --- | --- |
| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download<br>复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq |
| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html |
运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。
## 2. 首版覆盖边界
首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。
| 行业 | 代表性职位 | 首批技能分类范围 |
| --- | --- | --- |
| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 |
| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 |
| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 |
| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 |
| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 |
| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 |
| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps |
| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 |
| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 |
| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 |
计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。
矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。
## 3. 导入规则
1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。
2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。
3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。
4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。
5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。
6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。
7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。
8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。
9. **迁移不可改写**:T2/T3 计划使用 `V35__skill_cloud_taxonomy.sql` 和 `V36__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。
10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。
## 4. 来源版本、许可与归属 URL
| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL |
| --- | --- | --- | --- | --- |
| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download |
| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html |
ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.`
## 5. 映射责任边界
| 事项 | 上游来源责任 | 项目责任 |
| --- | --- | --- |
| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID |
| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 |
| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 |
| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 |
| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 |
| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 |
## 6. 验收检查项
- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。
- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。
- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。
- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。
- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。
- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。
- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。
- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。
- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。
- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。
- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。

55
changes/n03-skill-cloud/decision-point-audit.md

@ -0,0 +1,55 @@
# N03 Decision Point Audit
审计时间:2026-09-15T08:40:05Z
## 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 revision 2 已批准 |
| DP-4 | approved | SDD;W1/W2/W3 串行 wave,依赖关系已固化 |
| DP-5 | adjudicated | T3 来源与可证伪性修复获人工批准 |
| DP-6 | pass with formal waiver | 功能范围内验证通过;全量测试中的既有无关失败由用户正式豁免 |
| DP-7 | pending | 等待归档确认 |
## Contract Coverage
- W1:来源清单、V35 词库结构、V36 代表性种子数据、可追溯 O*NET provenance。
- W2:Repository、词云排序、个人匹配、学习路径,50 项 wave 测试通过。
- W3:三个 STUDENT 接口、真实权限测试、端到端一致性、接口文档和 OpenAPI。
- 14 条 MUST/SHALL 行为均有实现与测试证据。
- delta specs 已发布到根 baseline:specs/skill-cloud、specs/skill-match、specs/learning-path,共 14 条 Requirements,无冲突。
## Verification Evidence
- Skill Cloud 指定测试组:74 tests,0 failures,0 errors,BUILD SUCCESS。
- mvn -DskipTests package:BUILD SUCCESS。
- python docs\verify_openapi.py:paths 129,All checks passed。
- 启动缺陷修复:V37 将 importance_is_proxy 规范化为跨方言 BOOLEAN;H2 MODE=MySQL 与 MySQL 8.0.43 冒烟通过。
- 完整 mvn test:1396 tests,2 failures,1 error,BUILD FAILURE。
- 本次完整测试失败:
- CareerProgressServiceTest.fullAggregation_returnsCorrectSummaries:既有时间依赖。
- JobReviewDimensionControllerTest.listReviews_shouldReturnDimensionScores:既有 Mock 签名漂移。
- JobReviewDimensionServiceTest.listReviews_shouldReturnDimensionScoresWithoutN1:既有 Repository 方法签名漂移。
- 这些失败位于本变更未修改的模块;Skill Cloud 测试在全量套件中全部通过。
## Formal Waiver
用户于 2026-09-15 明确批准:将与本功能无关的既有失败作为正式豁免记录,并进入 release-archivist。当前 fresh run 只剩上述 3 条历史阻塞;更早一轮曾包含 LocalResumeStorageServiceTest 的 Windows 临时目录错误,后续环境运行已恢复。本豁免覆盖所有与本功能无关、且在变更前后可独立复现的既有失败。
## Scope and Coherence
- 变更实现集中在 com.mydreamjob.skill、Flyway V35–V37、SecurityConfig、docs、OpenAPI 与对应测试。
- 未修改 V1–V34 历史迁移或已有接口语义。
- V37 是启动兼容修复,不改变 API 行为或业务公式。
- W2/W3 独立评审均已通过;运行时启动修复也已独立复审 PASS。
- 已知非阻断项:heatSource 设计表述与实际 heatSourceCode/heatElementId/heatProxy 字段命名不同;文档已按实际字段发布。
## Remaining Actions
1. 记录 DP-7 归档确认。
2. 将状态从 executing 转为 closing。
3. ssf CLI 不可用;物理归档/合并需在获得用户确认后手工执行,且必须排除仓库中已有的无关未提交文件。

180
changes/n03-skill-cloud/design.md

@ -0,0 +1,180 @@
# 行业技能词云与学习路径设计
## 事实与约束
- 仓库当前为 Spring Boot 3.5.16、Java 17、MySQL/Flyway/JPA,最新已存在迁移为 `V34`。
- 学生技能存储在 `student_profiles.skills_json`,历史数据可能为空、重复或无法解析。
- 企业行业与岗位业务数据是自由文本,不能作为稳定的“行业 → 职位 → 技能”分类源。
- ESCO 提供职位、技能和技能分类关系;O*NET 提供 Importance 数值。两者均需保留来源版本与许可归属。
- 首版只导入互联网、金融、教育及代表性职位,避免全量翻译和超大迁移。
- 三个接口均为只读、仅 STUDENT,身份只取 JWT,响应统一使用 `ApiResponse`。
- 词云热度是静态 O*NET Importance,不随平台岗位发布和投递行为变化。
- 计算完全确定,不调用 AI、网络课程服务或第三方运行时接口。
## Goals
- 建立可持续扩展的开源技能知识库,并允许项目维护中文名称、别名和细粒度分类。
- 提供完整、稳定、可筛选的热度排序技能词云。
- 以同一排序结果计算个人 TOP10 匹配度,避免不同接口结果不一致。
- 基于未命中技能生成最小可用的学习优先级和建议。
- 保持现有模块接口语义不变,并满足项目测试、OpenAPI 和黑盒验收约定。
## Non-Goals
- 不做岗位实时文本挖掘、平台岗位热度聚合或用户行为周榜。
- 不导入 ESCO/O*NET 全量数据,不建设后台维护界面。
- 不保存匹配快照、学习进度、完成状态或提醒。
- 不提供课程、讲师、价格、时长、链接和 AI 对话。
- 不向 COMPANY/ADMIN 暴露个人匹配和学习路径。
## Decisions
### D1: 以 ESCO + O*NET 作为静态知识底座
**Choice**:ESCO 作为职位、通用技能和分类主表;当具体技术(如 React、Docker、Spring Boot)没有可靠 ESCO 节点时,允许使用 O*NET `software_skills` 作为补充技能来源。O*NET 继续提供可追溯 Importance;项目只维护中文标签、别名和映射。
**Rationale**:能用开源数据覆盖技能层级,同时保留 O*NET 明确的重要度字段,不需要自行创造热度权重。
**Alternatives**:
- 仅 O*NET:重要度完整,但英文和美国职业体系适配成本高。
- 严格仅 ESCO:来源单一,但会丢失 React、Docker 等 ESCO 未收录的具体技能。
- 自建词典:可控性高,但需要长期运营且缺少来源可信度。
**Consequences**:需要明确区分 ESCO 主来源技能与 O*NET 补充技术;每条热度必须记录 O*NET SOC 和 Element ID,无法映射时保留技能并写空热度。
### D2: 词库使用关系表落 Flyway
**Choice**:新增行业、职位、技能分类、技能、别名、职位技能关系和来源版本表,通过 Flyway 迁移导入代表性数据。
**Rationale**:接口需要多条件过滤、稳定排序和可追溯来源;关系表可利用外键、唯一约束和索引保证数据质量。
**Alternatives**:
- 静态 JSON:实现快,但无法方便地按节点过滤、校验引用和演进。
- 图数据库:适合复杂关系,但超出当前技术栈和首版范围。
**Consequences**:迁移文件需要严格审查,后续词库更新必须创建新迁移,不能修改已执行迁移。
### D3: 三个接口共享同一条排名管道
**Choice**:先查询筛选范围内的技能列表并按热度排序,再从同一列表截取 TOP10;匹配和学习路径均调用同一领域服务,不重新排序。
**Rationale**:词云、匹配度和学习路径必须对“热门技能集合”保持一致,减少重复逻辑和边界分歧。
**Alternatives**:
- 每个接口独立查询和排序:实现分散,容易出现排序不一致。
- 持久化统一排名快照:可缓存,但当前数据规模不需要额外调度和一致性成本。
**Consequences**:排序规则、空热度规则和别名规则只有一个实现位置;单元测试必须聚焦该共享管道。
### D4: TOP10 不单独存储或返回
**Choice**:词云接口返回完整排序列表,前端直接取前十条;匹配服务在内部最多取前十条。
**Rationale**:用户已确认不需要独立 TOP10 数据源,列表本身是唯一事实来源。
**Alternatives**:响应同时返回 `top10` 字段或提供独立接口;会重复数据并增加一致性风险。
**Consequences**:前端必须按约定截取前十条;接口文档需明确列表已按排名排序。
### D5: 匹配和建议按请求实时计算且不持久化
**Choice**:每次读取当前学生技能、词库和筛选条件后计算匹配度和学习路径,不保存计算结果。
**Rationale**:数据量小、算法确定,且用户未要求历史记录;避免引入无用状态和隐私数据留存。
**Alternatives**:保存每日匹配快照或学习计划;会增加表、清理与一致性复杂度,首版无收益。
**Consequences**:学生技能或词库更新后,结果立即变化;响应必须包含足够字段让前端独立展示。
### D6: 学习建议使用分类模板
**Choice**:技能分类映射到编程语言、框架、工具和通用四类建议模板。
**Rationale**:满足“技能 + 优先级 + 简化建议”,无需依赖外部课程数据。
**Alternatives**:调用 AI 或维护课程表;前者不确定且超出约束,后者缺少可用授权数据。
**Consequences**:建议保持简短、行业无关和可重复;后续可在不改变接口结构的前提下细化模板。
## Data Model
| 表 | 关键字段 | 作用 |
| --- | --- | --- |
| `skill_industries` | `id`, `code`, `name_zh`, `source_code`, `sort_order` | 行业节点 |
| `skill_occupations` | `id`, `industry_id`, `code`, `name_zh`, `source_code`, `sort_order` | 行业下职位 |
| `skill_categories` | `id`, `occupation_id`, `code`, `name_zh`, `suggestion_template_code`, `sort_order` | 职位下技能分类 |
| `skills` | `id`, `code`, `name_zh`, `name_en`, `source_type`, `source_code` | 标准技能 |
| `skill_aliases` | `id`, `skill_id`, `alias`, `normalized_alias` | 中英文、大小写和写法别名 |
| `occupation_skills` | `occupation_id`, `skill_id`, `category_id`, `importance`, `importance_source_code`, `importance_element_id`, `importance_is_proxy`, `relationship_type`, `source_version` | 职位技能关系、热度及其机器可追溯来源 |
| `skill_source_versions` | `source_name`, `source_version`, `license`, `attribution_url`, `imported_at` | 来源与许可追溯 |
约束与索引:
- `skill_occupations.industry_id + code` 唯一,防止同一行业内职位重复。
- `skill_categories.occupation_id + code` 唯一。
- `skill_aliases.normalized_alias` 全局唯一,避免同一写法映射到多个技能。
- `occupation_skills.occupation_id + skill_id` 唯一。
- 每条非空 `importance` 必须有 O*NET `importance_source_code` 和 `importance_element_id`;代理映射必须设置 `importance_is_proxy=1`。
- 查询索引覆盖 `occupation_id + category_id` 和 `occupation_id + importance`。
- `category_id` 必须属于 `occupation_id`,通过服务校验并在必要测试中验证。
## API Contract
### `GET /api/v1/skills/word-cloud`
查询参数:`industryId` 必填,`occupationId` 必填,`categoryId` 可选。
返回 `data`:
- `industry`、`occupation`、`category`:实际筛选节点。
- `total`:技能总数。
- `items`:按 `heat DESC, nameZh ASC, skillId ASC` 排序,包含 `rank`、`skillId`、`code`、`nameZh`、`nameEn`、`categoryId`、`categoryName`、`heat`、`heatSource`。
### `GET /api/v1/skills/match`
使用与词云相同的三个查询参数。返回 `data`:
- `matchScore`:0–100 整数。
- `matchedCount` 与 `totalCount`。
- `matchedSkills` 与 `missingSkills`:使用精简技能项,缺失技能保持热度顺序。
### `GET /api/v1/skills/learning-path`
使用与词云相同的三个查询参数。返回 `data`:
- `total`。
- `items`:每项包含 `priority`、`skillId`、`skillName`、`categoryId`、`categoryName`、`suggestion`。
## Processing Rules
1. Flyway 先建立表和约束,再导入代表性词库数据。
2. 查询服务校验行业、职位、分类关系;ESCO 作为职位与通用技能主表,O*NET `software_skills` 仅补充 ESCO 缺失的具体技术,构建统一的排序技能列表。
3. 匹配服务解析当前学生 `skills_json`;解析失败按空技能处理并记录告警,不输出原始 JSON。
4. 学生技能和技能别名统一执行 Unicode 规范化、去首尾空白、大小写折叠、连续空白折叠及常见标点归一化。
5. TOP10 大小取 `min(10, 列表大小)`;匹配度按 `round(matched * 100.0 / size)` 计算,`size=0` 时返回 0。
6. 学习路径遍历缺失技能,按列表顺序生成连续优先级,并依据 `suggestion_template_code` 产生建议。
7. 所有计算在只读事务中完成,不写入匹配或学习路径记录。
## Security and Privacy
- 在 SecurityFilterChain 中将 `/skills/**` 限制为已认证请求,Controller/Service 再断言 STUDENT 角色。
- 身份只取 SecurityContext/JWT,不接受 `studentUserId`、`userId` 或 `skills` 请求参数。
- 响应不包含原始技能 JSON、手机号、邮箱、简历内容或词库内部敏感字段。
- 日志只记录异常类型与请求上下文,不记录完整学生技能数组。
## Risks and Verification
| 风险 | 处理 | 验证证据 |
| --- | --- | --- |
| ESCO/O*NET 中文覆盖不足 | 首版只导入代表性职位,所有入选技能必须有中文标准名 | 种子数据检查:中文名非空率 100% |
| 别名冲突导致匹配不确定 | `normalized_alias` 全局唯一,迁移失败即阻断 | Flyway 唯一约束测试和别名单测 |
| Importance 缺失 | `heat=null`,排序末位,保留技能 | 排序单元测试和 H2 Repository 测试 |
| 分类关系错配 | 校验分类属于职位、职位属于行业 | 非法组合 MockMvc 400 场景 |
| 迁移过大影响启动 | 首版限制三类行业和代表性职位,种子数据可控 | 迁移在 H2/MySQL 测试库执行耗时记录 |
| 学生技能 JSON 损坏 | 捕获解析异常并按空技能处理 | 异常 JSON 服务测试,断言 200 和匹配度 0 |
| 三个接口结果漂移 | 使用共享排名服务与同一 DTO 排序 | 集成测试断言 TOP10、匹配和学习路径顺序一致 |
| 许可或版本遗漏 | 保存来源版本、许可和归属 URL | 来源说明文档审查与迁移数据断言 |
## Completion Boundary
设计完成的标准是:四个规划产物一致、所有需求有可测试场景、任务覆盖三个接口和 Flyway 数据变更、没有未确认的兼容性或权限决策。实现和验收不在本设计阶段启动。

147
changes/n03-skill-cloud/execution-contract.md

@ -0,0 +1,147 @@
# N03 行业技能词云与学习路径执行契约
## Intent Lock
**问题**:学生无法从行业和职位维度查看招聘市场热门技能,无法量化自己相对 TOP10 的掌握程度,也没有针对缺失技能的优先级建议。
**意图**:基于 ESCO + O*NET 开源知识底座,通过 Flyway 建立可追溯的行业—职位—技能分类—技能数据。ESCO 作为职位、通用技能和分类主表,O*NET `software_skills` 仅补充 ESCO 缺失的具体技术;O*NET Importance 提供热度及机器可追溯来源。系统提供仅 STUDENT 可访问的词云、个人匹配度和学习路径三个确定性接口。
**完成证明**:V35/V36 迁移成功执行;三个接口按同一热度列表工作;权限、排序、别名、匹配分母、空数据和通用学习建议均通过自动化测试;OpenAPI 与文档一致。
## Scope Fence
### 允许范围
- 新增 `mydreamjob-server/src/main/resources/db/migration/V35__skill_cloud_taxonomy.sql` 和 `V36__skill_cloud_seed.sql`。
- 新增 `com.mydreamjob.skill` 领域包、Repository、Service、DTO 和三个 Controller。
- 修改安全配置以保护 `/skills/**`,仅允许已登录 STUDENT。
- 新增 ESCO/O*NET 来源说明、接口文档、OpenAPI 契约和测试。\n- 新增从 O*NET 31.0 官方 CSV 提取的测试证据快照,仅用于全量核对种子来源和 Importance,不参与运行时。
- 首版数据只覆盖互联网、金融、教育及代表性职位。
- 具体技术无可靠 ESCO 节点时,允许使用 O*NET `software_skills` 作为补充来源。
- 词云热度只取 O*NET Importance;无映射时 `heat=null`。
- 匹配度使用当前 TOP10 和实际技能数;学习路径只返回缺失技能、优先级和通用建议。
### 禁止范围
- 不修改 V1–V34 历史迁移,不改变已有接口语义。
- 不接 AI、课程平台、第三方运行时接口或在线词库同步。
- 不按平台岗位 `jobId`、发布量或用户行为计算热度。
- 不实现后台词库维护、学习进度、提醒、历史快照或前端页面。
- 不向 COMPANY/ADMIN 返回个人匹配或学习路径。
- 不在响应或日志中输出原始 `skills_json`、手机号、邮箱、简历内容。
## Approved Behavior
| ID | 已批准行为 | 批次 |
| --- | --- | --- |
| REQ-SC-1 | `/skills/word-cloud`、`/skills/match`、`/skills/learning-path` 只允许已登录 STUDENT;匿名返回 401,COMPANY/ADMIN 返回 403。 | B3 |
| REQ-SC-2 | `industryId`、`occupationId` 必填且必须互为关联;`categoryId` 可选,存在时必须属于目标职位;非法组合返回 400。 | B2、B3 |
| REQ-SC-3 | 词云返回筛选范围内全部技能,按 `heat DESC, nameZh ASC, skillId ASC` 排序;`heat=null` 排在有值技能之后。 | B2 |
| REQ-SC-4 | TOP10 不单独存储或返回,前端取词云前十条;少于十条时全部结果构成实际 TOP10。 | B2、B3 |
| REQ-SC-5 | 每个技能项包含技能 ID、名称、分类、`heat`、稳定排名和可追溯来源标识;响应使用 `ApiResponse`。 | B2、B3 |
| REQ-SM-1 | 个人匹配只使用当前筛选结果的 `min(10, actualSize)` 个技能,不另建排序。 | B2 |
| REQ-SM-2 | 学生技能只从 JWT 对应学生的 `student_profiles.skills_json` 读取;缺失、空数组或损坏 JSON 按无技能处理。 | B2 |
| REQ-SM-3 | 匹配使用标准技能和别名,执行 NFKC、首尾空白、大小写、连字符转空格、连续空白折叠和句点移除归一化;`+`、`#` 不得删除。 | B2 |
| REQ-SM-4 | 匹配度为 `round(matched * 100 / min(10, actualSize))`;`actualSize=0` 时返回 0;重复命中同一技能只计一次。 | B2 |
| REQ-SM-5 | 响应分别返回命中与缺失技能,缺失列表保持 TOP10 顺序,两者并集等于实际 TOP10。 | B2、B3 |
| REQ-LP-1 | 学习路径只包含当前 TOP10 的未命中技能,不包含已命中或 TOP10 之外的技能。 | B2 |
| REQ-LP-2 | 学习路径沿用词云顺序,优先级从 1 连续编号;空热度技能仍在有权重技能之后。 | B2 |
| REQ-LP-3 | 学习建议由编程语言、框架、工具、通用四类确定性模板生成;不调用 AI 或外部服务,不含课程链接、时长或价格。 | B2 |
| REQ-LP-4 | 每项只返回技能 ID、名称、分类、优先级和简化建议;无缺口时返回空列表和总数 0。 | B2、B3 |
需求覆盖结果:三份 spec 中全部 14 条 `MUST`/`SHALL` 行为均已映射,无未映射需求。
## Architecture and Interface Constraints
- 使用 ESCO 作为职位、通用技能和分类主关系;具体技术无可靠 ESCO 节点时,允许使用 O*NET `software_skills` 作为补充技能来源。
- O*NET Importance 作为 `heat`;项目维护中文名称、别名和细粒度分类。
- 数据模型固定为 `skill_industries`、`skill_occupations`、`skill_categories`、`skills`、`skill_aliases`、`occupation_skills`、`skill_source_versions` 七张表。
- `skill_aliases.normalized_alias` 全局唯一;`occupation_skills(occupation_id, skill_id)` 唯一;分类必须属于职位,职位必须属于行业。
- `occupation_skills` 必须保存 `importance_source_code`、`importance_element_id` 和 `importance_is_proxy`;非空 `importance` 必须可追溯到 O*NET 31.0 CSV。
- 三个接口共享同一个只读排名管道和技能投影;不得复制三套排序规则。
- 接口路径固定为:
- `GET /api/v1/skills/word-cloud`
- `GET /api/v1/skills/match`
- `GET /api/v1/skills/learning-path`
- 三个接口查询参数均为 `industryId`、`occupationId` 必填,`categoryId` 可选。
- 计算按请求实时完成,不持久化匹配或学习路径。
- 学生技能解析异常记录不含原始内容的告警,接口返回 200 和空技能匹配结果。
- 首版只使用代表性数据;迁移编号以当前仓库下一个可用版本为准。若合并前 V35/V36 已被其他变更占用,必须回退规划阶段刷新编号和契约。
## Execution Batches
### Batch B1: 数据与来源基础
- 覆盖任务:T1、T2、T3,T3 在第 1 次评审后执行来源与分类修复。
- 交付物:来源清单、`V35__skill_cloud_taxonomy.sql`、`V36__skill_cloud_seed.sql`、迁移测试。
- 完成定义:代表性数据包含三类行业和约定职位;所有入选技能有中文标准名;ESCO 职位来源、O*NET 补充技术、每个 Importance 的 SOC/Element ID 均可机器追溯;自动化测试通过版本化证据快照全量核对 O*NET 来源和数值。
- 依赖:无。
- 评审门:B1 完成并由 `code-reviewer` 记录 `pass` 后才能开始 B2。
### Batch B2: 排名、匹配与学习路径
- 覆盖任务:T4、T5、T6、T7。
- 交付物:实体/Repository、共享词云查询排名服务、别名归一化、个人匹配服务、学习路径模板服务及单元测试。
- 完成定义:REQ-SC-2 至 REQ-SC-5、REQ-SM-1 至 REQ-SM-5、REQ-LP-1 至 REQ-LP-4 的领域行为可重复且确定。
- 依赖:B1 通过评审。
- 评审门:B2 完成并由 `code-reviewer` 记录 `pass` 后才能开始 B3。
### Batch B3: 接口、权限、契约与回归
- 覆盖任务:T8、T9、T10。
- 交付物:三个 Controller、权限配置、MockMvc/H2 集成测试、`docs/skill-cloud-api.md`、OpenAPI 和前端联调文档更新。
- 完成定义:REQ-SC-1 至 REQ-SC-5、REQ-SM-5、REQ-LP-4 的接口行为通过;匿名 401、非 STUDENT 403、非法参数 400;文档与运行时一致。
- 依赖:B2 通过评审。
- 评审门:B3 完成并由 `code-reviewer` 记录 `pass` 后才能进入验证和收尾。
## Test Obligations
| ID | 测试义务 | 批次 | 证据 |
| --- | --- | --- | --- |
| TO-1 | Flyway 创建七张表、外键、唯一约束和索引,中文名与来源 ID 完整。 | B1 | `mvn -Dtest=SkillTaxonomyMigrationTest test` |
| TO-2 | 行业/职位/分类关系校验,非法组合返回 400。 | B2、B3 | Repository/Service 单测与 MockMvc |
| TO-3 | 热度降序、空热度末位、同热度稳定排序、空范围列表。 | B2 | `mvn -Dtest=SkillCloudQueryServiceTest test` |
| TO-4 | TOP10 少于十条时使用实际数量,匹配分母为实际数量,`1/3` 四舍五入为 33。 | B2 | `mvn -Dtest=SkillMatchServiceTest test` |
| TO-5 | 大小写、空格、连字符、句点、`C++`、`C#` 和重复别名匹配。 | B2 | 归一化与匹配单测 |
| TO-6 | 无技能、无资料、损坏 JSON、全部命中和全部未命中场景。 | B2、B3 | Service 与 MockMvc 测试 |
| TO-7 | 学习路径只含未命中技能,优先级连续,无缺口返回空列表。 | B2 | `mvn -Dtest=SkillLearningPathServiceTest test` |
| TO-8 | 四类建议模板稳定、行业无关,不含链接、时长、价格或 AI 内容。 | B2 | 模板单测 |
| TO-9 | 三个接口权限、响应结构和参数错误符合契约。 | B3 | `mvn -Dtest=SkillCloudControllerTest test` |
| TO-10 | 三个接口复用同一 TOP10,响应不含原始 `skills_json`。 | B3 | H2 集成测试 |
| TO-11 | 全量回归、打包和静态 OpenAPI 契约通过。 | B3 | `mvn test`、`mvn clean verify`、`python docs\verify_openapi.py` |\n| TO-12 | 测试读取 O*NET 31.0 证据快照,全量核对 47 条 O*NET 技术来源及全部非空 Importance;任一来源不存在或数值被篡改都必须失败。 | B1 | `mvn -Dtest=SkillCloudSeedDataIntegrationTest test` |
## Build Rules
- 每个 Batch 按 TDD 先写失败测试,再实现最小行为,不得跳过测试。
- 只修改范围内文件;发现需要修改已验收接口或历史迁移时立即停止并触发 Rewind。
- 迁移和种子数据必须可在 H2(`MODE=MySQL`)与真实 MySQL 执行,不依赖外部文件路径。
- 所有排序、匹配和建议必须确定性;禁止随机、当前时间或请求次数影响结果。
- 任何新增日志不得包含个人技能 JSON、Token、手机号、邮箱或简历文本。
- OpenAPI 注解、示例和实际响应必须一致,不能只改文档不改运行时行为。
## Review Gates
- **DP-3**:本契约必须由用户明确批准并记录后,才能开始任何实现。
- **Batch Review**:B1、B2、B3 每个 Batch 完成后由 `code-reviewer` 按规范符合性和代码质量评审,记录 `pass` 或 `fail`。
- **DP-4**:批准契约后由 `build-executor` 给出执行模式与 wave 计划,用户确认后开始执行。
- **Verification Gate**:实现完成后执行全量测试、OpenAPI 校验和必要的 MySQL/Docker 黑盒验证。
- **DP-7**:由 `release-archivist` 完成最终验证与关闭,不在本契约内自动完成。
## Rewind Conditions
出现以下任一情况必须停止执行并回到规划阶段刷新工件和本契约:
- 需要新增、删除或改变用户可见接口、筛选参数、权限或响应语义。
- TOP10、匹配度分母、排序规则或空数据处理发生改变。
- 需要引入 AI、课程平台、实时岗位热度或后台维护接口。
- 需要导入全量词库或扩大首版行业/职位范围。
- 需要新增除 ESCO、O*NET 之外的其他技能来源。
- V35/V36 被其他变更占用,或历史迁移需要修改。
- ESCO/O*NET 许可、中文映射策略或数据来源发生实质变化。
- 发现某个已批准 `MUST`/`SHALL` 无法映射到测试或执行批次。
## DP-3 Approval Gate
执行者确认:本契约完整覆盖已批准的 14 条必需行为,无未映射需求,B1→B2→B3 依赖清晰,所有测试义务均有证据命令。
**批准方式**:用户明确回复批准本执行契约后,记录 `dp_3_result`、`dp_3_timestamp`,再执行 `specifying → bridging → approved-for-build` 状态转换。DP-3 记录前禁止进入 `build-executor`。

59
changes/n03-skill-cloud/proposal.md

@ -0,0 +1,59 @@
# N03 行业技能词云与学习路径接口
## Why
当前系统已经具备学生资料、技能标签、岗位匹配和求职进度能力,但学生无法从行业与职位的视角看到“市场在要求哪些技能”,也无法知道自己相对热门技能 TOP10 的掌握程度,更无法直接得到按优先级排列的学习补缺清单。仓库截至 V34 没有技能词云、行业技能热度或个人学习路径实现,现有 `student_profiles.skills_json` 与 `jobs.requirements` 也尚未被统一为可查询的技能分类体系。
本变更通过引入 ESCO + O*NET 的开源知识底座,为“行业 → 职位 → 技能分类 → 技能”建立可版本化数据,并为已登录学生提供三个确定性接口:查看词云与 TOP10、计算个人技能匹配度、生成缺失技能学习路径。
## What Changes
- 新增 Flyway 词库表,保存行业、职位、技能分类、技能、别名、职位技能关系、O*NET Importance 热度及数据来源版本。
- 首版导入互联网、金融、教育行业及代表性职位;中文名称、别名和细粒度技能分类由项目维护映射。
- 新增 `GET /api/v1/skills/word-cloud`:按行业、职位、可选技能分类返回全部技能,按热度降序排列。
- 新增 `GET /api/v1/skills/match`:用当前学生技能命中当前筛选结果 TOP10,返回匹配度、命中技能和缺失技能。
- 新增 `GET /api/v1/skills/learning-path`:对缺失 TOP10 技能按热度生成优先级和通用学习建议。
- 三个接口仅允许已登录 STUDENT 访问,统一使用现有 JWT、角色校验和 `ApiResponse` 规范。
- 更新 OpenAPI 契约、前端联调文档和词库来源说明。
## Scope
### In Scope
- ESCO 与 O*NET 的代表性数据导入、中文映射、来源版本和许可归属。
- 行业、职位、技能分类、技能、别名、热度及职位技能关系查询。
- 当前筛选范围内的完整技能词云,以及可由前端直接截取的 TOP10。
- 个人 `skills_json` 与 TOP10 的别名归一化匹配。
- 匹配度按 `命中数 / min(10, 实际技能数)` 计算,实际技能数为 0 时返回 0。
- 缺失技能按同一热度排序生成优先级 1、2、3…。
- 按技能分类生成通用学习建议,不依赖 AI 或外部服务。
- 单元测试、MockMvc 测试、H2 持久化测试和 OpenAPI 静态校验。
### Out of Scope
- 平台岗位 `jobId`、岗位发布频率或实时文本抽取参与热度计算。
- ESCO/O*NET 全量导入、自动翻译全量技能名称、在线同步开源词库。
- 后台词库维护页面、运营导入工具或运行时更新接口。
- AI 生成课程大纲、课程链接、讲师、学习时长或培训平台对接。
- 保存个人匹配历史、学习进度、完成状态或提醒任务。
- 企业和管理员查看学生个人匹配结果。
- 前端页面、交互设计和可视化实现。
## Impact
- `mydreamjob-server/src/main/resources/db/migration`:新增词库结构迁移和代表性种子数据。
- `mydreamjob-server/src/main/java/com/mydreamjob`:新增技能词云领域包、实体、Repository、Service、DTO 和 Controller,并调整安全配置。
- `mydreamjob-server/src/test/java/com/mydreamjob`:新增查询排序、匹配、学习路径和权限场景测试。
- `docs/openapi-rc.json`、`docs/frontend-integration.md`:登记三个接口和响应示例。
- 新增词库来源与版本说明文档,保留 ESCO/O*NET 的许可和数据版本。
- 不修改已有岗位匹配、候选人评分、求职进度等已验收接口语义。
## Proof of Completion
- Flyway 在 MySQL 模式测试库成功创建词库表并导入代表性数据。
- 三个接口返回统一 `ApiResponse`,匿名访问为 401,非 STUDENT 为 403。
- 词云结果按 O*NET Importance 降序;无权重技能保留且排在有权重技能之后;同权重排序稳定。
- TOP10 与词云使用同一排序结果,匹配度和学习路径复用该结果。
- 匹配度覆盖 0、少于 10 项、恰好 10 项、全部命中和部分命中场景。
- 学习路径优先级连续,且只包含未命中的 TOP10 技能;无缺口时返回空列表。
- `mvn test`、`mvn clean verify` 和 `python docs/verify_openapi.py` 全部通过。

84
changes/n03-skill-cloud/specs/learning-path/spec.md

@ -0,0 +1,84 @@
# Learning Path Specification
## ADDED Requirements
### Requirement: Learning path consumes unmatched TOP10 skills
学习路径接口 MUST 复用个人技能匹配结果,并只包含当前 TOP10 中未命中的技能;不得包含已命中技能或 TOP10 之外的技能。
#### Scenario: Student has skill gaps
- **WHEN** 当前 TOP10 中部分技能未命中
- **THEN** 学习路径 MUST 包含全部未命中技能且不含已命中技能
#### Scenario: Student has no skill gaps
- **WHEN** 当前 TOP10 全部命中
- **THEN** 学习路径 MUST 返回空列表和总数 0
#### Scenario: Student has no matching skill evidence
- **WHEN** 学生没有技能数据
- **THEN** 学习路径 MUST 包含当前 TOP10 的全部技能
### Requirement: Learning priority follows heat order
学习路径项 MUST 按与 TOP10 相同的热度顺序排列,并从 1 开始连续编号;空热度技能继续遵守词云的末位排序规则。
#### Scenario: Multiple missing skills are ordered
- **WHEN** 缺失技能包含多个热度不同的技能
- **THEN** 热度最高的技能 MUST 获得优先级 1,后续技能依次编号
#### Scenario: Missing skills have equal heat
- **WHEN** 缺失技能热度相同
- **THEN** 优先级 MUST 沿用词云的稳定次级排序,重复请求结果一致
#### Scenario: Only one skill is missing
- **WHEN** 当前 TOP10 只有一项未命中
- **THEN** 该技能 MUST 返回优先级 1
### Requirement: Learning suggestions are deterministic and industry-neutral
每项学习建议 MUST 由后端按技能分类的通用模板生成,不调用 AI、知识库问答或外部课程服务;建议不得包含课程链接、讲师、价格、学习时长或完成度。
#### Scenario: Programming language gap is returned
- **WHEN** 缺失技能属于编程语言分类
- **THEN** 建议 MUST 描述基础语法学习和编码练习方向
#### Scenario: Framework gap is returned
- **WHEN** 缺失技能属于框架分类
- **THEN** 建议 MUST 描述核心概念学习和小型项目实践方向
#### Scenario: Tool gap is returned
- **WHEN** 缺失技能属于工具分类
- **THEN** 建议 MUST 描述安装配置和工程实践方向
#### Scenario: Unknown category is returned
- **WHEN** 缺失技能无法匹配特定模板分类
- **THEN** 系统 MUST 返回一条行业无关的通用学习建议
### Requirement: Learning path response is actionable and minimal
每个学习路径项 MUST 返回技能 ID、技能名称、技能分类、优先级和简化学习建议;响应 MUST 不包含个人隐私、简历原文或模型推理内容。
#### Scenario: A learning item is generated
- **WHEN** 存在未命中技能
- **THEN** 每个路径项 MUST 同时具备技能身份、分类、连续优先级和建议文本
#### Scenario: Repeated request is made
- **WHEN** 学生资料、词库数据和筛选条件均未变化
- **THEN** 接口 MUST 返回相同顺序、相同优先级和相同建议
#### Scenario: Response contains no private profile data
- **WHEN** 学习路径成功返回
- **THEN** 响应 MUST 不含手机号、邮箱、简历文件、原始 `skills_json` 或其他无关个人数据

98
changes/n03-skill-cloud/specs/skill-cloud/spec.md

@ -0,0 +1,98 @@
# Skill Cloud Specification
## ADDED Requirements
### Requirement: STUDENT-only skill cloud access
系统 MUST 只允许已登录 STUDENT 调用技能词云接口,并从 JWT 解析当前用户,不接受客户端传入学生身份参数。
#### Scenario: Anonymous request is rejected
- **WHEN** 未携带有效 JWT 请求 `GET /api/v1/skills/word-cloud`
- **THEN** 系统 MUST 返回 401,且不得返回任何词库数据
#### Scenario: Company or administrator request is rejected
- **WHEN** COMPANY 或 ADMIN 用户携带有效 JWT 请求技能词云
- **THEN** 系统 MUST 返回 403,且不得返回任何词库数据
#### Scenario: Student request is accepted
- **WHEN** STUDENT 用户携带有效 JWT 请求技能词云
- **THEN** 系统 MUST 按筛选条件返回词库数据
### Requirement: Skill cloud filter validation
接口 MUST 要求 `industryId` 和 `occupationId` 为必填正整数,`categoryId` 为可选正整数;行业与职位必须存在且互为关联,技能分类存在时必须属于该职位。
#### Scenario: Required filters are missing
- **WHEN** 请求未传 `industryId` 或 `occupationId`
- **THEN** 系统 MUST 返回 400,并说明缺失的筛选参数
#### Scenario: Occupation does not belong to industry
- **WHEN** 请求传入存在但不属于目标行业的职位
- **THEN** 系统 MUST 返回 400,且不得返回技能数据
#### Scenario: Category does not belong to occupation
- **WHEN** 请求传入存在但不属于目标职位的技能分类
- **THEN** 系统 MUST 返回 400,且不得返回技能数据
#### Scenario: Category is omitted
- **WHEN** 请求只传行业和职位
- **THEN** 系统 MUST 返回该职位下所有技能分类的技能
### Requirement: Complete ranked skill cloud
系统 MUST 返回当前筛选范围内全部技能,并按 `heat` 降序排列;`heat` 为空时排在非空值之后。同 `heat` 或同为空值时,系统 MUST 使用稳定的中文技能名称和技能 ID 作为次级排序条件。
#### Scenario: Skills are ordered by heat
- **WHEN** 筛选结果存在多个带 O*NET Importance 的技能
- **THEN** 返回列表 MUST 按数值从高到低排列
#### Scenario: Skills without heat are retained
- **WHEN** 筛选结果包含无 O*NET Importance 的 ESCO 技能
- **THEN** 这些技能 MUST 保留在列表中,`heat` 为 null,并排在所有非 null 热度技能之后
#### Scenario: Equal heat has deterministic order
- **WHEN** 两个技能具有相同 `heat`
- **THEN** 系统 MUST 按稳定次级规则返回一致顺序,重复请求不得随机变化
#### Scenario: Empty scope returns empty list
- **WHEN** 合法筛选范围内没有技能
- **THEN** 系统 MUST 返回成功响应、空技能列表和总数 0
### Requirement: TOP10 is derived from the skill cloud
系统 MUST NOT 为 TOP10 维护独立排序或独立数据源;前端必须能够直接使用词云返回列表的前十条得到 TOP10。
#### Scenario: Scope has at least ten skills
- **WHEN** 词云结果包含至少十条技能
- **THEN** 前十条 MUST 与同一筛选条件下的热门技能 TOP10 一致
#### Scenario: Scope has fewer than ten skills
- **WHEN** 词云结果少于十条技能
- **THEN** 全部结果 MUST 构成实际 TOP10 集合
### Requirement: Skill cloud response exposes source-linked metadata
每个技能项 MUST 至少返回技能 ID、中文名称、技能分类 ID、技能分类名称、`heat` 和稳定排名。技能仍可通过 ID 追溯至 ESCO/O*NET 来源。
#### Scenario: Skill item is returned
- **WHEN** 查询返回任一技能
- **THEN** 响应 MUST 包含该技能的身份、分类、热度和排名字段
#### Scenario: Response is wrapped consistently
- **WHEN** 请求成功
- **THEN** 响应 MUST 使用项目统一 `ApiResponse` 结构,并在 `data` 中返回筛选条件和技能列表

98
changes/n03-skill-cloud/specs/skill-match/spec.md

@ -0,0 +1,98 @@
# Skill Match Specification
## ADDED Requirements
### Requirement: Personal matching uses the current ranked TOP10
个人技能匹配接口 MUST 复用技能词云相同的筛选参数、数据范围和热度排序;匹配集合 MUST 是当前筛选结果的前 `min(10, 实际技能数)` 项。
#### Scenario: Filtered result has at least ten skills
- **WHEN** 当前筛选返回十条或更多技能
- **THEN** 匹配集合 MUST 恰好包含热度最高的十条技能
#### Scenario: Filtered result has fewer than ten skills
- **WHEN** 当前筛选返回六条技能
- **THEN** 匹配集合 MUST 包含全部六条技能,分母 MUST 为 6
#### Scenario: Filtered result is empty
- **WHEN** 当前合法筛选范围内没有技能
- **THEN** 匹配度 MUST 返回 0,命中和缺失列表 MUST 均为空
### Requirement: Student skills are read from the authenticated profile
系统 MUST 从当前登录 STUDENT 的 `student_profiles.skills_json` 读取技能;不得接受客户端另行提交的技能列表作为个人匹配依据。
#### Scenario: Student profile has valid skills
- **WHEN** 当前学生存在合法技能数组
- **THEN** 系统 MUST 使用该数组计算匹配结果
#### Scenario: Student profile is absent or has no skills
- **WHEN** 当前学生没有资料记录、技能字段为空或技能数组为空
- **THEN** 匹配度 MUST 返回 0,命中列表为空,缺失列表为当前 TOP10
#### Scenario: Skills JSON is malformed
- **WHEN** 当前学生技能字段无法解析为字符串数组
- **THEN** 系统 MUST 按无技能处理,不得导致 500 响应或泄露原始字段内容
### Requirement: Skill matching normalizes aliases
系统 MUST 使用技能标准和别名进行匹配,归一化至少覆盖首尾空白、大小写、连续空格和常见标点差异;每个标准技能命中一次即可。
#### Scenario: Alias matches canonical skill
- **WHEN** 学生技能为 `SpringBoot`,TOP10 标准技能为 `Spring Boot` 且存在对应别名
- **THEN** 系统 MUST 将该技能计为命中
#### Scenario: Case and spacing differ
- **WHEN** 学生技能与标准技能的字母大小写或空格数量不同
- **THEN** 系统 MUST 在归一化后判断为同一技能
#### Scenario: Duplicate student skills exist
- **WHEN** 学生技能数组包含多个指向同一标准技能的别名
- **THEN** 系统 MUST 只计一次命中
### Requirement: Match score uses the actual TOP10 size
匹配度 MUST 使用公式 `round(命中数 × 100 / min(10, 实际技能数))` 计算,并返回 0 到 100 的整数。实际技能数为 0 时,匹配度 MUST 为 0。
#### Scenario: All skills match
- **WHEN** 当前 TOP10 有 8 项,学生命中全部 8 项
- **THEN** 匹配度 MUST 返回 100
#### Scenario: Partial match is rounded
- **WHEN** 当前 TOP10 有 3 项,学生命中 1 项
- **THEN** 匹配度 MUST 返回 33
#### Scenario: No skill matches
- **WHEN** 当前 TOP10 非空且学生没有命中任何技能
- **THEN** 匹配度 MUST 返回 0
### Requirement: Match response separates matched and missing skills
响应 MUST 返回匹配度、命中数量、实际匹配集合大小、命中技能列表和缺失技能列表;缺失技能 MUST 保持 TOP10 的热度排序。
#### Scenario: Some skills are matched
- **WHEN** 学生命中 TOP10 的部分技能
- **THEN** 命中列表和缺失列表 MUST 分别返回对应技能,且并集等于 TOP10
#### Scenario: All skills are matched
- **WHEN** 学生命中 TOP10 的全部技能
- **THEN** 缺失技能列表 MUST 为空,匹配度 MUST 为 100
#### Scenario: No skills are matched
- **WHEN** 学生没有命中 TOP10 的任何技能
- **THEN** 命中技能列表 MUST 为空,缺失列表 MUST 等于 TOP10

38
changes/n03-skill-cloud/tasks.md

@ -0,0 +1,38 @@
# 实施任务
## 交付与证明映射
| 需求域 | 规划来源 | 实现任务 | 主要证明 |
| --- | --- | --- | --- |
| 词库来源与覆盖 | proposal、design | T1、T2、T3 | 来源清单、中文名检查、Flyway 执行成功 |
| 词云筛选与排序 | skill-cloud spec | T4、T5 | Repository/Service 单测、MockMvc 场景 |
| TOP10 派生与一致性 | skill-cloud、skill-match spec | T5、T7、T8 | 集成测试断言同一排序结果 |
| 个人技能匹配 | skill-match spec | T6、T8 | 别名、分母、空数据和部分命中单测 |
| 学习路径与建议 | learning-path spec | T7、T8 | 优先级连续、模板建议和空列表测试 |
| 权限与隐私 | 三份 spec、design | T8、T10 | 401/403、响应脱敏和日志检查 |
| 文档与契约 | proposal、design | T1、T10 | `docs/verify_openapi.py`、接口文档审查 |
## 任务
- [ ] 1. 固化 ESCO + O*NET 代表性数据清单:在 `changes/n03-skill-cloud/data/source-manifest.md` 和 `docs/skill-taxonomy-sources.md` 记录行业、职位、技能范围、源版本、许可、归属 URL、中文映射负责人和导入规则。产出:互联网、金融、教育及代表性职位的可审查数据边界。依赖:无。证据:人工审查清单,所有入选技能均有稳定来源 ID 和中文标准名。
- [ ] 2. 新增 Flyway 词库结构迁移:创建 `mydreamjob-server/src/main/resources/db/migration/V35__skill_cloud_taxonomy.sql`,实现七张词库表、外键、唯一约束、查询索引,以及 `occupation_skills.importance_source_code`、`importance_element_id`、`importance_is_proxy` 来源追溯字段,不修改 V1–V34。产出:H2/MySQL 均可创建词库结构。依赖:T1。证据:`mvn -Dtest=SkillTaxonomyMigrationTest test`。
- [ ] 3. 导入代表性种子数据:创建 `mydreamjob-server/src/main/resources/db/migration/V36__skill_cloud_seed.sql`,导入三类行业、代表性职位、全部已声明技能分类、ESCO 主来源技能、O*NET `software_skills` 补充技术、别名和 O*NET Importance;每条非空热度必须保存真实 SOC、Element ID 和代理标记,无映射时 `importance` 留空。产出:种子数据可重复执行迁移并满足中文名约束。依赖:T2。证据:迁移测试断言数量、中文名、分类覆盖、ESCO 职位来源、47 条 O*NET 技术来源和全部非空 Importance 与版本化证据快照一致。
- [ ] 4. 建立词库领域与查询仓储:在 `mydreamjob-server/src/main/java/com/mydreamjob/skill/` 新增实体、Repository 和查询投影,覆盖行业—职位—分类校验及技能关系查询。产出:能按 `industryId`、`occupationId`、可选 `categoryId` 读取技能。依赖:T2、T3。证据:`mvn -Dtest=SkillTaxonomyRepositoryTest test`。
- [ ] 5. 实现共享词云排名服务与 DTO:在 `mydreamjob-server/src/main/java/com/mydreamjob/skill/` 实现统一筛选、`heat DESC, nameZh ASC, skillId ASC` 排序和完整列表输出;返回 `rank`、技能身份、分类、`heat`、来源字段。产出:词云和 TOP10 使用同一个事实来源。依赖:T4。证据:`mvn -Dtest=SkillCloudQueryServiceTest test`,覆盖空热度、同热度、空范围和分类缺省。
- [ ] 6. 实现个人技能匹配服务:新增 `SkillMatchService`、别名归一化器和匹配 DTO,只从 JWT 对应学生的 `skills_json` 读取技能;按 `min(10, actualSize)` 截取并计算整数匹配度。产出:命中、缺失、分母和异常 JSON 行为符合规范。依赖:T5。证据:`mvn -Dtest=SkillMatchServiceTest test`,覆盖 0、1/3=33、全命中、少技能和重复别名。
- [ ] 7. 实现学习路径生成服务:新增 `SkillLearningPathService` 和分类模板,按缺失技能顺序生成连续优先级与通用建议;不接 AI 或外部资源。产出:无缺口返回空列表,有缺口返回最小学路径。依赖:T6。证据:`mvn -Dtest=SkillLearningPathServiceTest test`。
- [ ] 8. 暴露三个 STUDENT 接口并登记权限:新增 `SkillCloudController`,实现 `GET /skills/word-cloud`、`GET /skills/match`、`GET /skills/learning-path`,在 `SecurityConfig` 中加入 `/skills/**` 的登录与 STUDENT 校验,并补充 OpenAPI 注解。产出:三个 API 只接受 STUDENT,响应统一为 `ApiResponse`。依赖:T5、T6、T7。证据:`mvn -Dtest=SkillCloudControllerTest test`,覆盖匿名 401、COMPANY/ADMIN 403、参数非法 400 和学生成功 200。
- [ ] 9. 补齐持久化与端到端回归测试:在 `mydreamjob-server/src/test/java/com/mydreamjob/skill/` 增加 H2 持久化、排序一致性、匹配与学习路径复用同一 TOP10 的集成测试,并验证响应不含原始 `skills_json`。产出:三个接口的边界与一致性有自动化证明。依赖:T3、T8。证据:`mvn test`。
- [ ] 10. 更新接口与来源文档并执行发布级验证:新增 `docs/skill-cloud-api.md`,更新 `docs/frontend-integration.md`、`docs/openapi-rc.json` 和词库来源说明,明确 TOP10 由前端截取、参数规则、响应字段、无 AI 和许可归属。依赖:T8、T9。产出:静态契约、运行时行为和文档一致。证据:`python docs\verify_openapi.py`、`mvn clean verify`。
## 实施顺序
T1 → T2 → T3 → T4 → T5 → T6 → T7 → T8 → T9 → T10。
## 退出条件
- 所有任务完成且证据命令通过。
- 三份 spec 的场景全部有对应自动化或明确人工契约验证。
- 词库来源版本和许可可追溯,未修改历史迁移。
- 未实现非目标范围;没有课程、AI、后台维护或个人进度持久化。
- OpenAPI 静态契约与运行时接口一致,且 STUDENT 权限边界可验证。

19
docs/frontend-integration.md

@ -715,3 +715,22 @@ GET /profile/me/career-progress?page=0&size=20&status=INTERVIEW # 仅 STUDENT
- **脱敏**:不返回 `studentUserId/candidateId/companyUserId/evaluatorUserId/phone/email/resumeUrl/passwordHash/JWT`、通知标题/正文、评分 `comment`、Offer 薪资/条款、他人任何身份字段。**本接口只返回通知计数,通知详情仍用 `/notifications`**。 - **脱敏**:不返回 `studentUserId/candidateId/companyUserId/evaluatorUserId/phone/email/resumeUrl/passwordHash/JWT`、通知标题/正文、评分 `comment`、Offer 薪资/条款、他人任何身份字段。**本接口只返回通知计数,通知详情仍用 `/notifications`**。
- **只读**:`GET`,不写任何业务表;`size` 上限 50,聚合查询次数与单页条数无关(无 N+1)。 - **只读**:`GET`,不写任何业务表;`size` 上限 50,聚合查询次数与单页条数无关(无 N+1)。
- API 详见 [docs/career-progress-api.md](career-progress-api.md)。 - API 详见 [docs/career-progress-api.md](career-progress-api.md)。
## 21. 行业技能词云、个人匹配度与学习路径联调
三个接口共用行业、职位和技能分类筛选,仅 STUDENT 可调用,并统一使用 Bearer JWT。完整字段、错误示例和排序语义详见 [skill-cloud-api.md](skill-cloud-api.md)。
| 功能 | Method | Path | 关键返回/交互 |
|---|---|---|---|
| 行业技能词云 | `GET` | `/skills/word-cloud` | `data.items` 为完整排序结果;前端直接取前 10 条作为 TOP10。 |
| 个人技能匹配度 | `GET` | `/skills/match` | 服务端只读取 JWT 学生资料;返回匹配度、命中技能和缺失技能。 |
| 学习路径 | `GET` | `/skills/learning-path` | 只返回缺失技能、连续优先级和通用学习建议。 |
共同 query 参数:`industryId`、`occupationId` 必填正整数;`categoryId` 可选正整数。非法关系返回 `400`,未登录 `401`,非 STUDENT `403`。
联调注意:
- `heat=null` 的技能仍会返回,且必须排在所有非空 `heat` 之后;不要在前端丢弃这些技能或自行改变顺序。
- TOP10 不是独立接口字段;统一使用 `data.items.slice(0, min(10, data.total))`。
- 匹配度公式为 `round(matchedCount * 100 / min(10, total))`;范围 15 条、命中 5 条时为 `33`。
- 学习路径不返回课程链接、AI 文本、学习时长或进度记录。

722
docs/openapi-rc.json

@ -6296,6 +6296,449 @@
"409": { "description": "重复决定 9604 / 非法流转 9605", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } } "409": { "description": "重复决定 9604 / 非法流转 9605", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } }
} }
} }
},
"/skills/word-cloud": {
"get": {
"tags": [
"行业技能词云"
],
"summary": "查询行业技能词云",
"description": "返回筛选范围内的全部技能,按 heat DESC, nameZh ASC, skillId ASC 排序,heat=null 最后;前端取前十条作为 TOP10。仅 STUDENT 可访问。",
"operationId": "getWordCloud",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "industryId",
"in": "query",
"required": true,
"description": "行业 ID,必填正整数",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"example": 1
}
},
{
"name": "occupationId",
"in": "query",
"required": true,
"description": "职位 ID,必填正整数;必须属于该行业",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"example": 2
}
},
{
"name": "categoryId",
"in": "query",
"required": false,
"description": "技能分类 ID,可选正整数;省略时返回该职位下全部分类",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"example": 101
}
}
],
"responses": {
"200": {
"description": "成功返回筛选范围内的完整技能列表",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseSkillCloudResponse"
},
"example": {
"code": 200,
"message": "ok",
"data": {
"industry": {
"id": 1,
"code": "INTERNET",
"name": "互联网"
},
"occupation": {
"id": 1,
"code": "FRONTEND_DEVELOPER",
"name": "前端开发工程师"
},
"category": null,
"total": 1,
"items": [
{
"rank": 1,
"skillId": 26,
"skillCode": "REACT",
"nameZh": "React",
"nameEn": "React",
"sourceType": "O*NET",
"sourceCode": "15-1254.00|React|2.E.7.h",
"categoryId": 103,
"categoryName": "前端框架",
"heat": 4.38,
"heatSourceCode": "15-1254.00",
"heatElementId": "2.E.7.h",
"heatProxy": true
}
]
},
"requestId": "req-word-cloud",
"timestamp": "2026-09-15T10:00: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-15T10:03: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-15T10:03:00Z"
}
}
}
}
}
}
},
"/skills/match": {
"get": {
"tags": [
"行业技能词云"
],
"summary": "查询个人技能匹配度",
"description": "使用 JWT 对应学生的 skills_json,与当前筛选结果 TOP10 比对;matchScore=round(matchedCount*100/min(10,total))。仅 STUDENT 可访问。",
"operationId": "getSkillMatch",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "industryId",
"in": "query",
"required": true,
"description": "行业 ID,必填正整数",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"example": 1
}
},
{
"name": "occupationId",
"in": "query",
"required": true,
"description": "职位 ID,必填正整数;必须属于该行业",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"example": 2
}
},
{
"name": "categoryId",
"in": "query",
"required": false,
"description": "技能分类 ID,可选正整数;省略时使用该职位下全部技能",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"example": 101
}
}
],
"responses": {
"200": {
"description": "成功返回个人技能匹配结果",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseSkillMatchResponse"
},
"example": {
"code": 200,
"message": "ok",
"data": {
"matchScore": 50,
"matchedCount": 1,
"totalCount": 2,
"matchedSkills": [
{
"rank": 1,
"skillId": 26,
"skillCode": "REACT",
"nameZh": "React",
"nameEn": "React",
"sourceType": "O*NET",
"sourceCode": "15-1254.00|React|2.E.7.h",
"categoryId": 103,
"categoryName": "前端框架",
"heat": 4.38,
"heatSourceCode": "15-1254.00",
"heatElementId": "2.E.7.h",
"heatProxy": true
}
],
"missingSkills": [
{
"rank": 2,
"skillId": 31,
"skillCode": "DOCKER",
"nameZh": "Docker",
"nameEn": "Docker",
"sourceType": "O*NET",
"sourceCode": "15-1299.08|Docker|2.E.7.h",
"categoryId": 105,
"categoryName": "工程化工具",
"heat": 4.12,
"heatSourceCode": "15-1299.08",
"heatElementId": "2.E.7.h",
"heatProxy": true
}
]
},
"requestId": "req-match",
"timestamp": "2026-09-15T10:01: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-15T10:03: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-15T10:03:00Z"
}
}
}
}
}
}
},
"/skills/learning-path": {
"get": {
"tags": [
"行业技能词云"
],
"summary": "生成个人学习路径",
"description": "只针对个人 TOP10 中未命中的技能,沿用热度顺序返回连续 priority 和确定性通用建议;不调用 AI,不返回课程链接、时长或进度。仅 STUDENT 可访问。",
"operationId": "getLearningPath",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"name": "industryId",
"in": "query",
"required": true,
"description": "行业 ID,必填正整数",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"example": 1
}
},
{
"name": "occupationId",
"in": "query",
"required": true,
"description": "职位 ID,必填正整数;必须属于该行业",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"example": 2
}
},
{
"name": "categoryId",
"in": "query",
"required": false,
"description": "技能分类 ID,可选正整数;省略时使用该职位下全部技能",
"schema": {
"type": "integer",
"format": "int64",
"minimum": 1,
"example": 101
}
}
],
"responses": {
"200": {
"description": "成功返回缺失技能的学习路径;没有缺口时列表为空",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseSkillLearningPathResponse"
},
"example": {
"code": 200,
"message": "ok",
"data": {
"total": 1,
"items": [
{
"priority": 1,
"skillId": 26,
"skillName": "React",
"categoryId": 103,
"categoryName": "前端框架",
"suggestion": "先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。"
}
]
},
"requestId": "req-learning-path",
"timestamp": "2026-09-15T10:02: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-15T10:03: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-15T10:03:00Z"
}
}
}
}
}
}
} }
}, },
"components": { "components": {
@ -9679,6 +10122,285 @@
"requestId": { "type": "string" }, "requestId": { "type": "string" },
"timestamp": { "type": "string", "format": "date-time" } "timestamp": { "type": "string", "format": "date-time" }
} }
},
"SkillCloudNode": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"code": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"SkillCloudItem": {
"type": "object",
"properties": {
"rank": {
"type": "integer",
"format": "int32",
"minimum": 1,
"description": "当前筛选范围内的稳定排名,从 1 开始。"
},
"skillId": {
"type": "integer",
"format": "int64"
},
"skillCode": {
"type": "string"
},
"nameZh": {
"type": "string"
},
"nameEn": {
"type": "string",
"nullable": true
},
"sourceType": {
"type": "string",
"enum": [
"ESCO",
"O*NET"
],
"description": "技能身份主来源。"
},
"sourceCode": {
"type": "string",
"description": "ESCO URI 或 O*NET 三元组标识。"
},
"categoryId": {
"type": "integer",
"format": "int64"
},
"categoryName": {
"type": "string"
},
"heat": {
"type": "number",
"nullable": true,
"description": "O*NET Importance;无映射为 null,且排在非空热度之后。"
},
"heatSourceCode": {
"type": "string",
"nullable": true,
"description": "热度来源 SOC。"
},
"heatElementId": {
"type": "string",
"nullable": true,
"description": "热度来源 O*NET Element ID。"
},
"heatProxy": {
"type": "boolean",
"description": "热度是否为代理映射。"
}
}
},
"SkillCloudResponse": {
"type": "object",
"properties": {
"industry": {
"$ref": "#/components/schemas/SkillCloudNode"
},
"occupation": {
"$ref": "#/components/schemas/SkillCloudNode"
},
"category": {
"$ref": "#/components/schemas/SkillCloudNode",
"nullable": true
},
"total": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SkillCloudItem"
}
}
}
},
"SkillMatchResponse": {
"type": "object",
"properties": {
"matchScore": {
"type": "integer",
"format": "int32",
"minimum": 0,
"maximum": 100,
"description": "round(matchedCount*100/min(10,total));total=0 时为 0。"
},
"matchedCount": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"totalCount": {
"type": "integer",
"format": "int32",
"minimum": 0,
"description": "实际匹配集合大小 min(10,total)。"
},
"matchedSkills": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SkillCloudItem"
}
},
"missingSkills": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SkillCloudItem"
}
}
}
},
"SkillLearningPathItem": {
"type": "object",
"properties": {
"priority": {
"type": "integer",
"format": "int32",
"minimum": 1
},
"skillId": {
"type": "integer",
"format": "int64"
},
"skillName": {
"type": "string"
},
"categoryId": {
"type": "integer",
"format": "int64"
},
"categoryName": {
"type": "string"
},
"suggestion": {
"type": "string",
"description": "按技能分类生成的通用建议,不含课程、AI、链接、时长或进度。"
}
}
},
"SkillLearningPathResponse": {
"type": "object",
"properties": {
"total": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SkillLearningPathItem"
}
}
}
},
"ApiResponseSkillCloudResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/SkillCloudResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"ApiResponseSkillMatchResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/SkillMatchResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"ApiResponseSkillLearningPathResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/SkillLearningPathResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"UnauthorizedResponse": {
"type": "object",
"additionalProperties": false,
"required": [
"code",
"message",
"timestamp"
],
"properties": {
"code": {
"type": "integer",
"format": "int32",
"enum": [
401
]
},
"message": {
"type": "string",
"enum": [
"not authenticated"
]
},
"timestamp": {
"type": "string",
"enum": [
""
]
}
}
} }
}, },
"securitySchemes": { "securitySchemes": {

275
docs/skill-cloud-api.md

@ -0,0 +1,275 @@
# 行业技能词云、个人匹配度与学习路径 API
> 版本:N03 设计锁定版
> 状态:已编码实现
> 适用范围:学生端只读接口。本文描述 `GET /api/v1/skills/word-cloud`、`GET /api/v1/skills/match`、`GET /api/v1/skills/learning-path`。
> Base URL:`http://127.0.0.1:8080/api/v1`(生产环境替换域名,路径不变)。
## 1. 权限与共同参数
三个接口均:
- 仅允许 `STUDENT` 访问。
- 必须携带 `Authorization: Bearer <accessToken>`,身份只从 JWT/SecurityContext 获取,不接受 `userId`、`studentId` 等身份注入参数。
- 成功、`400` 和 `403` 响应使用统一 `ApiResponse`;匿名或无效 JWT 的 `401` 由全局 Security 入口直接返回裸 JSON,不包含 `data` 和 `requestId`。
- 只读,不写入学习进度、学生资料或词库数据。
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `industryId` | long | 是 | 词库行业 ID,必须为正整数,且必须是真实存在的行业。 |
| `occupationId` | long | 是 | 词库职位 ID,必须为正整数,且必须属于该行业。 |
| `categoryId` | long | 否 | 技能分类 ID,必须为正整数;省略时使用该职位下全部技能分类。传入时必须属于目标职位。 |
任一必填参数缺失、非正整数,或行业—职位—分类关系非法时返回 `400`。如果未登录返回 `401`,非 STUDENT 用户返回 `403`。
## 2. 通用响应与排序
三个接口的 `data` 都使用 camelCase 字段。词云和 TOP10 使用同一份服务端排序结果:
1. `heat` 从高到低;`heat` 为 `null` 的技能排在所有非空热度之后。
2. 热度相同时按 `nameZh` 升序。
3. 其余情况按 `skillId` 升序,保证重复请求顺序稳定。
4. `rank` 从 1 连续递增。
`heat` 来自 O*NET Importance。无可靠 O*NET Importance 映射的 ESCO 技能仍返回,但 `heat` 为 `null`。TOP10 不在后端单独存储或单独排序;前端直接取词云 `data.items` 的前 `min(10, data.total)` 项。
技能身份来源:
- `sourceType`:`ESCO` 或 `O*NET`,表示技能条目的主来源。
- `sourceCode`:可追溯来源标识。ESCO 为技能 URI;O*NET 补充技能为 `SOC|Workplace Example|Element ID`。
- `heatSourceCode`:热度来源 SOC;没有 O*NET 热度时为 `null`。
- `heatElementId`:O*NET Importance 的来源 Element ID;没有时为 `null`。
- `heatProxy`:该条热度是否为代理映射。代理值不改变排序规则,但前端可据此展示来源说明。
## 3. 查询词云
```http
GET /api/v1/skills/word-cloud?industryId=1&occupationId=2&categoryId=101
Authorization: Bearer <accessToken>
```
`categoryId` 可省略:
```http
GET /api/v1/skills/word-cloud?industryId=1&occupationId=2
```
成功响应为 `ApiResponse<SkillCloudResponse>`。`SkillCloudResponse` 返回筛选节点、总数和当前范围内的完整技能列表:
```json
{
"code": 200,
"message": "ok",
"data": {
"industry": { "id": 1, "code": "INTERNET", "name": "互联网" },
"occupation": { "id": 1, "code": "FRONTEND_DEVELOPER", "name": "前端开发工程师" },
"category": { "id": 103, "code": "FRONTEND_FRAMEWORK", "name": "前端框架" },
"total": 1,
"items": [
{
"rank": 1,
"skillId": 26,
"skillCode": "REACT",
"nameZh": "React",
"nameEn": "React",
"sourceType": "O*NET",
"sourceCode": "15-1254.00|React|2.E.7.h",
"categoryId": 103,
"categoryName": "前端框架",
"heat": 4.38,
"heatSourceCode": "15-1254.00",
"heatElementId": "2.E.7.h",
"heatProxy": true
}
]
},
"requestId": "req-123",
"timestamp": "2026-09-15T10:00:00Z"
}
```
`category` 在未传 `categoryId` 时为 `null`。合法范围但暂时没有技能时返回成功、`total=0`、`items=[]`。前端根据 `items` 渲染词云,并截取前十条作为 TOP10。
## 4. 查询个人技能匹配度
```http
GET /api/v1/skills/match?industryId=1&occupationId=2&categoryId=101
Authorization: Bearer <accessToken>
```
匹配集合严格取当前筛选结果的 TOP10。系统从当前 JWT 学生的 `student_profiles.skills_json` 读取技能,使用标准技能名、英文名、技能代码和别名归一化匹配。大小为 `total` 的筛选范围使用 `min(10, total)` 作为分母:
```text
matchScore = round(matchedCount * 100 / min(10, total))
```
- `total=0` 时 `matchScore=0`,命中列表和缺失列表为空。
- 范围少于 10 项时按实际技能数计算。
- 重复技能或指向同一标准技能的多个别名只计一次。
- 示例:范围 15 条、命中 5 条,`matchScore=33`。
- 原始 `skills_json`、手机号、邮箱和简历内容不会出现在响应中。
成功响应为 `ApiResponse<SkillMatchResponse>`:
```json
{
"code": 200,
"message": "ok",
"data": {
"matchScore": 50,
"matchedCount": 1,
"totalCount": 2,
"matchedSkills": [
{
"rank": 1,
"skillId": 26,
"skillCode": "REACT",
"nameZh": "React",
"nameEn": "React",
"sourceType": "O*NET",
"sourceCode": "15-1254.00|React|2.E.7.h",
"categoryId": 103,
"categoryName": "前端框架",
"heat": 4.38,
"heatSourceCode": "15-1254.00",
"heatElementId": "2.E.7.h",
"heatProxy": true
}
],
"missingSkills": [
{
"rank": 2,
"skillId": 31,
"skillCode": "DOCKER",
"nameZh": "Docker",
"nameEn": "Docker",
"sourceType": "O*NET",
"sourceCode": "15-1299.08|Docker|2.E.7.h",
"categoryId": 105,
"categoryName": "工程化工具",
"heat": 4.12,
"heatSourceCode": "15-1299.08",
"heatElementId": "2.E.7.h",
"heatProxy": true
}
]
},
"requestId": "req-456",
"timestamp": "2026-09-15T10:01:00Z"
}
```
`matchedSkills` 与 `missingSkills` 的并集等于当前 TOP10,两个列表各自保持词云排序;`totalCount` 是实际匹配集合大小。
## 5. 生成学习路径
```http
GET /api/v1/skills/learning-path?industryId=1&occupationId=2&categoryId=101
Authorization: Bearer <accessToken>
```
学习路径只消费个人匹配结果中的 `missingSkills`:
- 只包含当前 TOP10 中未命中的技能,不包含已命中技能或 TOP10 之外的技能。
- 顺序与 `missingSkills` 完全一致,`priority` 从 1 连续递增。
- 每项只返回技能 ID、名称、分类、优先级和简化学习建议。
- 后端不调用 AI、课程平台或外部知识库;不会返回课程链接、讲师、价格、学习时长或完成度。
- 系统不持久化学习进度或完成状态。
- 全部命中或合法范围没有技能时返回 `total=0`、`items=[]`。
建议模板按技能分类确定性生成:
| 模板类别 | 建议方向 |
|---|---|
| 编程语言 | 基础语法、常见数据结构、至少一个编码练习。 |
| 框架 | 核心概念与生命周期、小型项目集成实践。 |
| 工具 | 安装与基础配置、结合工程完成实际操作。 |
| 通用 | 核心概念和常见应用场景、简短实践巩固。 |
```json
{
"code": 200,
"message": "ok",
"data": {
"total": 1,
"items": [
{
"priority": 1,
"skillId": 26,
"skillName": "React",
"categoryId": 103,
"categoryName": "前端框架",
"suggestion": "先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。"
}
]
},
"requestId": "req-789",
"timestamp": "2026-09-15T10:02:00Z"
}
```
## 6. 错误响应示例
`400` 和 `403` 使用统一 `ApiResponse`;`401` 由全局 Security 入口返回裸 JSON。以下示例与运行时契约一致。
```json
{
"code": 1001,
"message": "bad request",
"data": null,
"requestId": "req-400",
"timestamp": "2026-09-15T10:03:00Z"
}
```
| HTTP | 场景 | 典型 `code` |
|---|---|---|
| `400` | `industryId`/`occupationId` 缺失、非正整数,或节点关系非法 | `1001` |
| `401` | 未携带有效 JWT 或令牌无效/过期 | `401` |
| `403` | 已登录但不是 STUDENT | `2002` |
示例 401:
```json
{
"code": 401,
"message": "not authenticated",
"timestamp": ""
}
```
`401` 响应不包含 `data` 和 `requestId`。
示例 403:
```json
{
"code": 2002,
"message": "forbidden",
"data": null,
"requestId": "req-403",
"timestamp": "2026-09-15T10:05:00Z"
}
```
## 7. 字段参考
### SkillCloudNode
`id`、`code`、`name`。`category` 节点在未指定分类时为 `null`。
### SkillCloudItem
`rank`、`skillId`、`skillCode`、`nameZh`、`nameEn`、`sourceType`、`sourceCode`、`categoryId`、`categoryName`、`heat`、`heatSourceCode`、`heatElementId`、`heatProxy`。
### SkillMatchResponse
`matchScore`、`matchedCount`、`totalCount`、`matchedSkills`、`missingSkills`。
### SkillLearningPathItem / SkillLearningPathResponse
`SkillLearningPathItem`:`priority`、`skillId`、`skillName`、`categoryId`、`categoryName`、`suggestion`。
`SkillLearningPathResponse`:`total`、`items`。

79
docs/skill-taxonomy-sources.md

@ -0,0 +1,79 @@
# 行业技能词库来源与覆盖说明
本说明是仓库面向实现和评审的技能词库来源基线,内容与 `changes/n03-skill-cloud/data/source-manifest.md` 保持同一事实集合。
## 1. 固定来源
| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 |
| --- | --- | --- | --- | --- |
| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download<br>复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq |
| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html |
运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。
## 2. 首版覆盖边界
首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。
| 行业 | 代表性职位 | 首批技能分类范围 |
| --- | --- | --- |
| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 |
| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 |
| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 |
| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 |
| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 |
| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 |
| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps |
| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 |
| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 |
| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 |
计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。
矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。
## 3. 导入规则
1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。
2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。
3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。
4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。
5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。
6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。
7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。
8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。
9. **迁移不可改写**:T2/T3 计划使用 `V35__skill_cloud_taxonomy.sql` 和 `V36__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。
10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。
## 4. 来源版本、许可与归属 URL
| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL |
| --- | --- | --- | --- | --- |
| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download |
| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html |
ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.`
## 5. 映射责任边界
| 事项 | 上游来源责任 | 项目责任 |
| --- | --- | --- |
| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID |
| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 |
| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 |
| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 |
| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 |
| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 |
## 6. 验收检查项
- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。
- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。
- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。
- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。
- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。
- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。
- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。
- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。
- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。
- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。
- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。

68
docs/verify_openapi.py

@ -6,9 +6,10 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) print('paths:', len(d['paths']))
assert len(d['paths']) == 126, f"Expected 126 paths, got {len(d['paths'])}" assert len(d['paths']) == 129, f"Expected 129 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
required = [ required = [
'/review-weight-templates', '/review-weight-templates',
'/profile/me/review-weights', '/profile/me/review-weights',
@ -66,6 +67,9 @@ required = [
'/admin/reports', '/admin/reports',
'/admin/reports/{id}', '/admin/reports/{id}',
'/admin/reports/{id}/decision', '/admin/reports/{id}/decision',
'/skills/word-cloud',
'/skills/match',
'/skills/learning-path',
] ]
for path in required: for path in required:
assert path in paths, f"Missing path: {path}" assert path in paths, f"Missing path: {path}"
@ -135,6 +139,68 @@ assert 'get' in paths['/admin/reports'], "Missing GET /admin/reports"
assert 'get' in paths['/admin/reports/{id}'], "Missing GET /admin/reports/{id}" assert 'get' in paths['/admin/reports/{id}'], "Missing GET /admin/reports/{id}"
assert 'post' in paths['/admin/reports/{id}/decision'], "Missing POST /admin/reports/{id}/decision" assert 'post' in paths['/admin/reports/{id}/decision'], "Missing POST /admin/reports/{id}/decision"
print(" V33 report/block endpoints present (9 operations / 8 paths)") print(" V33 report/block endpoints present (9 operations / 8 paths)")
# Skill cloud endpoints (3 paths / 3 GET operations)
for skill_path in ('/skills/word-cloud', '/skills/match', '/skills/learning-path'):
assert 'get' in paths[skill_path], f"Missing GET {skill_path}"
skill_operation_ids = {
'/skills/word-cloud': 'getWordCloud',
'/skills/match': 'getSkillMatch',
'/skills/learning-path': 'getLearningPath',
}
for skill_path, skill_operation_id in skill_operation_ids.items():
assert paths[skill_path]['get'].get('operationId') == skill_operation_id
http_methods = {'get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'}
matching_operations = [
(method, path)
for path, path_item in paths.items()
for method, operation in path_item.items()
if method in http_methods
and operation.get('operationId') == skill_operation_id
]
assert matching_operations == [('get', skill_path)], (
f'{skill_operation_id} must not conflict: expected GET {skill_path}, got {matching_operations}'
)
assert paths['/skills/word-cloud']['get']['responses']['200']['content']['application/json']['schema']['$ref'] == '#/components/schemas/ApiResponseSkillCloudResponse'
assert paths['/skills/match']['get']['responses']['200']['content']['application/json']['schema']['$ref'] == '#/components/schemas/ApiResponseSkillMatchResponse'
assert paths['/skills/learning-path']['get']['responses']['200']['content']['application/json']['schema']['$ref'] == '#/components/schemas/ApiResponseSkillLearningPathResponse'
for skill_path in ('/skills/word-cloud', '/skills/match', '/skills/learning-path'):
unauthorized = paths[skill_path]['get']['responses']['401']
assert unauthorized['description'] == '未登录或令牌无效,由全局 Security 入口返回裸 JSON'
assert unauthorized['content']['application/json']['schema']['$ref'] == '#/components/schemas/UnauthorizedResponse'
example = unauthorized['content']['application/json']['example']
assert example == {'code': 401, 'message': 'not authenticated', 'timestamp': ''}
assert 'requestId' not in example
schemas = d['components']['schemas']
unauthorized_schema = schemas.get('UnauthorizedResponse')
assert unauthorized_schema is not None, 'Missing schema: UnauthorizedResponse'
assert unauthorized_schema.get('additionalProperties') is False
assert set(unauthorized_schema.get('required', [])) == {'code', 'message', 'timestamp'}
assert set(unauthorized_schema.get('properties', {})) == {'code', 'message', 'timestamp'}
skill_schema_fields = {
'SkillCloudNode': {'id', 'code', 'name'},
'SkillCloudItem': {'rank', 'skillId', 'skillCode', 'nameZh', 'nameEn', 'sourceType', 'sourceCode', 'categoryId', 'categoryName', 'heat', 'heatSourceCode', 'heatElementId', 'heatProxy'},
'SkillCloudResponse': {'industry', 'occupation', 'category', 'total', 'items'},
'SkillMatchResponse': {'matchScore', 'matchedCount', 'totalCount', 'matchedSkills', 'missingSkills'},
'SkillLearningPathItem': {'priority', 'skillId', 'skillName', 'categoryId', 'categoryName', 'suggestion'},
'SkillLearningPathResponse': {'total', 'items'},
}
for schema_name, expected_fields in skill_schema_fields.items():
schema = schemas.get(schema_name)
assert schema is not None, f'Missing schema: {schema_name}'
assert expected_fields <= set(schema.get('properties', {})), f'Schema fields missing: {schema_name}'
word_cloud_example = paths['/skills/word-cloud']['get']['responses']['200']['content']['application/json']['example']['data']
assert word_cloud_example['total'] == len(word_cloud_example['items'])
match_example = paths['/skills/match']['get']['responses']['200']['content']['application/json']['example']['data']
assert match_example['matchedCount'] == len(match_example['matchedSkills'])
assert match_example['totalCount'] == len(match_example['matchedSkills']) + len(match_example['missingSkills'])
assert match_example['matchScore'] == round(match_example['matchedCount'] * 100 / match_example['totalCount'])
learning_path_example = paths['/skills/learning-path']['get']['responses']['200']['content']['application/json']['example']['data']
assert learning_path_example['total'] == len(learning_path_example['items'])
print(" Skill cloud endpoints and schemas present (3 GET operations)")
print(" Offer advance endpoints all present") print(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") print(" Account deletion endpoints present (3 operations)")

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

@ -74,6 +74,8 @@ public class SecurityConfig {
.requestMatchers("/auth/register").permitAll() .requestMatchers("/auth/register").permitAll()
// 当前用户 // 当前用户
.requestMatchers("/auth/me").authenticated() .requestMatchers("/auth/me").authenticated()
// 行业技能词云(需登录,仅 STUDENT,Controller 层校验角色)
.requestMatchers(HttpMethod.GET, "/skills/**").authenticated()
// 企业资料(需登录) // 企业资料(需登录)
.requestMatchers("/companies/me/**").authenticated() .requestMatchers("/companies/me/**").authenticated()
// 企业公开岗位列表(匿名 GET) // 企业公开岗位列表(匿名 GET)

207
mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java

@ -0,0 +1,207 @@
package com.mydreamjob.skill;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.profile.ProfileService;
import com.mydreamjob.skill.dto.SkillCloudResponse;
import com.mydreamjob.skill.dto.SkillLearningPathResponse;
import com.mydreamjob.skill.dto.SkillMatchResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.Positive;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 行业技能词云、个人匹配度与学习路径接口。
*/
@Tag(name = "行业技能词云", description = "按行业、职位和技能分类查询热门技能、个人匹配度与学习路径")
@RestController
@Validated
@RequiredArgsConstructor
public class SkillCloudController {
private final SkillCloudQueryService skillCloudQueryService;
private final SkillMatchService skillMatchService;
private final SkillLearningPathService skillLearningPathService;
private final ProfileService profileService;
@Operation(
operationId = "getWordCloud",
summary = "查询行业技能词云",
description = "返回当前筛选范围内的全部技能,按热度降序排列;前端取前十条作为 TOP10。仅 STUDENT 可访问。"
)
@SecurityRequirement(name = "bearerAuth")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "成功返回筛选范围内的全部技能",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(
name = "success",
value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"industry\":{\"id\":1,\"code\":\"INTERNET\",\"name\":\"互联网\"},\"occupation\":{\"id\":1,\"code\":\"FRONTEND_DEVELOPER\",\"name\":\"前端开发工程师\"},\"category\":null,\"total\":1,\"items\":[{\"rank\":1,\"skillId\":26,\"skillCode\":\"REACT\",\"nameZh\":\"React\",\"nameEn\":\"React\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1254.00|React|2.E.7.h\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"heat\":4.38,\"heatSourceCode\":\"15-1254.00\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}]}}"
)
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401", description = "未登录",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403", description = "已登录但不是 STUDENT 角色",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
@GetMapping("/skills/word-cloud")
public ResponseEntity<ApiResponse<SkillCloudResponse>> getWordCloud(
@Parameter(description = "行业 ID,必填正整数", required = true, example = "1")
@Positive(message = "industryId must be positive")
@RequestParam Long industryId,
@Parameter(description = "职位 ID,必填正整数", required = true, example = "1")
@Positive(message = "occupationId must be positive")
@RequestParam Long occupationId,
@Parameter(description = "技能分类 ID,可选正整数;省略时返回职位下全部分类", required = false, example = "101")
@Positive(message = "categoryId must be positive")
@RequestParam(required = false) Long categoryId,
HttpServletRequest request) {
Long userId = resolveAuthenticatedStudent();
SkillCloudResponse data = skillCloudQueryService.query(industryId, occupationId, categoryId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(
operationId = "getSkillMatch",
summary = "查询个人技能匹配度",
description = "使用当前 STUDENT 的个人技能,与筛选结果热度 TOP10 比对并返回命中、缺失和匹配度。仅 STUDENT 可访问。"
)
@SecurityRequirement(name = "bearerAuth")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "成功返回个人技能匹配结果",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(
name = "success",
value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"matchScore\":50,\"matchedCount\":1,\"totalCount\":2,\"matchedSkills\":[{\"rank\":1,\"skillId\":26,\"skillCode\":\"REACT\",\"nameZh\":\"React\",\"nameEn\":\"React\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1254.00|React|2.E.7.h\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"heat\":4.38,\"heatSourceCode\":\"15-1254.00\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}],\"missingSkills\":[{\"rank\":2,\"skillId\":31,\"skillCode\":\"DOCKER\",\"nameZh\":\"Docker\",\"nameEn\":\"Docker\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1299.08|Docker|2.E.7.h\",\"categoryId\":105,\"categoryName\":\"工程化工具\",\"heat\":4.12,\"heatSourceCode\":\"15-1299.08\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}]}}"
)
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401", description = "未登录",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403", description = "已登录但不是 STUDENT 角色",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
@GetMapping("/skills/match")
public ResponseEntity<ApiResponse<SkillMatchResponse>> getMatch(
@Parameter(description = "行业 ID,必填正整数", required = true, example = "1")
@Positive(message = "industryId must be positive")
@RequestParam Long industryId,
@Parameter(description = "职位 ID,必填正整数", required = true, example = "1")
@Positive(message = "occupationId must be positive")
@RequestParam Long occupationId,
@Parameter(description = "技能分类 ID,可选正整数;省略时使用职位下全部技能", required = false, example = "101")
@Positive(message = "categoryId must be positive")
@RequestParam(required = false) Long categoryId,
HttpServletRequest request) {
Long userId = resolveAuthenticatedStudent();
SkillMatchResponse data = skillMatchService.match(userId, industryId, occupationId, categoryId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
@Operation(
operationId = "getLearningPath",
summary = "生成个人学习路径",
description = "仅针对个人 TOP10 中未命中的技能,按热度顺序返回优先级和通用学习建议。仅 STUDENT 可访问。"
)
@SecurityRequirement(name = "bearerAuth")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "成功返回缺失技能的学习路径;没有缺口时列表为空",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(
name = "success",
value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"total\":1,\"items\":[{\"priority\":1,\"skillId\":26,\"skillName\":\"React\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"suggestion\":\"先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。\"}]}}"
)
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401", description = "未登录",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403", description = "已登录但不是 STUDENT 角色",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
@GetMapping("/skills/learning-path")
public ResponseEntity<ApiResponse<SkillLearningPathResponse>> getLearningPath(
@Parameter(description = "行业 ID,必填正整数", required = true, example = "1")
@Positive(message = "industryId must be positive")
@RequestParam Long industryId,
@Parameter(description = "职位 ID,必填正整数", required = true, example = "1")
@Positive(message = "occupationId must be positive")
@RequestParam Long occupationId,
@Parameter(description = "技能分类 ID,可选正整数;省略时使用职位下全部技能", required = false, example = "101")
@Positive(message = "categoryId must be positive")
@RequestParam(required = false) Long categoryId,
HttpServletRequest request) {
Long userId = resolveAuthenticatedStudent();
SkillLearningPathResponse data = skillLearningPathService.generate(
userId, industryId, occupationId, categoryId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
private Long resolveAuthenticatedStudent() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null
|| !authentication.isAuthenticated()
|| "anonymousUser".equals(authentication.getName())) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
try {
Long userId = Long.valueOf(authentication.getName());
profileService.assertStudentRole(userId);
return userId;
} catch (NumberFormatException exception) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

160
mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudQueryService.java

@ -0,0 +1,160 @@
package com.mydreamjob.skill;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.skill.domain.SkillCategory;
import com.mydreamjob.skill.domain.SkillIndustry;
import com.mydreamjob.skill.domain.SkillOccupation;
import com.mydreamjob.skill.dto.SkillCloudItem;
import com.mydreamjob.skill.dto.SkillCloudNode;
import com.mydreamjob.skill.dto.SkillCloudResponse;
import com.mydreamjob.skill.dto.SkillCloudSkillProjection;
import com.mydreamjob.skill.repository.OccupationSkillRepository;
import com.mydreamjob.skill.repository.SkillCategoryRepository;
import com.mydreamjob.skill.repository.SkillIndustryRepository;
import com.mydreamjob.skill.repository.SkillOccupationRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
import java.util.stream.IntStream;
@Service
@Transactional(readOnly = true)
public class SkillCloudQueryService {
private static final Comparator<SkillCloudSkillProjection> SKILL_ORDER = (left, right) -> {
int heatComparison = compareHeat(left.importance(), right.importance());
if (heatComparison != 0) {
return heatComparison;
}
int nameComparison = compareNullable(left.nameZh(), right.nameZh());
if (nameComparison != 0) {
return nameComparison;
}
return compareNullable(left.skillId(), right.skillId());
};
private final SkillIndustryRepository industryRepository;
private final SkillOccupationRepository occupationRepository;
private final SkillCategoryRepository categoryRepository;
private final OccupationSkillRepository occupationSkillRepository;
public SkillCloudQueryService(SkillIndustryRepository industryRepository,
SkillOccupationRepository occupationRepository,
SkillCategoryRepository categoryRepository,
OccupationSkillRepository occupationSkillRepository) {
this.industryRepository = industryRepository;
this.occupationRepository = occupationRepository;
this.categoryRepository = categoryRepository;
this.occupationSkillRepository = occupationSkillRepository;
}
public SkillCloudResponse query(Long industryId, Long occupationId, Long categoryId) {
long validatedIndustryId = requirePositive(industryId, "industryId");
long validatedOccupationId = requirePositive(occupationId, "occupationId");
Long validatedCategoryId = categoryId == null ? null : requirePositive(categoryId, "categoryId");
SkillIndustry industry = industryRepository.findById(validatedIndustryId)
.orElseThrow(() -> badRequest("industry not found: industryId=" + validatedIndustryId));
SkillOccupation occupation = occupationRepository.findById(validatedOccupationId)
.orElseThrow(() -> badRequest("occupation not found: occupationId=" + validatedOccupationId));
if (!occupation.getIndustryId().equals(validatedIndustryId)) {
throw badRequest("occupation does not belong to industry: occupationId="
+ validatedOccupationId + ", industryId=" + validatedIndustryId);
}
SkillCategory category = null;
if (validatedCategoryId != null) {
category = categoryRepository.findById(validatedCategoryId)
.orElseThrow(() -> badRequest("category not found: categoryId=" + validatedCategoryId));
if (!category.getOccupationId().equals(validatedOccupationId)) {
throw badRequest("category does not belong to occupation: categoryId="
+ validatedCategoryId + ", occupationId=" + validatedOccupationId);
}
}
List<SkillCloudSkillProjection> projections = validatedCategoryId == null
? occupationSkillRepository.findSkillProjectionsByOccupationId(validatedOccupationId)
: occupationSkillRepository.findSkillProjectionsByOccupationIdAndCategoryId(
validatedOccupationId, validatedCategoryId);
List<SkillCloudSkillProjection> sortedProjections = projections.stream()
.sorted(SKILL_ORDER)
.toList();
List<SkillCloudItem> items = IntStream.range(0, sortedProjections.size())
.mapToObj(index -> toItem(sortedProjections.get(index), index + 1))
.toList();
return new SkillCloudResponse(
toNode(industry.getId(), industry.getCode(), industry.getNameZh()),
toNode(occupation.getId(), occupation.getCode(), occupation.getNameZh()),
category == null ? null : toNode(category.getId(), category.getCode(), category.getNameZh()),
items.size(),
items
);
}
private SkillCloudItem toItem(SkillCloudSkillProjection projection, int rank) {
return new SkillCloudItem(
rank,
projection.skillId(),
projection.skillCode(),
projection.nameZh(),
projection.nameEn(),
projection.sourceType(),
projection.sourceCode(),
projection.categoryId(),
projection.categoryName(),
projection.importance(),
projection.importanceSourceCode(),
projection.importanceElementId(),
projection.importanceIsProxy()
);
}
private static int compareHeat(BigDecimal left, BigDecimal right) {
if (left == null && right == null) {
return 0;
}
if (left == null) {
return 1;
}
if (right == null) {
return -1;
}
return right.compareTo(left);
}
private static <T extends Comparable<? super T>> int compareNullable(T left, T right) {
if (left == null && right == null) {
return 0;
}
if (left == null) {
return 1;
}
if (right == null) {
return -1;
}
return left.compareTo(right);
}
private static SkillCloudNode toNode(Long id, String code, String name) {
return new SkillCloudNode(id, code, name);
}
private static long requirePositive(Long value, String parameterName) {
if (value == null || value <= 0) {
throw badRequest(parameterName + " must be a positive integer");
}
return value;
}
private static BusinessException badRequest(String detail) {
return new BusinessException(ErrorCode.BAD_REQUEST, detail);
}
}

95
mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillLearningPathService.java

@ -0,0 +1,95 @@
package com.mydreamjob.skill;
import com.mydreamjob.skill.domain.SkillCategory;
import com.mydreamjob.skill.dto.SkillCloudItem;
import com.mydreamjob.skill.dto.SkillLearningPathItem;
import com.mydreamjob.skill.dto.SkillLearningPathResponse;
import com.mydreamjob.skill.dto.SkillMatchResponse;
import com.mydreamjob.skill.repository.SkillCategoryRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Locale;
import java.util.stream.IntStream;
@Service
@Transactional(readOnly = true)
public class SkillLearningPathService {
private final SkillMatchService skillMatchService;
private final SkillCategoryRepository skillCategoryRepository;
public SkillLearningPathService(SkillMatchService skillMatchService,
SkillCategoryRepository skillCategoryRepository) {
this.skillMatchService = skillMatchService;
this.skillCategoryRepository = skillCategoryRepository;
}
public SkillLearningPathResponse generate(Long studentUserId,
Long industryId,
Long occupationId,
Long categoryId) {
SkillMatchResponse matchResponse = skillMatchService.match(
studentUserId, industryId, occupationId, categoryId);
List<SkillCloudItem> missingSkills = matchResponse.missingSkills();
List<SkillLearningPathItem> items = IntStream.range(0, missingSkills.size())
.mapToObj(index -> toItem(missingSkills.get(index), index + 1, occupationId))
.toList();
return new SkillLearningPathResponse(items.size(), items);
}
private SkillLearningPathItem toItem(SkillCloudItem skill, int priority, Long occupationId) {
SuggestionTemplate template = resolveTemplate(skill, occupationId);
String skillName = skill.nameZh();
return new SkillLearningPathItem(
priority,
skill.skillId(),
skillName,
skill.categoryId(),
skill.categoryName(),
template.render(skillName)
);
}
private SuggestionTemplate resolveTemplate(SkillCloudItem skill, Long occupationId) {
if (skill.categoryId() == null) {
return SuggestionTemplate.GENERAL;
}
String templateCode = skillCategoryRepository
.findByIdAndOccupationId(skill.categoryId(), occupationId)
.map(SkillCategory::getSuggestionTemplateCode)
.orElse(null);
return SuggestionTemplate.fromCode(templateCode);
}
private enum SuggestionTemplate {
PROGRAMMING_LANGUAGE("先掌握{skill}的基础语法和常见数据结构,再完成至少一个编码练习。"),
FRAMEWORK("先理解{skill}的核心概念与生命周期,再通过一个小型项目练习集成。"),
TOOL("先完成{skill}的安装与基础配置,再结合现有工程完成一次实际操作。"),
GENERAL("先了解{skill}的核心概念和常见应用场景,再通过一个简短实践巩固。");
private final String template;
SuggestionTemplate(String template) {
this.template = template;
}
String render(String skillName) {
return template.replace("{skill}", skillName);
}
static SuggestionTemplate fromCode(String code) {
if (code == null || code.isBlank()) {
return GENERAL;
}
try {
return valueOf(code.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
return GENERAL;
}
}
}
}

152
mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillMatchService.java

@ -0,0 +1,152 @@
package com.mydreamjob.skill;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import com.mydreamjob.skill.domain.SkillAlias;
import com.mydreamjob.skill.dto.SkillCloudItem;
import com.mydreamjob.skill.dto.SkillCloudResponse;
import com.mydreamjob.skill.dto.SkillMatchResponse;
import com.mydreamjob.skill.repository.SkillAliasRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Slf4j
@Service
@Transactional(readOnly = true)
public class SkillMatchService {
private static final int TOP_LIMIT = 10;
private final SkillCloudQueryService skillCloudQueryService;
private final StudentProfileRepository studentProfileRepository;
private final SkillAliasRepository skillAliasRepository;
private final SkillNameNormalizer skillNameNormalizer;
private final ObjectMapper objectMapper;
public SkillMatchService(SkillCloudQueryService skillCloudQueryService,
StudentProfileRepository studentProfileRepository,
SkillAliasRepository skillAliasRepository,
SkillNameNormalizer skillNameNormalizer,
ObjectMapper objectMapper) {
this.skillCloudQueryService = skillCloudQueryService;
this.studentProfileRepository = studentProfileRepository;
this.skillAliasRepository = skillAliasRepository;
this.skillNameNormalizer = skillNameNormalizer;
this.objectMapper = objectMapper;
}
public SkillMatchResponse match(Long studentUserId,
Long industryId,
Long occupationId,
Long categoryId) {
SkillCloudResponse cloud = skillCloudQueryService.query(industryId, occupationId, categoryId);
int actualTopSize = Math.min(TOP_LIMIT, cloud.items().size());
List<SkillCloudItem> topSkills = cloud.items().subList(0, actualTopSize);
Set<String> studentSkills = loadStudentSkills(studentUserId);
Map<Long, List<SkillAlias>> aliasesBySkillId = loadAliases(topSkills);
List<SkillCloudItem> matchedSkills = new ArrayList<>();
List<SkillCloudItem> missingSkills = new ArrayList<>();
for (SkillCloudItem skill : topSkills) {
if (matches(skill, studentSkills, aliasesBySkillId.getOrDefault(skill.skillId(), List.of()))) {
matchedSkills.add(skill);
} else {
missingSkills.add(skill);
}
}
int matchScore = actualTopSize == 0
? 0
: (int) Math.round(matchedSkills.size() * 100.0 / actualTopSize);
return new SkillMatchResponse(
matchScore,
matchedSkills.size(),
actualTopSize,
matchedSkills,
missingSkills
);
}
private Set<String> loadStudentSkills(Long studentUserId) {
if (studentUserId == null) {
return Set.of();
}
StudentProfile profile = studentProfileRepository.findByUserId(studentUserId).orElse(null);
if (profile == null || profile.getSkillsJson() == null || profile.getSkillsJson().isBlank()) {
return Set.of();
}
try {
List<String> parsed = objectMapper.readValue(
profile.getSkillsJson(),
new TypeReference<List<String>>() {}
);
if (parsed == null || parsed.isEmpty()) {
return Set.of();
}
Set<String> normalizedSkills = new HashSet<>();
for (String skill : parsed) {
if (skill == null) {
continue;
}
String normalized = skillNameNormalizer.normalize(skill);
if (!normalized.isBlank()) {
normalizedSkills.add(normalized);
}
}
return Set.copyOf(normalizedSkills);
} catch (Exception exception) {
log.warn("Failed to parse student skills JSON: profileId={}, errorType={}",
profile.getId(), exception.getClass().getSimpleName());
return Set.of();
}
}
private Map<Long, List<SkillAlias>> loadAliases(List<SkillCloudItem> topSkills) {
Collection<Long> skillIds = topSkills.stream()
.map(SkillCloudItem::skillId)
.filter(java.util.Objects::nonNull)
.toList();
if (skillIds.isEmpty()) {
return Map.of();
}
Map<Long, List<SkillAlias>> aliasesBySkillId = new HashMap<>();
for (SkillAlias alias : skillAliasRepository.findAllBySkillIdInOrderByIdAsc(skillIds)) {
aliasesBySkillId.computeIfAbsent(alias.getSkillId(), ignored -> new ArrayList<>()).add(alias);
}
return aliasesBySkillId;
}
private boolean matches(SkillCloudItem skill,
Set<String> studentSkills,
List<SkillAlias> aliases) {
if (containsNormalized(studentSkills, skill.nameZh())
|| containsNormalized(studentSkills, skill.nameEn())
|| containsNormalized(studentSkills, skill.skillCode())) {
return true;
}
return aliases.stream().anyMatch(alias -> containsNormalized(studentSkills, alias.getAlias()));
}
private boolean containsNormalized(Set<String> studentSkills, String candidate) {
if (candidate == null) {
return false;
}
String normalized = skillNameNormalizer.normalize(candidate);
return !normalized.isBlank() && studentSkills.contains(normalized);
}
}

28
mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillNameNormalizer.java

@ -0,0 +1,28 @@
package com.mydreamjob.skill;
import org.springframework.stereotype.Component;
import java.text.Normalizer;
import java.util.Locale;
import java.util.regex.Pattern;
@Component
public class SkillNameNormalizer {
private static final Pattern UNICODE_HYPHENS = Pattern.compile("\\p{Pd}");
private static final Pattern WHITESPACE = Pattern.compile("\\s+", Pattern.UNICODE_CHARACTER_CLASS);
public String normalize(String value) {
if (value == null) {
return "";
}
String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC)
.trim()
.toLowerCase(Locale.ROOT);
normalized = UNICODE_HYPHENS.matcher(normalized).replaceAll(" ");
normalized = normalized.replace(".", "");
normalized = WHITESPACE.matcher(normalized).replaceAll(" ");
return normalized.trim();
}
}

62
mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/OccupationSkill.java

@ -0,0 +1,62 @@
package com.mydreamjob.skill.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "occupation_skills")
public class OccupationSkill {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "occupation_id", nullable = false)
private Long occupationId;
@Column(name = "skill_id", nullable = false)
private Long skillId;
@Column(name = "category_id", nullable = false)
private Long categoryId;
@Column(name = "importance", precision = 4, scale = 2)
private BigDecimal importance;
@Column(name = "importance_source_code", length = 20)
private String importanceSourceCode;
@Column(name = "importance_element_id", length = 30)
private String importanceElementId;
@Column(name = "importance_is_proxy", nullable = false)
private Boolean importanceIsProxy;
@Column(name = "relationship_type", nullable = false, length = 20)
private String relationshipType;
@Column(name = "source_version_id", nullable = false)
private Long sourceVersionId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

56
mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/Skill.java

@ -0,0 +1,56 @@
package com.mydreamjob.skill.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "skills")
public class Skill {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "code", nullable = false, length = 120)
private String code;
@Column(name = "name_zh", nullable = false, length = 180)
private String nameZh;
@Column(name = "name_en", length = 180)
private String nameEn;
@Column(name = "source_type", nullable = false, length = 20)
private String sourceType;
@Column(name = "source_code", nullable = false, length = 180)
private String sourceCode;
@Column(name = "source_version_id", nullable = false)
private Long sourceVersionId;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

41
mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillAlias.java

@ -0,0 +1,41 @@
package com.mydreamjob.skill.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "skill_aliases")
public class SkillAlias {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "skill_id", nullable = false)
private Long skillId;
@Column(name = "alias", nullable = false, length = 180)
private String alias;
@Column(name = "normalized_alias", nullable = false, length = 180)
private String normalizedAlias;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

50
mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillCategory.java

@ -0,0 +1,50 @@
package com.mydreamjob.skill.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "skill_categories")
public class SkillCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "occupation_id", nullable = false)
private Long occupationId;
@Column(name = "code", nullable = false, length = 100)
private String code;
@Column(name = "name_zh", nullable = false, length = 120)
private String nameZh;
@Column(name = "suggestion_template_code", nullable = false, length = 30)
private String suggestionTemplateCode;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

47
mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillIndustry.java

@ -0,0 +1,47 @@
package com.mydreamjob.skill.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "skill_industries")
public class SkillIndustry {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "code", nullable = false, length = 80)
private String code;
@Column(name = "name_zh", nullable = false, length = 120)
private String nameZh;
@Column(name = "source_code", length = 120)
private String sourceCode;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

56
mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillOccupation.java

@ -0,0 +1,56 @@
package com.mydreamjob.skill.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "skill_occupations")
public class SkillOccupation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "industry_id", nullable = false)
private Long industryId;
@Column(name = "code", nullable = false, length = 100)
private String code;
@Column(name = "name_zh", nullable = false, length = 150)
private String nameZh;
@Column(name = "name_en", length = 180)
private String nameEn;
@Column(name = "source_code", nullable = false, length = 150)
private String sourceCode;
@Column(name = "source_version_id", nullable = false)
private Long sourceVersionId;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

50
mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/SkillSourceVersion.java

@ -0,0 +1,50 @@
package com.mydreamjob.skill.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "skill_source_versions")
public class SkillSourceVersion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "source_name", nullable = false, length = 100)
private String sourceName;
@Column(name = "source_version", nullable = false, length = 150)
private String sourceVersion;
@Column(name = "license", nullable = false, length = 255)
private String license;
@Column(name = "attribution_url", nullable = false, length = 500)
private String attributionUrl;
@Column(name = "imported_at", nullable = false)
private LocalDateTime importedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

20
mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudItem.java

@ -0,0 +1,20 @@
package com.mydreamjob.skill.dto;
import java.math.BigDecimal;
public record SkillCloudItem(
int rank,
Long skillId,
String skillCode,
String nameZh,
String nameEn,
String sourceType,
String sourceCode,
Long categoryId,
String categoryName,
BigDecimal heat,
String heatSourceCode,
String heatElementId,
Boolean heatProxy
) {
}

8
mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudNode.java

@ -0,0 +1,8 @@
package com.mydreamjob.skill.dto;
public record SkillCloudNode(
Long id,
String code,
String name
) {
}

15
mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudResponse.java

@ -0,0 +1,15 @@
package com.mydreamjob.skill.dto;
import java.util.List;
public record SkillCloudResponse(
SkillCloudNode industry,
SkillCloudNode occupation,
SkillCloudNode category,
int total,
List<SkillCloudItem> items
) {
public SkillCloudResponse {
items = List.copyOf(items);
}
}

21
mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillCloudSkillProjection.java

@ -0,0 +1,21 @@
package com.mydreamjob.skill.dto;
import java.math.BigDecimal;
public record SkillCloudSkillProjection(
Long skillId,
String skillCode,
String nameZh,
String nameEn,
String sourceType,
String sourceCode,
Long categoryId,
String categoryName,
BigDecimal importance,
String importanceSourceCode,
String importanceElementId,
Boolean importanceIsProxy,
Integer skillSortOrder,
Integer categorySortOrder
) {
}

11
mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathItem.java

@ -0,0 +1,11 @@
package com.mydreamjob.skill.dto;
public record SkillLearningPathItem(
int priority,
Long skillId,
String skillName,
Long categoryId,
String categoryName,
String suggestion
) {
}

12
mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillLearningPathResponse.java

@ -0,0 +1,12 @@
package com.mydreamjob.skill.dto;
import java.util.List;
public record SkillLearningPathResponse(
int total,
List<SkillLearningPathItem> items
) {
public SkillLearningPathResponse {
items = List.copyOf(items);
}
}

16
mydreamjob-server/src/main/java/com/mydreamjob/skill/dto/SkillMatchResponse.java

@ -0,0 +1,16 @@
package com.mydreamjob.skill.dto;
import java.util.List;
public record SkillMatchResponse(
int matchScore,
int matchedCount,
int totalCount,
List<SkillCloudItem> matchedSkills,
List<SkillCloudItem> missingSkills
) {
public SkillMatchResponse {
matchedSkills = List.copyOf(matchedSkills);
missingSkills = List.copyOf(missingSkills);
}
}

73
mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/OccupationSkillRepository.java

@ -0,0 +1,73 @@
package com.mydreamjob.skill.repository;
import com.mydreamjob.skill.domain.OccupationSkill;
import com.mydreamjob.skill.dto.SkillCloudSkillProjection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
public interface OccupationSkillRepository extends JpaRepository<OccupationSkill, Long> {
long countByOccupationId(Long occupationId);
long countByOccupationIdAndCategoryId(Long occupationId, Long categoryId);
@Transactional(readOnly = true)
@Query("""
SELECT new com.mydreamjob.skill.dto.SkillCloudSkillProjection(
os.skillId,
s.code,
s.nameZh,
s.nameEn,
s.sourceType,
s.sourceCode,
c.id,
c.nameZh,
os.importance,
os.importanceSourceCode,
os.importanceElementId,
os.importanceIsProxy,
s.sortOrder,
c.sortOrder
)
FROM OccupationSkill os, Skill s, SkillCategory c
WHERE os.skillId = s.id
AND os.categoryId = c.id
AND c.occupationId = os.occupationId
AND os.occupationId = :occupationId
""")
List<SkillCloudSkillProjection> findSkillProjectionsByOccupationId(
@Param("occupationId") Long occupationId);
@Transactional(readOnly = true)
@Query("""
SELECT new com.mydreamjob.skill.dto.SkillCloudSkillProjection(
os.skillId,
s.code,
s.nameZh,
s.nameEn,
s.sourceType,
s.sourceCode,
c.id,
c.nameZh,
os.importance,
os.importanceSourceCode,
os.importanceElementId,
os.importanceIsProxy,
s.sortOrder,
c.sortOrder
)
FROM OccupationSkill os, Skill s, SkillCategory c
WHERE os.skillId = s.id
AND os.categoryId = c.id
AND c.occupationId = os.occupationId
AND os.occupationId = :occupationId
AND os.categoryId = :categoryId
""")
List<SkillCloudSkillProjection> findSkillProjectionsByOccupationIdAndCategoryId(
@Param("occupationId") Long occupationId,
@Param("categoryId") Long categoryId);
}

15
mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillAliasRepository.java

@ -0,0 +1,15 @@
package com.mydreamjob.skill.repository;
import com.mydreamjob.skill.domain.SkillAlias;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface SkillAliasRepository extends JpaRepository<SkillAlias, Long> {
Optional<SkillAlias> findByNormalizedAlias(String normalizedAlias);
List<SkillAlias> findAllBySkillIdInOrderByIdAsc(Collection<Long> skillIds);
}

13
mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillCategoryRepository.java

@ -0,0 +1,13 @@
package com.mydreamjob.skill.repository;
import com.mydreamjob.skill.domain.SkillCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SkillCategoryRepository extends JpaRepository<SkillCategory, Long> {
boolean existsByIdAndOccupationId(Long id, Long occupationId);
Optional<SkillCategory> findByIdAndOccupationId(Long id, Long occupationId);
}

7
mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillIndustryRepository.java

@ -0,0 +1,7 @@
package com.mydreamjob.skill.repository;
import com.mydreamjob.skill.domain.SkillIndustry;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SkillIndustryRepository extends JpaRepository<SkillIndustry, Long> {
}

13
mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillOccupationRepository.java

@ -0,0 +1,13 @@
package com.mydreamjob.skill.repository;
import com.mydreamjob.skill.domain.SkillOccupation;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SkillOccupationRepository extends JpaRepository<SkillOccupation, Long> {
boolean existsByIdAndIndustryId(Long id, Long industryId);
Optional<SkillOccupation> findByIdAndIndustryId(Long id, Long industryId);
}

15
mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillRepository.java

@ -0,0 +1,15 @@
package com.mydreamjob.skill.repository;
import com.mydreamjob.skill.domain.Skill;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface SkillRepository extends JpaRepository<Skill, Long> {
Optional<Skill> findByCode(String code);
List<Skill> findAllByIdInOrderByIdAsc(Collection<Long> ids);
}

7
mydreamjob-server/src/main/java/com/mydreamjob/skill/repository/SkillSourceVersionRepository.java

@ -0,0 +1,7 @@
package com.mydreamjob.skill.repository;
import com.mydreamjob.skill.domain.SkillSourceVersion;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SkillSourceVersionRepository extends JpaRepository<SkillSourceVersion, Long> {
}

120
mydreamjob-server/src/main/resources/db/migration/V35__skill_cloud_taxonomy.sql

@ -0,0 +1,120 @@
-- Flyway V35: 行业技能词云知识库表结构
-- 来源与许可:ESCO v1.2.1 提供职位/技能/分类关系,O*NET 31.0 提供 Importance。
-- 本迁移只建立结构,代表性种子数据由 V36 导入。
CREATE TABLE skill_source_versions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
source_name VARCHAR(100) NOT NULL COMMENT '来源名称:ESCO/O*NET',
source_version VARCHAR(150) NOT NULL COMMENT '固定来源版本',
license VARCHAR(255) NOT NULL COMMENT '来源许可证或复用条件',
attribution_url VARCHAR(500) NOT NULL COMMENT '官方来源或归属地址',
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_skill_source_versions_source UNIQUE (source_name, source_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词库来源版本';
CREATE TABLE skill_industries (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(80) NOT NULL COMMENT '项目行业稳定编码',
name_zh VARCHAR(120) NOT NULL COMMENT '中文行业名称',
source_code VARCHAR(120) COMMENT '上游或项目映射来源编码',
sort_order INT 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_skill_industries_code UNIQUE (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词云行业';
CREATE TABLE skill_occupations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
industry_id BIGINT NOT NULL COMMENT '所属行业',
code VARCHAR(100) NOT NULL COMMENT '职位稳定编码',
name_zh VARCHAR(150) NOT NULL COMMENT '中文职位名称',
name_en VARCHAR(180) COMMENT '英文职位名称',
source_code VARCHAR(150) NOT NULL COMMENT 'ESCO 职位来源 ID',
source_version_id BIGINT NOT NULL COMMENT '来源版本',
sort_order INT 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_skill_occupations_industry_code UNIQUE (industry_id, code),
CONSTRAINT fk_skill_occupations_industry FOREIGN KEY (industry_id) REFERENCES skill_industries (id),
CONSTRAINT fk_skill_occupations_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词云职位';
CREATE TABLE skill_categories (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
occupation_id BIGINT NOT NULL COMMENT '所属职位',
code VARCHAR(100) NOT NULL COMMENT '技能分类稳定编码',
name_zh VARCHAR(120) NOT NULL COMMENT '中文分类名称',
suggestion_template_code VARCHAR(30) NOT NULL COMMENT '学习建议模板编码',
sort_order INT 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_skill_categories_occupation_code UNIQUE (occupation_id, code),
CONSTRAINT ck_skill_categories_suggestion_template CHECK (
suggestion_template_code IN ('PROGRAMMING_LANGUAGE', 'FRAMEWORK', 'TOOL', 'GENERAL')
),
CONSTRAINT fk_skill_categories_occupation FOREIGN KEY (occupation_id) REFERENCES skill_occupations (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='职位技能分类';
CREATE TABLE skills (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(120) NOT NULL COMMENT '项目技能稳定编码',
name_zh VARCHAR(180) NOT NULL COMMENT '中文标准技能名称',
name_en VARCHAR(180) COMMENT '英文标准技能名称',
source_type VARCHAR(20) NOT NULL COMMENT '来源类型',
source_code VARCHAR(180) NOT NULL COMMENT 'ESCO/O*NET 或项目来源 ID',
source_version_id BIGINT NOT NULL COMMENT '来源版本',
sort_order INT 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_skills_code UNIQUE (code),
CONSTRAINT uk_skills_source UNIQUE (source_type, source_code),
CONSTRAINT ck_skills_source_type CHECK (source_type IN ('ESCO', 'O*NET', 'PROJECT')),
CONSTRAINT fk_skills_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标准技能';
CREATE TABLE skill_aliases (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
skill_id BIGINT NOT NULL COMMENT '标准技能',
alias VARCHAR(180) NOT NULL COMMENT '技能别名',
normalized_alias VARCHAR(180) NOT NULL COMMENT '归一化技能别名',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_skill_aliases_normalized_alias UNIQUE (normalized_alias),
CONSTRAINT uk_skill_aliases_skill_alias UNIQUE (skill_id, alias),
CONSTRAINT fk_skill_aliases_skill FOREIGN KEY (skill_id) REFERENCES skills (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标准技能别名';
CREATE TABLE occupation_skills (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
occupation_id BIGINT NOT NULL COMMENT '职位',
skill_id BIGINT NOT NULL COMMENT '标准技能',
category_id BIGINT NOT NULL COMMENT '职位内技能分类',
importance DECIMAL(4,2) COMMENT 'O*NET Importance,0-5,允许为空',
importance_source_code VARCHAR(20) COMMENT 'O*NET SOC 来源,非空热度必填',
importance_element_id VARCHAR(30) COMMENT 'O*NET Element ID,非空热度必填',
importance_is_proxy TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为项目分类到 O*NET 元素的代理映射',
relationship_type VARCHAR(20) NOT NULL COMMENT '职位技能关系类型',
source_version_id BIGINT NOT NULL COMMENT 'Importance 来源版本',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_occupation_skills_occupation_skill UNIQUE (occupation_id, skill_id),
CONSTRAINT ck_occupation_skills_importance CHECK (
importance IS NULL OR (importance >= 0.00 AND importance <= 5.00)
),
CONSTRAINT ck_occupation_skills_importance_provenance CHECK (
importance IS NULL OR (importance_source_code IS NOT NULL AND importance_element_id IS NOT NULL)
),
CONSTRAINT ck_occupation_skills_importance_proxy CHECK (
importance_is_proxy IN (0, 1)
),
CONSTRAINT ck_occupation_skills_relationship_type CHECK (
relationship_type IN ('ESSENTIAL', 'OPTIONAL', 'PROJECT')
),
CONSTRAINT fk_occupation_skills_occupation FOREIGN KEY (occupation_id) REFERENCES skill_occupations (id),
CONSTRAINT fk_occupation_skills_skill FOREIGN KEY (skill_id) REFERENCES skills (id),
CONSTRAINT fk_occupation_skills_category FOREIGN KEY (category_id) REFERENCES skill_categories (id),
CONSTRAINT fk_occupation_skills_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id),
KEY idx_occupation_skills_occupation_category (occupation_id, category_id),
KEY idx_occupation_skills_occupation_importance (occupation_id, importance)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='职位技能关系与热度';

413
mydreamjob-server/src/main/resources/db/migration/V36__skill_cloud_seed.sql

@ -0,0 +1,413 @@
-- Flyway V36: ESCO/O*NET 代表性行业技能种子数据
-- 本迁移只导入互联网、金融、教育三类行业及 10 个代表性职位。
-- ESCO 负责职位、通用技能和分类主关系;O*NET software_skills 仅补充 ESCO 缺失的具体技术。
-- O*NET software_skills.source_code 格式:SOC|Workplace Example|Element ID,均来自本地 O*NET 31.0 CSV 的真实行。
--
-- ESCO 职位映射:
-- FRONTEND_DEVELOPER -> c40a2919-48a9-40ea-b506-1f34f693496d
-- BACKEND_DEVELOPER -> f2b15a0e-e65a-438a-affb-29b9d50b77d1
-- QA_ENGINEER -> 106f79e4-6264-45f1-9e7a-297435cd684b
-- DEVOPS_ENGINEER -> 使用后端软件开发者 ESCO URI 作为项目代理(ESCO 无更近 DevOps 节点)
-- FINANCIAL_DATA_ANALYST -> 8d586ee9-0ab1-4155-a3b0-2ca786c8e48c
-- RISK_MODELING_ENGINEER -> b09acdda-6680-49d1-a411-129610ee3bc0
-- QUANTITATIVE_DEVELOPER -> 使用软件开发者 ESCO URI 作为项目代理(ESCO 无更近量化开发节点)
-- EDUCATION_PRODUCT_MANAGER -> a79170ad-0b7b-4697-864f-1748359aa10a
-- INSTRUCTIONAL_DESIGNER -> a9c30651-ccbc-4a80-900c-7880615cdf6e
-- EDUCATION_DATA_ANALYST -> d3edb8f8-3a06-47a0-8fb9-9b212c006aa2
--
-- O*NET Importance 代理 SOC:
-- FRONTEND_DEVELOPER -> 15-1254.00 Web Developers
-- BACKEND_DEVELOPER -> 15-1252.00 Software Developers
-- QA_ENGINEER -> 15-1253.00 Software Quality Assurance Analysts and Testers
-- DEVOPS_ENGINEER -> 15-1244.00 Network and Computer Systems Administrators
-- FINANCIAL_DATA_ANALYST -> 13-2031.00 Budget Analysts(目标金融分析师 SOC 在本地 O*NET 31.0 Importance CSV 中无数据,故使用语义较近的预算分析师代理)
-- RISK_MODELING_ENGINEER -> 13-2053.00 Insurance Underwriters(目标金融风险专家 SOC 在本地 Importance CSV 中无数据,故使用包含风险决策活动的保险承保人代理)
-- QUANTITATIVE_DEVELOPER -> 13-2099.01 Financial Quantitative Analysts
-- EDUCATION_PRODUCT_MANAGER -> 13-1082.00 Project Management Specialists(无直接教育产品经理 SOC,使用项目管理专家代理)
-- INSTRUCTIONAL_DESIGNER -> 25-9031.00 Instructional Coordinators
-- EDUCATION_DATA_ANALYST -> 15-2051.00 Data Scientists
--
-- 分类代理规则(importance_is_proxy=1):
-- TECH=4.A.3.b.1 Working with Computers;PROCESS=4.A.2.a.2 Processing Information;
-- ANALYSIS=4.A.2.a.4 Analyzing Data or Information;COMPLIANCE=4.A.2.a.3 Evaluating Information to Determine Compliance with Standards;
-- DESIGN=4.A.2.b.2 Thinking Creatively;STRATEGY=4.A.2.b.4 Developing Objectives and Strategies;
-- MONITOR=4.A.1.a.2 Monitoring Processes, Materials, or Surroundings;ESTIMATE=4.A.1.b.3 Estimating the Quantifiable Characteristics of Products, Events, or Information;
-- COMM=4.A.4.a.2 Communicating with Supervisors, Peers, or Subordinates;EDU=2.C.6 Education and Training;
-- FINANCE=2.C.1.c Economics and Accounting;MATH=2.C.4.a Mathematics;SECURITY=2.C.8.a Public Safety and Security。
-- 每条非空 importance 均保存上述代理 SOC、Element ID 和 importance_is_proxy=1,可由本地 O*NET CSV 逐条复核。
-- 无法稳定映射 Importance 的技能保留关系并写 NULL,不伪造热度。
--
INSERT INTO skill_source_versions (id, source_name, source_version, license, attribution_url, imported_at) VALUES
(1, 'ESCO', '1.2.1 (Release v5.7.0, Last update 10/12/2025)', 'Commission Decision 2011/833/EU', 'https://esco.ec.europa.eu/en/use-esco/download', CURRENT_TIMESTAMP),
(2, 'O*NET', '31.0 Database', 'CC BY 4.0', 'https://www.onetcenter.org/database.html', CURRENT_TIMESTAMP);
INSERT INTO skill_industries (id, code, name_zh, source_code, sort_order) VALUES
(1, 'INTERNET', '互联网', 'PROJECT:INDUSTRY:INTERNET', 1),
(2, 'FINANCE', '金融', 'PROJECT:INDUSTRY:FINANCE', 2),
(3, 'EDUCATION', '教育', 'PROJECT:INDUSTRY:EDUCATION', 3);
-- DevOps 与量化开发没有更近的 ESCO 职位节点,source_code 显式复用软件开发者 URI;这是项目代理映射,不是上游原始职位关系。
INSERT INTO skill_occupations (id, industry_id, code, name_zh, name_en, source_code, source_version_id, sort_order) VALUES
(1, 1, 'FRONTEND_DEVELOPER', '前端开发工程师', 'Frontend Developer', 'http://data.europa.eu/esco/occupation/c40a2919-48a9-40ea-b506-1f34f693496d', 1, 1),
(2, 1, 'BACKEND_DEVELOPER', '后端开发工程师', 'Backend Developer', 'http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1', 1, 2),
(3, 1, 'QA_ENGINEER', '测试工程师', 'Software QA Engineer', 'http://data.europa.eu/esco/occupation/106f79e4-6264-45f1-9e7a-297435cd684b', 1, 3),
(4, 1, 'DEVOPS_ENGINEER', 'DevOps 工程师', 'DevOps Engineer', 'http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1', 1, 4),
(5, 2, 'FINANCIAL_DATA_ANALYST', '金融数据分析师', 'Financial Data Analyst', 'http://data.europa.eu/esco/occupation/8d586ee9-0ab1-4155-a3b0-2ca786c8e48c', 1, 1),
(6, 2, 'RISK_MODELING_ENGINEER', '风控建模工程师', 'Risk Modeling Engineer', 'http://data.europa.eu/esco/occupation/b09acdda-6680-49d1-a411-129610ee3bc0', 1, 2),
(7, 2, 'QUANTITATIVE_DEVELOPER', '量化开发工程师', 'Quantitative Developer', 'http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1', 1, 3),
(8, 3, 'EDUCATION_PRODUCT_MANAGER', '教育产品经理', 'Education Product Manager', 'http://data.europa.eu/esco/occupation/a79170ad-0b7b-4697-864f-1748359aa10a', 1, 1),
(9, 3, 'INSTRUCTIONAL_DESIGNER', '教学设计师', 'Instructional Designer', 'http://data.europa.eu/esco/occupation/a9c30651-ccbc-4a80-900c-7880615cdf6e', 1, 2),
(10, 3, 'EDUCATION_DATA_ANALYST', '教育数据分析师', 'Education Data Analyst', 'http://data.europa.eu/esco/occupation/d3edb8f8-3a06-47a0-8fb9-9b212c006aa2', 1, 3);
INSERT INTO skill_categories (id, occupation_id, code, name_zh, suggestion_template_code, sort_order) VALUES
(101, 1, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1),
(102, 1, 'WEB_BASIC', 'Web 基础', 'FRAMEWORK', 2),
(103, 1, 'FRAMEWORK', '前端框架', 'FRAMEWORK', 3),
(104, 1, 'TOOLS', '工程化工具', 'TOOL', 4),
(105, 1, 'BROWSER_DEBUG', '浏览器与调试', 'TOOL', 5),
(106, 1, 'TEST', '测试', 'GENERAL', 6),
(107, 1, 'COLLABORATION', '版本控制与协作', 'GENERAL', 7),
(201, 2, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1),
(202, 2, 'FRAMEWORK', '后端框架与平台', 'FRAMEWORK', 2),
(203, 2, 'DATABASE', '数据库', 'TOOL', 3),
(204, 2, 'CACHE_MESSAGING', '缓存与消息', 'TOOL', 4),
(205, 2, 'CLOUD_DEVOPS', '云/DevOps', 'TOOL', 5),
(206, 2, 'TEST', '测试', 'GENERAL', 6),
(207, 2, 'TOOLS', '工程化工具', 'TOOL', 7),
(208, 2, 'SECURITY', '安全', 'GENERAL', 8),
(301, 3, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1),
(302, 3, 'TEST_DESIGN', '测试设计', 'GENERAL', 2),
(303, 3, 'AUTOMATION_TEST', '自动化测试框架', 'FRAMEWORK', 3),
(304, 3, 'API_DATABASE_TEST', '接口与数据库测试', 'TOOL', 4),
(305, 3, 'PERFORMANCE_TEST', '性能测试', 'TOOL', 5),
(306, 3, 'CICD_TOOLS', 'CI/CD 与工程化工具', 'TOOL', 6),
(401, 4, 'LANGUAGE_SCRIPT', '编程语言与脚本', 'PROGRAMMING_LANGUAGE', 1),
(402, 4, 'CLOUD', '云平台', 'TOOL', 2),
(403, 4, 'CONTAINER', '容器与编排', 'TOOL', 3),
(404, 4, 'CICD', 'CI/CD', 'TOOL', 4),
(405, 4, 'IAC', '配置管理与 IaC', 'TOOL', 5),
(406, 4, 'OBSERVABILITY', '可观测性', 'TOOL', 6),
(407, 4, 'SECURITY', '安全', 'GENERAL', 7),
(408, 4, 'DATABASE', '数据库', 'TOOL', 8),
(501, 5, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1),
(502, 5, 'ANALYSIS_VISUALIZATION', '数据分析与可视化', 'TOOL', 2),
(503, 5, 'DATABASE_WAREHOUSE', '数据库与数据仓库', 'TOOL', 3),
(504, 5, 'STATISTICS_MODELING', '统计与建模', 'FRAMEWORK', 4),
(505, 5, 'FINANCE_PLATFORM', '金融计算框架/平台', 'FRAMEWORK', 5),
(506, 5, 'FINANCE_DOMAIN', '金融领域知识', 'GENERAL', 6),
(507, 5, 'TOOLS', '工程化工具', 'TOOL', 7),
(601, 6, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1),
(602, 6, 'STATISTICS_MODELING', '统计与建模', 'FRAMEWORK', 2),
(603, 6, 'RISK_MODEL', '风险模型', 'GENERAL', 3),
(604, 6, 'MODEL_PLATFORM', '建模框架/平台', 'FRAMEWORK', 4),
(605, 6, 'DATA_DATABASE', '数据处理与数据库', 'TOOL', 5),
(606, 6, 'MODEL_VALIDATION', '模型验证', 'GENERAL', 6),
(607, 6, 'DEPLOY_TOOLS', '部署与工程化工具', 'TOOL', 7),
(701, 7, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1),
(702, 7, 'QUANT_PLATFORM', '量化建模框架/平台', 'FRAMEWORK', 2),
(703, 7, 'MARKET_DATA', '市场与金融数据', 'TOOL', 3),
(704, 7, 'DATABASE', '数据库', 'TOOL', 4),
(705, 7, 'PERFORMANCE', '低延迟与高性能工程', 'TOOL', 5),
(706, 7, 'TEST', '测试', 'GENERAL', 6),
(707, 7, 'CLOUD_DEVOPS', '云/DevOps', 'TOOL', 7),
(801, 8, 'EDU_DOMAIN', '教育领域知识', 'GENERAL', 1),
(802, 8, 'PRODUCT_ANALYSIS', '产品分析方法', 'TOOL', 2),
(803, 8, 'REQUIREMENTS_PROCESS', '需求与流程', 'GENERAL', 3),
(804, 8, 'DATA_ANALYSIS', '数据分析', 'TOOL', 4),
(805, 8, 'EDU_PLATFORM', '教育平台与工具', 'TOOL', 5),
(806, 8, 'COLLAB_TOOLS', '协作与工程化工具', 'GENERAL', 6),
(901, 9, 'INSTRUCTIONAL_DESIGN', '教学设计', 'FRAMEWORK', 1),
(902, 9, 'COURSE_PLATFORM', '课程与学习平台', 'TOOL', 2),
(903, 9, 'DIGITAL_CONTENT', '数字化内容工具', 'TOOL', 3),
(904, 9, 'ASSESSMENT', '评估设计', 'GENERAL', 4),
(905, 9, 'LEARNING_ANALYSIS', '学习数据分析', 'TOOL', 5),
(906, 9, 'ACCESSIBILITY', '无障碍与规范', 'GENERAL', 6),
(1001, 10, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1),
(1002, 10, 'ANALYSIS_VISUALIZATION', '数据分析与可视化', 'TOOL', 2),
(1003, 10, 'DATABASE_SQL', '数据库与 SQL', 'TOOL', 3),
(1004, 10, 'STATISTICS_MODELING', '统计与建模', 'FRAMEWORK', 4),
(1005, 10, 'EDU_DOMAIN', '教育领域知识', 'GENERAL', 5),
(1006, 10, 'REPORTING_TOOLS', '报表与工程化工具', 'TOOL', 6);
INSERT INTO skills (id, code, name_zh, name_en, source_type, source_code, source_version_id, sort_order) VALUES
(1, 'JAVA', 'Java', 'Java (computer programming)', 'ESCO', 'http://data.europa.eu/esco/skill/19a8293b-8e95-4de3-983f-77484079c389', 1, 1),
(2, 'PYTHON', 'Python', 'Python (computer programming)', 'ESCO', 'http://data.europa.eu/esco/skill/ccd0a1d9-afda-43d9-b901-96344886e14d', 1, 2),
(3, 'JAVASCRIPT', 'JavaScript', 'JavaScript', 'ESCO', 'http://data.europa.eu/esco/skill/3cd569a2-4f88-4c1e-9995-8dce8c5e51a7', 1, 3),
(4, 'TYPESCRIPT', 'TypeScript', 'TypeScript', 'ESCO', 'http://data.europa.eu/esco/skill/867137fb-ff1b-4ca3-99f3-cb6969aa2c68', 1, 4),
(5, 'SQL', 'SQL', 'SQL', 'ESCO', 'http://data.europa.eu/esco/skill/598de5b0-5b58-4ea7-8058-a4bc4d18c742', 1, 5),
(6, 'CSHARP', 'C#', 'C#', 'ESCO', 'http://data.europa.eu/esco/skill/4c016b68-4116-468c-9dc6-42710c239e4a', 1, 6),
(7, 'HTML', 'HTML', 'use markup languages', 'ESCO', 'http://data.europa.eu/esco/skill/0af062de-eb43-41e9-9b96-249e2cd22d26', 1, 7),
(8, 'CSS', 'CSS', 'CSS', 'ESCO', 'http://data.europa.eu/esco/skill/e5d1f825-60ed-4bdd-872a-e748c387f777', 1, 8),
(9, 'R', 'R', 'R', 'ESCO', 'http://data.europa.eu/esco/skill/51586df8-1c46-4b47-8583-773cb63bf00b', 1, 9),
(10, 'MYSQL', 'MySQL', 'MySQL', 'ESCO', 'http://data.europa.eu/esco/skill/4da171e5-779c-4983-a76f-91c16751e99f', 1, 10),
(11, 'POSTGRESQL', 'PostgreSQL', 'PostgreSQL', 'ESCO', 'http://data.europa.eu/esco/skill/a8d07b5a-c1a1-42c6-9d53-db9c7a2ca996', 1, 11),
(12, 'STATISTICS', '统计学', 'statistics', 'ESCO', 'http://data.europa.eu/esco/skill/7ee4c2ea-b349-4bd2-81a3-ec31475d4833', 1, 12),
(13, 'MACHINE_LEARNING', '机器学习', 'utilise machine learning', 'ESCO', 'http://data.europa.eu/esco/skill/8369c2d6-c100-4cf6-bd83-9668d8678433', 1, 13),
(14, 'FINANCIAL_ANALYSIS', '金融分析', 'financial analysis', 'ESCO', 'http://data.europa.eu/esco/skill/99571e68-801f-49af-a897-5f75996642e1', 1, 14),
(15, 'RISK_MANAGEMENT', '风险管理', 'risk management', 'ESCO', 'http://data.europa.eu/esco/skill/6eff134b-e34f-4d6e-a6e8-5e47cf2228d0', 1, 15),
(16, 'INSTRUCTIONAL_DESIGN', '教学设计', 'instructional design models', 'ESCO', 'http://data.europa.eu/esco/skill/e3145bd7-7f35-44ec-a5dc-4417c5c9ab9a', 1, 16),
(17, 'CURRICULUM_DEVELOPMENT', '课程开发', 'advise on curriculum development', 'ESCO', 'http://data.europa.eu/esco/skill/142a5319-60cb-42a2-891f-90ba92af18b1', 1, 17),
(18, 'LEARNING_MANAGEMENT_SYSTEMS', '学习管理系统', 'learning management systems', 'ESCO', 'http://data.europa.eu/esco/skill/6c80d53c-d8c9-41fe-998f-091fca208834', 1, 18),
(19, 'EDUCATIONAL_TECHNOLOGY', '教育技术', 'develop e-learning plan', 'ESCO', 'http://data.europa.eu/esco/skill/8a6394d1-f44f-4b0d-81d5-7201fc025ba8', 1, 19),
(20, 'COMMUNICATION', '沟通能力', 'communication', 'ESCO', 'http://data.europa.eu/esco/skill/15d76317-c71a-4fa2-aadc-2ecc34e627b7', 1, 20),
(21, 'CRITICAL_THINKING', '批判性思维', 'use logical reasoning', 'ESCO', 'http://data.europa.eu/esco/skill/a9e6718b-b9d8-44c2-b06e-85ceed9d144c', 1, 21),
(22, 'COLLABORATION', '团队协作', 'cooperate with colleagues', 'ESCO', 'http://data.europa.eu/esco/skill/082967d4-b94c-4305-aa13-35a6aa8c5c7c', 1, 22),
(23, 'PROJECT_MANAGEMENT', '项目管理', 'project management', 'ESCO', 'http://data.europa.eu/esco/skill/7111b95d-0ce3-441a-9d92-4c75d05c4388', 1, 23),
(24, 'AGILE', '敏捷开发', 'Agile development', 'ESCO', 'http://data.europa.eu/esco/skill/dba46f87-0831-49cd-a1c7-340a653c0221', 1, 24),
(25, 'JENKINS', 'Jenkins', 'Jenkins (tools for software configuration management)', 'ESCO', 'http://data.europa.eu/esco/skill/f47a1998-0beb-43be-9f46-380aa4d183da', 1, 25),
(26, 'REACT', 'React', 'React', 'O*NET', '15-1254.00|React|2.E.7.h', 2, 26),
(27, 'VUE_JS', 'Vue.js', 'Vue.js', 'O*NET', '15-1254.00|Vue.js|2.E.7.h', 2, 27),
(28, 'NODE_JS', 'Node.js', 'Node.js', 'O*NET', '15-1254.00|Node.js|2.E.7.h', 2, 28),
(29, 'SPRING_BOOT', 'Spring Boot', 'Spring Boot', 'O*NET', '15-1252.00|Spring Boot|2.E.10.a', 2, 29),
(30, 'DJANGO', 'Django', 'Django', 'O*NET', '15-1252.00|Django|2.E.7.h', 2, 30),
(31, 'FLASK', 'Flask', 'Flask', 'O*NET', '15-2051.00|Flask|2.E.7.b', 2, 31),
(32, 'GIT', 'Git', 'Git', 'O*NET', '15-1252.00|Git|2.E.5.c', 2, 32),
(33, 'DOCKER', 'Docker', 'Docker', 'O*NET', '15-1252.00|Docker|2.E.10.a', 2, 33),
(34, 'KUBERNETES', 'Kubernetes', 'Kubernetes', 'O*NET', '15-1252.00|Kubernetes|2.E.10.a', 2, 34),
(35, 'GRADLE', 'Gradle', 'Gradle', 'O*NET', '15-1252.00|Gradle|2.E.7.e', 2, 35),
(36, 'MONGODB', 'MongoDB', 'MongoDB', 'O*NET', '15-1252.00|MongoDB|2.E.6.d', 2, 36),
(37, 'REDIS', 'Redis', 'Redis', 'O*NET', '15-1252.00|Redis|2.E.6.d', 2, 37),
(38, 'SELENIUM', 'Selenium', 'Selenium', 'O*NET', '15-1253.00|Selenium|2.E.7.f', 2, 38),
(39, 'JUNIT', 'JUnit', 'JUnit', 'O*NET', '15-1253.00|JUnit|2.E.7.f', 2, 39),
(40, 'POSTMAN', 'Postman', 'Postman', 'O*NET', '15-1253.00|Postman|2.E.13.c', 2, 40),
(41, 'PANDAS', 'pandas', 'pandas', 'O*NET', '15-2051.00|pandas|2.E.6.f', 2, 41),
(42, 'NUMPY', 'NumPy', 'NumPy', 'O*NET', '15-2051.00|NumPy|2.E.6.f', 2, 42),
(43, 'TABLEAU', 'Tableau', 'Tableau', 'O*NET', '15-2051.00|Tableau|2.E.6.m', 2, 43),
(44, 'POWER_BI', 'Microsoft Power BI', 'Microsoft Power BI', 'O*NET', '15-2051.00|Microsoft Power BI|2.E.6.m', 2, 44),
(45, 'MICROSOFT_EXCEL', 'Microsoft Excel', 'Microsoft Excel', 'O*NET', '15-2051.00|Microsoft Excel|2.E.4.i', 2, 45),
(46, 'TENSORFLOW', 'TensorFlow', 'TensorFlow', 'O*NET', '15-2051.00|TensorFlow|2.E.9.d', 2, 46),
(47, 'PYTORCH', 'PyTorch', 'PyTorch', 'O*NET', '15-2051.00|PyTorch|2.E.6.f', 2, 47),
(48, 'SCIKIT_LEARN', 'Scikit-learn', 'Scikit-learn', 'O*NET', '15-2051.00|Scikit-learn|2.E.7.b', 2, 48),
(49, 'APACHE_SPARK', 'Apache Spark', 'Apache Spark', 'O*NET', '15-2051.00|Apache Spark|2.E.6.m', 2, 49),
(50, 'FIGMA', 'Figma', 'Figma', 'O*NET', '15-1254.00|Figma|2.E.7.d', 2, 50),
(51, 'BASH', 'Bash', 'Bash', 'O*NET', '15-1252.00|Bash|2.E.13.c', 2, 51),
(52, 'GO', 'Go', 'Go', 'O*NET', '15-1252.00|Go|2.E.7.b', 2, 52),
(53, 'CPP', 'C++', 'C++', 'O*NET', '15-1252.00|C++|2.E.7.e', 2, 53),
(54, 'GOOGLE_CHROME', 'Google Chrome', 'Google Chrome', 'O*NET', '17-2051.02|Google Chrome|2.E.10.e', 2, 54),
(55, 'DEBUGGING_SOFTWARE', '调试软件', 'Debugging software', 'O*NET', '17-2071.00|Debugging software|2.E.7.f', 2, 55),
(56, 'APACHE_KAFKA', 'Apache Kafka', 'Apache Kafka', 'O*NET', '15-1299.07|Apache Kafka|2.E.7.b', 2, 56),
(57, 'SONARQUBE', 'SonarQube', 'SonarQube', 'O*NET', '15-1252.00|SonarQube|2.E.7.f', 2, 57),
(58, 'CHEF', 'Chef', 'Chef', 'O*NET', '15-1255.00|Chef|2.E.7.a', 2, 58),
(59, 'GITLAB', 'GitLab', 'GitLab', 'O*NET', '15-1299.08|GitLab|2.E.10.a', 2, 59),
(60, 'NAGIOS', 'Nagios', 'Nagios', 'O*NET', '11-3021.00|Nagios|2.E.11.a', 2, 60),
(61, 'NMAP', 'Nmap', 'Nmap', 'O*NET', '13-1199.07|Nmap|2.E.14.d', 2, 61),
(62, 'BLOOMBERG_PROFESSIONAL', 'Bloomberg Professional', 'Bloomberg Professional', 'O*NET', '41-3031.00|Bloomberg Professional|2.E.2.d', 2, 62),
(63, 'MOODLE', 'Moodle', 'Moodle', 'O*NET', '25-2022.00|Moodle|2.E.8.b', 2, 63),
(64, 'ARTICULATE_STORYLINE', 'Articulate Storyline', 'Articulate Storyline', 'O*NET', '25-9031.00|Articulate Storyline|2.E.8.b', 2, 64),
(65, 'MICROSOFT_POWERPOINT', 'Microsoft PowerPoint', 'Microsoft PowerPoint', 'O*NET', '27-4012.00|Microsoft PowerPoint|2.E.4.f', 2, 65),
(66, 'AXURE_RP', 'Axure RP', 'Axure RP', 'O*NET', '15-1255.00|Axure RP|2.E.4.g', 2, 66),
(67, 'ADOBE_PHOTOSHOP', 'Adobe Photoshop', 'Adobe Photoshop', 'O*NET', '19-3092.00|Adobe Photoshop|2.E.4.b', 2, 67),
(68, 'APACHE_JMETER', 'Apache JMeter', 'Apache JMeter', 'O*NET', '15-1253.00|Apache JMeter|2.E.7.e', 2, 68),
(69, 'ADOBE_ACROBAT', 'Adobe Acrobat', 'Adobe Acrobat', 'O*NET', '11-1011.00|Adobe Acrobat|2.E.5.b', 2, 69),
(70, 'MICROSOFT_AZURE', 'Microsoft Azure', 'Microsoft Azure software', 'O*NET', '41-4011.00|Microsoft Azure software|2.E.7.b', 2, 70),
(71, 'AWS', 'AWS', 'Amazon Web Services AWS software', 'O*NET', '11-1021.00|Amazon Web Services AWS software|2.E.6.f', 2, 71),
(72, 'WIRESHARK', 'Wireshark', 'Wireshark', 'O*NET', '15-1241.01|Wireshark|2.E.11.a', 2, 72);
INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias) VALUES
(1, 1, 'Java (computer programming)', 'java (computer programming)'),
(2, 2, 'Python (computer programming)', 'python (computer programming)'),
(3, 3, 'JavaScript', 'javascript'),
(4, 4, 'TypeScript', 'typescript'),
(5, 5, 'SQL', 'sql'),
(6, 6, 'C#', 'c#'),
(7, 7, 'HTML', 'html'),
(8, 8, 'CSS', 'css'),
(9, 9, 'R', 'r'),
(10, 10, 'MySQL', 'mysql'),
(11, 11, 'PostgreSQL', 'postgresql'),
(12, 12, 'statistics', 'statistics'),
(13, 13, 'machine learning', 'machine learning'),
(14, 14, 'financial analysis', 'financial analysis'),
(15, 15, 'risk management', 'risk management'),
(16, 16, 'instructional design', 'instructional design'),
(17, 17, 'curriculum development', 'curriculum development'),
(18, 18, 'learning management systems', 'learning management systems'),
(19, 19, 'educational technology', 'educational technology'),
(20, 20, 'communication', 'communication'),
(21, 21, 'critical thinking', 'critical thinking'),
(22, 22, 'collaboration', 'collaboration'),
(23, 23, 'project management', 'project management'),
(24, 24, 'agile', 'agile'),
(25, 25, 'Jenkins (tools for software configuration management)', 'jenkins (tools for software configuration management)'),
(26, 26, 'React', 'react'),
(27, 27, 'Vue.js', 'vuejs'),
(28, 28, 'Node.js', 'nodejs'),
(29, 29, 'Spring Boot', 'spring boot'),
(30, 30, 'Django', 'django'),
(31, 31, 'Flask', 'flask'),
(32, 32, 'Git', 'git'),
(33, 33, 'Docker', 'docker'),
(34, 34, 'Kubernetes', 'kubernetes'),
(35, 35, 'Gradle', 'gradle'),
(36, 36, 'MongoDB', 'mongodb'),
(37, 37, 'Redis', 'redis'),
(38, 38, 'Selenium', 'selenium'),
(39, 39, 'JUnit', 'junit'),
(40, 40, 'Postman', 'postman'),
(41, 41, 'pandas', 'pandas'),
(42, 42, 'NumPy', 'numpy'),
(43, 43, 'Tableau', 'tableau'),
(44, 44, 'Microsoft Power BI', 'microsoft power bi'),
(45, 45, 'Microsoft Excel', 'microsoft excel'),
(46, 46, 'TensorFlow', 'tensorflow'),
(47, 47, 'PyTorch', 'pytorch'),
(48, 48, 'scikit-learn', 'scikit learn'),
(49, 49, 'Apache Spark', 'apache spark'),
(50, 50, 'Figma', 'figma'),
(51, 51, 'Bash', 'bash'),
(52, 52, 'Go', 'go'),
(53, 53, 'C++', 'c++'),
(54, 54, 'Google Chrome', 'google chrome'),
(55, 55, 'debugging software', 'debugging software'),
(56, 56, 'Apache Kafka', 'apache kafka'),
(57, 57, 'SonarQube', 'sonarqube'),
(58, 58, 'Chef', 'chef'),
(59, 59, 'GitLab', 'gitlab'),
(60, 60, 'Nagios', 'nagios'),
(61, 61, 'Nmap', 'nmap'),
(62, 62, 'Bloomberg Professional', 'bloomberg professional'),
(63, 63, 'Moodle', 'moodle'),
(64, 64, 'Articulate Storyline', 'articulate storyline'),
(65, 65, 'Microsoft PowerPoint', 'microsoft powerpoint'),
(66, 66, 'Axure RP', 'axure rp'),
(67, 67, 'Adobe Photoshop', 'adobe photoshop'),
(68, 68, 'Apache JMeter', 'apache jmeter'),
(69, 69, 'Adobe Acrobat', 'adobe acrobat'),
(70, 70, 'Microsoft Azure software', 'microsoft azure software'),
(71, 71, 'Amazon Web Services AWS software', 'amazon web services aws software'),
(72, 72, 'Wireshark', 'wireshark');
INSERT INTO occupation_skills (id, occupation_id, skill_id, category_id, importance, importance_source_code, importance_element_id, importance_is_proxy, relationship_type, source_version_id) VALUES
(1, 1, 3, 101, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(2, 1, 4, 101, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(3, 1, 7, 102, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(4, 1, 8, 102, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(5, 1, 26, 103, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(6, 1, 27, 103, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(7, 1, 28, 104, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(8, 1, 35, 104, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(9, 1, 54, 105, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(10, 1, 55, 105, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(11, 1, 38, 106, 3.33, '15-1254.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(12, 1, 39, 106, 3.33, '15-1254.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(13, 1, 32, 107, 3.88, '15-1254.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2),
(14, 1, 24, 107, 3.88, '15-1254.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2),
(15, 2, 1, 201, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(16, 2, 2, 201, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(17, 2, 29, 202, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(18, 2, 30, 202, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(19, 2, 10, 203, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(20, 2, 11, 203, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(21, 2, 37, 204, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(22, 2, 56, 204, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(23, 2, 33, 205, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(24, 2, 34, 205, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(25, 2, 39, 206, 3.94, '15-1252.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(26, 2, 40, 206, 3.94, '15-1252.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(27, 2, 32, 207, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(28, 2, 35, 207, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(29, 2, 61, 208, 2.57, '15-1252.00', '2.C.8.a', 1, 'ESSENTIAL', 2),
(30, 2, 57, 208, 2.57, '15-1252.00', '2.C.8.a', 1, 'ESSENTIAL', 2),
(31, 3, 1, 301, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(32, 3, 2, 301, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(33, 3, 40, 302, 4.24, '15-1253.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(34, 3, 21, 302, 4.24, '15-1253.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(35, 3, 38, 303, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(36, 3, 39, 303, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(37, 3, 5, 304, 4.38, '15-1253.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(38, 3, 10, 304, 4.38, '15-1253.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(39, 3, 68, 305, 2.94, '15-1253.00', '4.A.1.b.3', 1, 'ESSENTIAL', 2),
(40, 3, 51, 305, 2.94, '15-1253.00', '4.A.1.b.3', 1, 'ESSENTIAL', 2),
(41, 3, 25, 306, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(42, 3, 59, 306, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(43, 4, 2, 401, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(44, 4, 51, 401, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(45, 4, 71, 402, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(46, 4, 70, 402, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(47, 4, 33, 403, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(48, 4, 34, 403, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(49, 4, 25, 404, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(50, 4, 59, 404, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(51, 4, 58, 405, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(52, 4, 32, 405, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(53, 4, 60, 406, 3.60, '15-1244.00', '4.A.1.a.2', 1, 'ESSENTIAL', 2),
(54, 4, 72, 406, 3.60, '15-1244.00', '4.A.1.a.2', 1, 'ESSENTIAL', 2),
(55, 4, 61, 407, 2.34, '15-1244.00', '2.C.8.a', 1, 'ESSENTIAL', 2),
(56, 4, 57, 407, 2.34, '15-1244.00', '2.C.8.a', 1, 'ESSENTIAL', 2),
(57, 4, 10, 408, 4.03, '15-1244.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(58, 4, 11, 408, 4.03, '15-1244.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(59, 5, 2, 501, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(60, 5, 9, 501, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(61, 5, 41, 502, 4.01, '13-2031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(62, 5, 43, 502, 4.01, '13-2031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(63, 5, 5, 503, 4.44, '13-2031.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(64, 5, 11, 503, 4.44, '13-2031.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(65, 5, 12, 504, 4.03, '13-2031.00', '2.C.4.a', 1, 'ESSENTIAL', 2),
(66, 5, 13, 504, 4.03, '13-2031.00', '2.C.4.a', 1, 'ESSENTIAL', 2),
(67, 5, 62, 505, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(68, 5, 45, 505, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(69, 5, 14, 506, 4.42, '13-2031.00', '2.C.1.c', 1, 'ESSENTIAL', 2),
(70, 5, 15, 506, 4.42, '13-2031.00', '2.C.1.c', 1, 'ESSENTIAL', 2),
(71, 5, 32, 507, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(72, 5, 44, 507, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(73, 6, 2, 601, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(74, 6, 9, 601, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(75, 6, 12, 602, 3.27, '13-2053.00', '2.C.4.a', 1, 'ESSENTIAL', 2),
(76, 6, 13, 602, 3.27, '13-2053.00', '2.C.4.a', 1, 'ESSENTIAL', 2),
(77, 6, 15, 603, 4.43, '13-2053.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(78, 6, 21, 603, 4.43, '13-2053.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(79, 6, 46, 604, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(80, 6, 48, 604, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(81, 6, 5, 605, 4.13, '13-2053.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(82, 6, 49, 605, 4.13, '13-2053.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(83, 6, 39, 606, 3.90, '13-2053.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(84, 6, 57, 606, 3.90, '13-2053.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(85, 6, 33, 607, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(86, 6, 32, 607, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(87, 7, 2, 701, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(88, 7, 53, 701, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(89, 7, 52, 701, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(90, 7, 42, 702, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(91, 7, 48, 702, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(92, 7, 62, 703, 4.20, '13-2099.01', '2.C.1.c', 1, 'ESSENTIAL', 2),
(93, 7, 14, 703, 4.20, '13-2099.01', '2.C.1.c', 1, 'ESSENTIAL', 2),
(94, 7, 11, 704, 4.50, '13-2099.01', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(95, 7, 5, 704, 4.50, '13-2099.01', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(96, 7, 49, 705, 3.53, '13-2099.01', '4.A.1.b.3', 1, 'ESSENTIAL', 2),
(97, 7, 56, 705, 3.53, '13-2099.01', '4.A.1.b.3', 1, 'ESSENTIAL', 2),
(98, 7, 39, 706, 3.05, '13-2099.01', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(99, 7, 68, 706, 3.05, '13-2099.01', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(100, 7, 33, 707, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(101, 7, 34, 707, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(102, 8, 16, 801, 2.52, '13-1082.00', '2.C.6', 1, 'ESSENTIAL', 2),
(103, 8, 17, 801, 2.52, '13-1082.00', '2.C.6', 1, 'ESSENTIAL', 2),
(104, 8, 66, 802, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(105, 8, 44, 802, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(106, 8, 23, 803, 3.73, '13-1082.00', '4.A.2.b.4', 1, 'ESSENTIAL', 2),
(107, 8, 24, 803, 3.73, '13-1082.00', '4.A.2.b.4', 1, 'ESSENTIAL', 2),
(108, 8, 43, 804, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(109, 8, 2, 804, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(110, 8, 18, 805, 4.45, '13-1082.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(111, 8, 63, 805, 4.45, '13-1082.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(112, 8, 32, 806, 4.73, '13-1082.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2),
(113, 8, 45, 806, 4.73, '13-1082.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2),
(114, 9, 16, 901, 4.78, '25-9031.00', '2.C.6', 1, 'ESSENTIAL', 2),
(115, 9, 17, 901, 4.78, '25-9031.00', '2.C.6', 1, 'ESSENTIAL', 2),
(116, 9, 18, 902, 4.45, '25-9031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(117, 9, 63, 902, 4.45, '25-9031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(118, 9, 64, 903, 3.72, '25-9031.00', '4.A.2.b.2', 1, 'ESSENTIAL', 2),
(119, 9, 50, 903, 3.72, '25-9031.00', '4.A.2.b.2', 1, 'ESSENTIAL', 2),
(120, 9, 19, 904, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(121, 9, 21, 904, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(122, 9, 12, 905, 4.19, '25-9031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(123, 9, 45, 905, 4.19, '25-9031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(124, 9, 69, 906, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(125, 9, 20, 906, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2),
(126, 10, 2, 1001, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(127, 10, 9, 1001, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(128, 10, 43, 1002, 4.83, '15-2051.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(129, 10, 44, 1002, 4.83, '15-2051.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2),
(130, 10, 5, 1003, 4.71, '15-2051.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(131, 10, 11, 1003, 4.71, '15-2051.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2),
(132, 10, 12, 1004, 4.52, '15-2051.00', '2.C.4.a', 1, 'ESSENTIAL', 2),
(133, 10, 13, 1004, 4.52, '15-2051.00', '2.C.4.a', 1, 'ESSENTIAL', 2),
(134, 10, 17, 1005, 2.28, '15-2051.00', '2.C.6', 1, 'ESSENTIAL', 2),
(135, 10, 19, 1005, 2.28, '15-2051.00', '2.C.6', 1, 'ESSENTIAL', 2),
(136, 10, 45, 1006, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(137, 10, 65, 1006, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2),
(138, 1, 50, 104, NULL, NULL, NULL, 0, 'OPTIONAL', 2),
(139, 9, 67, 903, NULL, NULL, NULL, 0, 'OPTIONAL', 2);

12
mydreamjob-server/src/main/resources/db/migration/V37__skill_cloud_boolean_type.sql

@ -0,0 +1,12 @@
-- Flyway V37: 将技能代理标记规范化为跨方言 BOOLEAN。
-- 不改写 V35/V36,确保已应用迁移的 Flyway checksum 保持有效。
ALTER TABLE occupation_skills
DROP CONSTRAINT ck_occupation_skills_importance_proxy;
ALTER TABLE occupation_skills
MODIFY COLUMN importance_is_proxy BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE occupation_skills
ADD CONSTRAINT ck_occupation_skills_importance_proxy
CHECK (importance_is_proxy IN (FALSE, TRUE));

236
mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java

@ -0,0 +1,236 @@
package com.mydreamjob.skill;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.skill.dto.SkillCloudItem;
import com.mydreamjob.skill.dto.SkillCloudNode;
import com.mydreamjob.skill.dto.SkillCloudResponse;
import com.mydreamjob.skill.dto.SkillLearningPathItem;
import com.mydreamjob.skill.dto.SkillLearningPathResponse;
import com.mydreamjob.skill.dto.SkillMatchResponse;
import org.junit.jupiter.api.AfterEach;
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.data.redis.core.ValueOperations;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
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.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:skill_controller_auth;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class SkillCloudAuthorizationIntegrationTest {
private static final String WORD_CLOUD_PATH = "/skills/word-cloud";
private static final String MATCH_PATH = "/skills/match";
private static final String LEARNING_PATH = "/skills/learning-path";
private static final List<String> PATHS = List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH);
private static final AtomicLong PHONE = new AtomicLong(920_000_000L);
@Autowired
private MockMvc mockMvc;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private UserRepository userRepository;
@MockBean
private SkillCloudQueryService skillCloudQueryService;
@MockBean
private SkillMatchService skillMatchService;
@MockBean
private SkillLearningPathService skillLearningPathService;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> operations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(operations);
when(redisTemplate.hasKey(any())).thenReturn(false);
when(skillCloudQueryService.query(1L, 1L, null)).thenReturn(cloudResponse());
when(skillMatchService.match(anyLong(), eq(1L), eq(1L), isNull())).thenReturn(matchResponse());
when(skillLearningPathService.generate(anyLong(), eq(1L), eq(1L), isNull()))
.thenReturn(learningPathResponse());
}
@AfterEach
void tearDown() {
userRepository.deleteAll();
}
@Test
void databaseRoleSetsControlAllSkillEndpoints() throws Exception {
Long student = createUser(Set.of(User.Role.STUDENT));
Long company = createUser(Set.of(User.Role.COMPANY));
Long admin = createUser(Set.of(User.Role.ADMIN));
Long studentCompany = createUser(Set.of(User.Role.STUDENT, User.Role.COMPANY));
Long companyAdmin = createUser(Set.of(User.Role.COMPANY, User.Role.ADMIN));
for (String path : PATHS) {
assertStatus(path, student, 200);
assertStatus(path, company, 403);
assertStatus(path, admin, 403);
assertStatus(path, studentCompany, 200);
assertStatus(path, companyAdmin, 403);
}
}
@Test
void anonymousAndInvalidJwtUseSecurityEntryPointContract() throws Exception {
for (String path : PATHS) {
assertUnauthorized(path, null);
assertUnauthorized(path, "Bearer invalid-token");
}
}
@Test
void identityQueryParametersCannotOverrideAuthenticatedSubject() throws Exception {
Long student = createUser(Set.of(User.Role.STUDENT));
Long company = createUser(Set.of(User.Role.COMPANY));
for (String path : PATHS) {
mockMvc.perform(get(path)
.param("industryId", "1")
.param("occupationId", "1")
.param("userId", company.toString())
.param("studentId", company.toString())
.header("Authorization", bearerToken(student)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
}
verify(skillCloudQueryService).query(1L, 1L, null);
verify(skillMatchService).match(student, 1L, 1L, null);
verify(skillLearningPathService).generate(student, 1L, 1L, null);
}
private void assertUnauthorized(String path, String authorization) throws Exception {
var request = get(path)
.param("industryId", "1")
.param("occupationId", "1");
if (authorization != null) {
request.header("Authorization", authorization);
}
mockMvc.perform(request)
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401))
.andExpect(jsonPath("$.message").value("not authenticated"))
.andExpect(jsonPath("$.timestamp").value(""))
.andExpect(jsonPath("$.requestId").doesNotExist())
.andExpect(jsonPath("$.data").doesNotExist());
}
private void assertStatus(String path, Long userId, int expectedStatus) throws Exception {
var request = get(path)
.param("industryId", "1")
.param("occupationId", "1")
.header("Authorization", bearerToken(userId));
if (expectedStatus == 200) {
mockMvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
} else {
mockMvc.perform(request)
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(2002));
}
}
private Long createUser(Set<User.Role> roles) {
User user = User.builder()
.phone(String.valueOf(PHONE.getAndIncrement()))
.role(roles.iterator().next())
.roles(Set.copyOf(roles))
.active(true)
.verified(true)
.build();
return userRepository.save(user).getId();
}
private String bearerToken(Long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(
userId,
String.valueOf(userId),
Map.of("roles", "STUDENT")
);
}
private SkillCloudResponse cloudResponse() {
return new SkillCloudResponse(
new SkillCloudNode(1L, "INTERNET", "互联网"),
new SkillCloudNode(1L, "FRONTEND_DEVELOPER", "前端开发工程师"),
null,
1,
List.of(skill())
);
}
private SkillMatchResponse matchResponse() {
return new SkillMatchResponse(100, 1, 1, List.of(skill()), List.of());
}
private SkillLearningPathResponse learningPathResponse() {
return new SkillLearningPathResponse(
1,
List.of(new SkillLearningPathItem(
1,
3L,
"JavaScript",
101L,
"编程语言",
"先掌握 JavaScript 的基础语法和常见数据结构,再完成至少一个编码练习。"
))
);
}
private SkillCloudItem skill() {
return new SkillCloudItem(
1,
3L,
"JAVASCRIPT",
"JavaScript",
"JavaScript",
"ESCO",
"http://data.europa.eu/esco/skill/javascript",
101L,
"编程语言",
new BigDecimal("4.38"),
"15-1254.00",
"2.B.3.e",
false
);
}
}

430
mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java

@ -0,0 +1,430 @@
package com.mydreamjob.skill;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.skill.dto.SkillCloudItem;
import com.mydreamjob.skill.dto.SkillCloudNode;
import com.mydreamjob.skill.dto.SkillCloudResponse;
import com.mydreamjob.skill.dto.SkillLearningPathItem;
import com.mydreamjob.skill.dto.SkillLearningPathResponse;
import com.mydreamjob.skill.dto.SkillMatchResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
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.data.redis.core.ValueOperations;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
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.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:skill_controller_api;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class SkillCloudControllerTest {
private static final String WORD_CLOUD_PATH = "/skills/word-cloud";
private static final String MATCH_PATH = "/skills/match";
private static final String LEARNING_PATH = "/skills/learning-path";
private static final AtomicLong PHONE = new AtomicLong(910_000_000L);
private Long studentId;
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private com.mydreamjob.auth.JwtTokenProvider jwtTokenProvider;
@Autowired
private UserRepository userRepository;
@MockBean
private SkillCloudQueryService skillCloudQueryService;
@MockBean
private SkillMatchService skillMatchService;
@MockBean
private SkillLearningPathService skillLearningPathService;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> operations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(operations);
when(redisTemplate.hasKey(any())).thenReturn(false);
studentId = userRepository.save(User.builder()
.phone(String.valueOf(PHONE.getAndIncrement()))
.role(User.Role.STUDENT)
.roles(Set.of(User.Role.STUDENT))
.active(true)
.verified(true)
.build()).getId();
when(skillCloudQueryService.query(1L, 1L, null)).thenReturn(cloudResponse());
when(skillMatchService.match(studentId, 1L, 1L, null)).thenReturn(matchResponse());
when(skillLearningPathService.generate(studentId, 1L, 1L, null)).thenReturn(learningPathResponse());
}
@AfterEach
void tearDown() {
userRepository.deleteAll();
}
@Test
void wordCloudStudentRequestReturnsServicePayload() throws Exception {
mockMvc.perform(get(WORD_CLOUD_PATH)
.param("industryId", "1")
.param("occupationId", "1")
.header("Authorization", studentToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.industry.code").value("INTERNET"))
.andExpect(jsonPath("$.data.occupation.code").value("FRONTEND_DEVELOPER"))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].skillId").value(3))
.andExpect(jsonPath("$.data.items[0].nameZh").value("JavaScript"))
.andExpect(jsonPath("$.data.items[0].sourceType").value("ESCO"));
}
@Test
void matchStudentRequestReturnsServicePayload() throws Exception {
mockMvc.perform(get(MATCH_PATH)
.param("industryId", "1")
.param("occupationId", "1")
.header("Authorization", studentToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.matchScore").value(100))
.andExpect(jsonPath("$.data.matchedCount").value(1))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.matchedSkills[0].skillId").value(3))
.andExpect(jsonPath("$.data.missingSkills").isEmpty());
}
@Test
void learningPathStudentRequestReturnsServicePayload() throws Exception {
mockMvc.perform(get(LEARNING_PATH)
.param("industryId", "1")
.param("occupationId", "1")
.header("Authorization", studentToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].priority").value(1))
.andExpect(jsonPath("$.data.items[0].skillId").value(3))
.andExpect(jsonPath("$.data.items[0].suggestion")
.value("先掌握 JavaScript 的基础语法和常见数据结构,再完成至少一个编码练习。"));
}
@ParameterizedTest
@ValueSource(strings = {WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH})
void anonymousRequestUsesSecurityEntryPointContract(String path) throws Exception {
var result = mockMvc.perform(get(path)
.param("industryId", "1")
.param("occupationId", "1"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401))
.andExpect(jsonPath("$.message").value("not authenticated"))
.andExpect(jsonPath("$.timestamp").value(""))
.andExpect(jsonPath("$.requestId").doesNotExist())
.andExpect(jsonPath("$.data").doesNotExist())
.andReturn();
JsonNode body = objectMapper.readTree(
result.getResponse().getContentAsString(StandardCharsets.UTF_8));
assertFields(body, Set.of("code", "message", "timestamp"));
}
@Test
void missingRequiredParametersReturnBadRequest() throws Exception {
mockMvc.perform(get(WORD_CLOUD_PATH)
.param("occupationId", "1")
.header("Authorization", studentToken()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
mockMvc.perform(get(WORD_CLOUD_PATH)
.param("industryId", "1")
.header("Authorization", studentToken()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
void nonPositiveParametersReturnBadRequest() throws Exception {
mockMvc.perform(get(WORD_CLOUD_PATH)
.param("industryId", "0")
.param("occupationId", "1")
.header("Authorization", studentToken()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
mockMvc.perform(get(MATCH_PATH)
.param("industryId", "1")
.param("occupationId", "-1")
.header("Authorization", studentToken()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
mockMvc.perform(get(LEARNING_PATH)
.param("industryId", "1")
.param("occupationId", "1")
.param("categoryId", "0")
.header("Authorization", studentToken()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
void identityComesOnlyFromAuthenticatedPrincipal() throws Exception {
mockMvc.perform(get(MATCH_PATH)
.param("industryId", "1")
.param("occupationId", "1")
.param("userId", "999")
.param("studentId", "888")
.header("Authorization", studentToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.matchScore").value(100));
verify(skillMatchService).match(eq(studentId), eq(1L), eq(1L), isNull());
}
@ParameterizedTest
@ValueSource(strings = {WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH})
void successfulResponsesDoNotExposePrivateProfileFields(String path) throws Exception {
String body = mockMvc.perform(get(path)
.param("industryId", "1")
.param("occupationId", "1")
.header("Authorization", studentToken()))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8);
assertThat(body)
.doesNotContain("skills_json")
.doesNotContain("phone")
.doesNotContain("email")
.doesNotContain("resume");
}
@Test
void skillOperationIdsAreExactAndGloballyUnique() throws Exception {
String json = mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8);
JsonNode paths = objectMapper.readTree(json).path("paths");
Map<String, List<String>> locationsByOperationId = new HashMap<>();
paths.fields().forEachRemaining(path -> path.getValue().fields().forEachRemaining(operation -> {
String operationId = operation.getValue().path("operationId").asText();
if (!operationId.isBlank()) {
locationsByOperationId
.computeIfAbsent(operationId, ignored -> new ArrayList<>())
.add(operation.getKey().toUpperCase(Locale.ROOT) + " " + path.getKey());
}
}));
Map<String, String> expectedOperationIds = Map.of(
WORD_CLOUD_PATH, "getWordCloud",
MATCH_PATH, "getSkillMatch",
LEARNING_PATH, "getLearningPath");
expectedOperationIds.forEach((path, operationId) -> {
assertThat(paths.path(path).path("get").path("operationId").asText()).isEqualTo(operationId);
assertThat(locationsByOperationId.getOrDefault(operationId, List.of()))
.containsExactly("GET " + path);
});
}
@Test
void openApiDocumentsStudentProtectedSkillEndpoints() throws Exception {
String json = mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8);
JsonNode root = objectMapper.readTree(json);
JsonNode paths = root.path("paths");
for (String path : List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH)) {
JsonNode get = paths.path(path).path("get");
assertThat(get.isMissingNode()).isFalse();
assertThat(get.path("security").toString()).contains("bearerAuth");
assertThat(get.path("responses").has("200")).isTrue();
assertThat(get.path("responses").has("400")).isTrue();
assertThat(get.path("responses").has("401")).isTrue();
assertThat(get.path("responses").has("403")).isTrue();
JsonNode parameters = get.path("parameters");
assertThat(parameter(parameters, "industryId").path("required").asBoolean()).isTrue();
assertThat(parameter(parameters, "occupationId").path("required").asBoolean()).isTrue();
assertThat(parameter(parameters, "categoryId").path("required").asBoolean()).isFalse();
}
}
@Test
void openApiSuccessExamplesMatchDtoContract() throws Exception {
String json = mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8);
JsonNode paths = objectMapper.readTree(json).path("paths");
JsonNode wordCloud = successExample(paths.path(WORD_CLOUD_PATH).path("get"));
JsonNode wordCloudData = wordCloud.path("data");
assertFields(wordCloudData, Set.of("industry", "occupation", "category", "total", "items"));
assertThat(wordCloudData.path("total").asInt()).isEqualTo(wordCloudData.path("items").size());
assertSkillItemContract(wordCloudData.path("items").get(0));
JsonNode match = successExample(paths.path(MATCH_PATH).path("get"));
JsonNode matchData = match.path("data");
assertFields(matchData, Set.of("matchScore", "matchedCount", "totalCount", "matchedSkills", "missingSkills"));
int matchedCount = matchData.path("matchedCount").asInt();
int totalCount = matchData.path("totalCount").asInt();
assertThat(matchData.path("matchedSkills").size()).isEqualTo(matchedCount);
assertThat(matchData.path("missingSkills").size()).isEqualTo(totalCount - matchedCount);
int expectedScore = totalCount == 0 ? 0 : (int) Math.round(matchedCount * 100.0 / totalCount);
assertThat(matchData.path("matchScore").asInt()).isEqualTo(expectedScore);
assertSkillItemContract(matchData.path("matchedSkills").get(0));
assertSkillItemContract(matchData.path("missingSkills").get(0));
JsonNode learningPath = successExample(paths.path(LEARNING_PATH).path("get"));
JsonNode learningData = learningPath.path("data");
assertFields(learningData, Set.of("total", "items"));
assertThat(learningData.path("total").asInt()).isEqualTo(learningData.path("items").size());
assertFields(learningData.path("items").get(0),
Set.of("priority", "skillId", "skillName", "categoryId", "categoryName", "suggestion"));
}
private JsonNode successExample(JsonNode get) throws Exception {
JsonNode value = get.path("responses").path("200").path("content")
.path("application/json").path("examples").path("success").path("value");
assertThat(value.isMissingNode()).isFalse();
return value.isTextual() ? objectMapper.readTree(value.asText()) : value;
}
private void assertSkillItemContract(JsonNode item) {
assertThat(item.isMissingNode()).isFalse();
assertFields(item, Set.of(
"rank", "skillId", "skillCode", "nameZh", "nameEn", "sourceType", "sourceCode",
"categoryId", "categoryName", "heat", "heatSourceCode", "heatElementId", "heatProxy"));
}
private void assertFields(JsonNode object, Set<String> expectedFields) {
Set<String> actualFields = new HashSet<>();
object.fieldNames().forEachRemaining(actualFields::add);
assertThat(actualFields).containsExactlyInAnyOrderElementsOf(expectedFields);
}
private JsonNode parameter(JsonNode parameters, String name) {
for (JsonNode parameter : parameters) {
if (name.equals(parameter.path("name").asText())) {
return parameter;
}
}
return objectMapper.missingNode();
}
private String studentToken() {
return token(studentId, "STUDENT");
}
private String token(long userId, String role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(
userId,
"1380013" + String.format("%04d", userId),
Map.of("roles", role)
);
}
private SkillCloudResponse cloudResponse() {
SkillCloudItem skill = skill();
return new SkillCloudResponse(
new SkillCloudNode(1L, "INTERNET", "互联网"),
new SkillCloudNode(1L, "FRONTEND_DEVELOPER", "前端开发工程师"),
null,
1,
List.of(skill)
);
}
private SkillMatchResponse matchResponse() {
return new SkillMatchResponse(100, 1, 1, List.of(skill()), List.of());
}
private SkillLearningPathResponse learningPathResponse() {
return new SkillLearningPathResponse(
1,
List.of(new SkillLearningPathItem(
1,
3L,
"JavaScript",
101L,
"编程语言",
"先掌握 JavaScript 的基础语法和常见数据结构,再完成至少一个编码练习。"
))
);
}
private SkillCloudItem skill() {
return new SkillCloudItem(
1,
3L,
"JAVASCRIPT",
"JavaScript",
"JavaScript",
"ESCO",
"http://data.europa.eu/esco/skill/javascript",
101L,
"编程语言",
new BigDecimal("4.38"),
"15-1254.00",
"2.B.3.e",
false
);
}
}

451
mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java

@ -0,0 +1,451 @@
package com.mydreamjob.skill;
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.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
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.data.redis.core.ValueOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
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.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T9: real HTTP -> Security -> Controller -> Service -> Repository -> Flyway/H2 regression.
*
* <p>The test deliberately uses the real V36 data and real persisted student profile. It does
* not mock the skill services, so it can catch inconsistent TOP10 derivation, accidental writes,
* and response-layer privacy leaks.</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:skill_cloud_t9_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
"spring.jpa.show-sql=false"
})
@org.springframework.transaction.annotation.Transactional
class SkillCloudEndToEndIntegrationTest {
private static final String WORD_CLOUD_PATH = "/skills/word-cloud";
private static final String MATCH_PATH = "/skills/match";
private static final String LEARNING_PATH = "/skills/learning-path";
private static final long BACKEND_INDUSTRY_ID = 1L;
private static final long BACKEND_OCCUPATION_ID = 2L;
private static final long THREE_SKILL_INDUSTRY_ID = 3L;
private static final long THREE_SKILL_OCCUPATION_ID = 9L;
private static final long THREE_SKILL_CATEGORY_ID = 903L;
private static final long EMPTY_CATEGORY_ID = 999_001L;
private static final AtomicLong PHONE = new AtomicLong(940_000_000L);
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private UserRepository userRepository;
@Autowired
private StudentProfileRepository studentProfileRepository;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@BeforeEach
@SuppressWarnings("unchecked")
void setUpRedisMock() {
ValueOperations<String, Object> operations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(operations);
when(redisTemplate.hasKey(any())).thenReturn(false);
}
@Test
void threeEndpointsShareTop10AndDoNotWriteForBackendDeveloper() throws Exception {
StudentFixture student = createStudent("[\"Django\",\"Git\",\"Java\"]");
Map<String, Long> countsBefore = taxonomyAndProfileCounts();
JsonNode cloud = data(getOkBody(
WORD_CLOUD_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
List<JsonNode> allSkills = nodes(cloud.path("items"));
assertThat(cloud.path("total").asInt()).isEqualTo(16);
assertThat(allSkills).hasSize(16);
assertRanksAreContinuous(allSkills);
assertHeatOrder(allSkills);
List<JsonNode> expectedTop10 = allSkills.subList(0, Math.min(10, allSkills.size()));
JsonNode match = data(getOkBody(
MATCH_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
assertMatchReusesTop10(match, expectedTop10);
JsonNode learningPath = data(getOkBody(
LEARNING_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
assertLearningPathFollowsMissingSkills(learningPath, match.path("missingSkills"));
JsonNode cloudAgain = data(getOkBody(
WORD_CLOUD_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
JsonNode matchAgain = data(getOkBody(
MATCH_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
JsonNode learningAgain = data(getOkBody(
LEARNING_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
assertThat(cloudAgain).isEqualTo(cloud);
assertThat(matchAgain).isEqualTo(match);
assertThat(learningAgain).isEqualTo(learningPath);
assertThat(taxonomyAndProfileCounts()).isEqualTo(countsBefore);
}
@Test
void matchScoreUsesActualTopSizeAndRoundsToOneThird() throws Exception {
StudentFixture student = createStudent("[\"Articulate Storyline\"]");
JsonNode cloud = data(getOkBody(
WORD_CLOUD_PATH,
THREE_SKILL_INDUSTRY_ID,
THREE_SKILL_OCCUPATION_ID,
THREE_SKILL_CATEGORY_ID,
student.authorization(),
null));
List<JsonNode> skills = nodes(cloud.path("items"));
assertThat(cloud.path("total").asInt()).isEqualTo(3);
assertThat(skills).hasSize(3);
assertHeatOrder(skills);
JsonNode lastHeat = skills.get(skills.size() - 1).path("heat");
assertThat(lastHeat.isMissingNode() || lastHeat.isNull()).isTrue();
JsonNode match = data(getOkBody(
MATCH_PATH,
THREE_SKILL_INDUSTRY_ID,
THREE_SKILL_OCCUPATION_ID,
THREE_SKILL_CATEGORY_ID,
student.authorization(),
null));
assertThat(match.path("totalCount").asInt()).isEqualTo(3);
assertThat(match.path("matchedCount").asInt()).isEqualTo(1);
assertThat(match.path("matchScore").asInt()).isEqualTo(33);
assertThat(match.path("matchedSkills")).hasSize(1);
assertThat(match.path("missingSkills")).hasSize(2);
assertThat(ids(match.path("matchedSkills")))
.containsExactly(skills.get(0).path("skillId").asLong());
JsonNode learningPath = data(getOkBody(
LEARNING_PATH,
THREE_SKILL_INDUSTRY_ID,
THREE_SKILL_OCCUPATION_ID,
THREE_SKILL_CATEGORY_ID,
student.authorization(),
null));
assertLearningPathFollowsMissingSkills(learningPath, match.path("missingSkills"));
}
@Test
void emptyCategoryReturnsEmptyResponsesAtPersistenceBoundary() throws Exception {
StudentFixture student = createStudent("[\"Java\"]");
jdbcTemplate.update(
"INSERT INTO skill_categories "
+ "(id, occupation_id, code, name_zh, suggestion_template_code, sort_order, created_at, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
EMPTY_CATEGORY_ID,
BACKEND_OCCUPATION_ID,
"T9_EMPTY_CATEGORY",
"T9 空分类",
"GENERAL",
99);
JsonNode cloud = data(getOkBody(
WORD_CLOUD_PATH,
BACKEND_INDUSTRY_ID,
BACKEND_OCCUPATION_ID,
EMPTY_CATEGORY_ID,
student.authorization(),
null));
assertThat(cloud.path("total").asInt()).isZero();
assertThat(cloud.path("items")).isEmpty();
JsonNode match = data(getOkBody(
MATCH_PATH,
BACKEND_INDUSTRY_ID,
BACKEND_OCCUPATION_ID,
EMPTY_CATEGORY_ID,
student.authorization(),
null));
assertThat(match.path("matchScore").asInt()).isZero();
assertThat(match.path("matchedCount").asInt()).isZero();
assertThat(match.path("totalCount").asInt()).isZero();
assertThat(match.path("matchedSkills")).isEmpty();
assertThat(match.path("missingSkills")).isEmpty();
JsonNode learningPath = data(getOkBody(
LEARNING_PATH,
BACKEND_INDUSTRY_ID,
BACKEND_OCCUPATION_ID,
EMPTY_CATEGORY_ID,
student.authorization(),
null));
assertThat(learningPath.path("total").asInt()).isZero();
assertThat(learningPath.path("items")).isEmpty();
}
@Test
void identityQueryParametersCannotOverrideAuthenticatedStudent() throws Exception {
StudentFixture student = createStudent("[\"Java\"]");
User company = createUser(User.Role.COMPANY);
for (String path : List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH)) {
JsonNode authenticated = data(getOkBody(
path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
JsonNode withOverrides = data(getOkBody(
path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), company.getId()));
assertThat(withOverrides).isEqualTo(authenticated);
}
}
@Test
void responsesDoNotLeakPrivateProfileDataOrInternalErrors() throws Exception {
StudentFixture student = createStudent("[\"Java\"]");
for (String path : List.of(WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH)) {
String body = getOkBody(
path, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null);
assertNoPrivateData(body, student);
}
String errorBody = mockMvc.perform(get(WORD_CLOUD_PATH)
.param("industryId", String.valueOf(BACKEND_INDUSTRY_ID))
.param("occupationId", String.valueOf(BACKEND_OCCUPATION_ID))
.param("categoryId", "999999")
.header("Authorization", student.authorization()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").isNumber())
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
assertThat(errorBody.toLowerCase(Locale.ROOT))
.doesNotContain("exception")
.doesNotContain("org.springframework")
.doesNotContain("java.sql")
.doesNotContain("stacktrace")
.doesNotContain("\tat ");
}
private void assertMatchReusesTop10(JsonNode match, List<JsonNode> expectedTop10) {
List<Long> expectedIds = ids(expectedTop10);
List<Long> matchedIds = ids(match.path("matchedSkills"));
List<Long> missingIds = ids(match.path("missingSkills"));
assertThat(match.path("totalCount").asInt()).isEqualTo(expectedTop10.size());
assertThat(match.path("matchedCount").asInt()).isEqualTo(matchedIds.size());
assertThat(matchedIds).allMatch(expectedIds::contains);
assertThat(expectedIds).containsAll(matchedIds);
List<Long> expectedMatched = expectedIds.stream().filter(matchedIds::contains).toList();
List<Long> expectedMissing = expectedIds.stream().filter(id -> !matchedIds.contains(id)).toList();
assertThat(matchedIds).containsExactlyElementsOf(expectedMatched);
assertThat(missingIds).containsExactlyElementsOf(expectedMissing);
List<Long> union = new ArrayList<>(matchedIds);
union.addAll(missingIds);
assertThat(union).containsExactlyInAnyOrderElementsOf(expectedIds);
int expectedScore = expectedTop10.isEmpty()
? 0
: (int) Math.round(matchedIds.size() * 100.0 / expectedTop10.size());
assertThat(match.path("matchScore").asInt()).isEqualTo(expectedScore);
}
private void assertLearningPathFollowsMissingSkills(JsonNode learningPath, JsonNode missingSkills) {
assertThat(learningPath.path("total").asInt()).isEqualTo(missingSkills.size());
assertThat(learningPath.path("items")).hasSize(missingSkills.size());
for (int index = 0; index < missingSkills.size(); index++) {
JsonNode item = learningPath.path("items").get(index);
JsonNode missing = missingSkills.get(index);
assertThat(item.path("priority").asInt()).isEqualTo(index + 1);
assertThat(item.path("skillId").asLong()).isEqualTo(missing.path("skillId").asLong());
assertThat(item.path("skillName").asText()).isEqualTo(missing.path("nameZh").asText());
assertThat(item.path("suggestion").asText()).isNotBlank();
}
}
private void assertRanksAreContinuous(List<JsonNode> items) {
for (int index = 0; index < items.size(); index++) {
assertThat(items.get(index).path("rank").asInt()).isEqualTo(index + 1);
}
}
private void assertHeatOrder(List<JsonNode> items) {
boolean seenNullHeat = false;
BigDecimal previousHeat = null;
for (JsonNode item : items) {
if (item.path("heat").isNull()) {
seenNullHeat = true;
continue;
}
assertThat(seenNullHeat).isFalse();
BigDecimal currentHeat = item.path("heat").decimalValue();
if (previousHeat != null) {
assertThat(currentHeat).isLessThanOrEqualTo(previousHeat);
}
previousHeat = currentHeat;
}
}
private void assertNoPrivateData(String body, StudentFixture student) {
assertThat(body).doesNotContain("skills_json");
assertThat(body).doesNotContain(student.phone());
assertThat(body).doesNotContain(student.email());
assertThat(body).doesNotContain(student.resumeUrl());
assertThat(body).doesNotContain(student.resumeOriginalName());
assertThat(body).doesNotContain(student.accessToken());
}
private String getOkBody(String path,
long industryId,
long occupationId,
Long categoryId,
String authorization,
Long overrideIdentity) throws Exception {
var request = get(path)
.param("industryId", String.valueOf(industryId))
.param("occupationId", String.valueOf(occupationId))
.header("Authorization", authorization);
if (categoryId != null) {
request.param("categoryId", String.valueOf(categoryId));
}
if (overrideIdentity != null) {
request.param("userId", String.valueOf(overrideIdentity));
request.param("studentId", String.valueOf(overrideIdentity));
}
return mockMvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
}
private JsonNode data(String body) throws Exception {
return objectMapper.readTree(body).path("data");
}
private List<JsonNode> nodes(JsonNode array) {
List<JsonNode> nodes = new ArrayList<>();
array.forEach(nodes::add);
return nodes;
}
private List<Long> ids(JsonNode array) {
return ids(nodes(array));
}
private List<Long> ids(List<JsonNode> items) {
return items.stream().map(node -> node.path("skillId").asLong()).toList();
}
private StudentFixture createStudent(String skillsJson) {
long phoneNumber = PHONE.getAndIncrement();
String phone = String.valueOf(phoneNumber);
String email = "private.student." + phoneNumber + "@example.com";
String resumeUrl = "https://private.example/" + phoneNumber + "-resume.pdf";
String resumeOriginalName = "private-" + phoneNumber + "-resume.pdf";
User user = userRepository.saveAndFlush(User.builder()
.phone(phone)
.email(email)
.role(User.Role.STUDENT)
.roles(Set.of(User.Role.STUDENT))
.active(true)
.verified(true)
.build());
studentProfileRepository.saveAndFlush(StudentProfile.builder()
.userId(user.getId())
.realName("sensitive-real-name")
.bio("raw-resume-sentinel")
.resumeUrl(resumeUrl)
.resumeOriginalName(resumeOriginalName)
.skillsJson(skillsJson)
.build());
String accessToken = jwtTokenProvider.generateAccessToken(
user.getId(),
phone,
Map.of("roles", "STUDENT"));
return new StudentFixture(user.getId(), phone, email, resumeUrl, resumeOriginalName, accessToken);
}
private User createUser(User.Role role) {
long phoneNumber = PHONE.getAndIncrement();
return userRepository.saveAndFlush(User.builder()
.phone(String.valueOf(phoneNumber))
.role(role)
.roles(Set.of(role))
.active(true)
.verified(true)
.build());
}
private Map<String, Long> taxonomyAndProfileCounts() {
Map<String, Long> counts = new LinkedHashMap<>();
for (String table : List.of(
"skill_source_versions",
"skill_industries",
"skill_occupations",
"skill_categories",
"skills",
"skill_aliases",
"occupation_skills",
"student_profiles")) {
Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Long.class);
counts.put(table, count);
}
return counts;
}
private record StudentFixture(
Long userId,
String phone,
String email,
String resumeUrl,
String resumeOriginalName,
String accessToken
) {
String authorization() {
return "Bearer " + accessToken;
}
}
}

325
mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java

@ -0,0 +1,325 @@
package com.mydreamjob.skill;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.skill.domain.SkillIndustry;
import com.mydreamjob.skill.domain.SkillOccupation;
import com.mydreamjob.skill.dto.SkillCloudItem;
import com.mydreamjob.skill.dto.SkillCloudResponse;
import com.mydreamjob.skill.dto.SkillCloudSkillProjection;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.hibernate.boot.model.relational.Namespace;
import org.hibernate.boot.model.relational.Sequence;
import org.hibernate.mapping.Table;
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 com.mydreamjob.skill.repository.OccupationSkillRepository;
import com.mydreamjob.skill.repository.SkillCategoryRepository;
import com.mydreamjob.skill.repository.SkillIndustryRepository;
import com.mydreamjob.skill.repository.SkillOccupationRepository;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@SpringBootTest
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:integrationdb_skill_query;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillCloudQueryServiceTest$SkillQuerySchemaFilterProvider"
})
@ActiveProfiles("integration-test")
@Transactional
class SkillCloudQueryServiceTest {
@Autowired
private SkillCloudQueryService service;
@Autowired
private JdbcTemplate jdbcTemplate;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Test
void returnsAllSeedSkillsForIndustryAndOccupationWithContinuousRanks() {
SkillCloudResponse response = service.query(1L, 1L, null);
assertThat(response.industry().id()).isEqualTo(1L);
assertThat(response.industry().code()).isEqualTo("INTERNET");
assertThat(response.industry().name()).isEqualTo("互联网");
assertThat(response.occupation().id()).isEqualTo(1L);
assertThat(response.occupation().code()).isEqualTo("FRONTEND_DEVELOPER");
assertThat(response.occupation().name()).isEqualTo("前端开发工程师");
assertThat(response.category()).isNull();
assertThat(response.total()).isEqualTo(15);
assertThat(response.items()).hasSize(15);
assertThat(response.items())
.extracting(SkillCloudItem::rank)
.containsExactlyElementsOf(IntStream.rangeClosed(1, 15).boxed().toList());
assertThat(response.items())
.extracting(SkillCloudItem::skillId)
.doesNotHaveDuplicates();
}
@Test
void sortsByHeatThenNameAndKeepsNullHeatLast() {
SkillCloudResponse response = service.query(1L, 1L, 104L);
assertThat(response.category().id()).isEqualTo(104L);
assertThat(response.category().code()).isEqualTo("TOOLS");
assertThat(response.category().name()).isEqualTo("工程化工具");
assertThat(response.items())
.extracting(SkillCloudItem::nameZh)
.containsExactly("Gradle", "Node.js", "Figma");
assertThat(response.items())
.extracting(SkillCloudItem::heat)
.containsExactly(new BigDecimal("4.88"), new BigDecimal("4.88"), null);
assertThat(response.items().get(response.items().size() - 1).heat()).isNull();
SkillCloudResponse all = service.query(1L, 1L, null);
assertThat(all.items().stream()
.map(SkillCloudItem::heat)
.filter(java.util.Objects::nonNull)
.toList())
.isSortedAccordingTo(java.util.Comparator.reverseOrder());
assertThat(all.items().get(all.items().size() - 1).heat()).isNull();
assertThat(all.items().subList(0, all.items().size() - 1))
.allSatisfy(item -> assertThat(item.heat()).isNotNull());
}
@Test
void comparatorUsesSkillIdWhenEqualKeysArriveInReverseOrder() {
SkillIndustry industry = new SkillIndustry();
industry.setId(1L);
industry.setCode("INTERNET");
industry.setNameZh("互联网");
SkillOccupation occupation = new SkillOccupation();
occupation.setId(1L);
occupation.setIndustryId(1L);
occupation.setCode("FRONTEND_DEVELOPER");
occupation.setNameZh("前端开发工程师");
SkillIndustryRepository industryRepository = mock(SkillIndustryRepository.class);
when(industryRepository.findById(1L)).thenReturn(Optional.of(industry));
SkillOccupationRepository occupationRepository = mock(SkillOccupationRepository.class);
when(occupationRepository.findById(1L)).thenReturn(Optional.of(occupation));
OccupationSkillRepository occupationSkillRepository = mock(OccupationSkillRepository.class);
when(occupationSkillRepository.findSkillProjectionsByOccupationId(1L))
.thenReturn(List.of(
projection(9002L, "TIE_SKILL_HIGH"),
projection(9001L, "TIE_SKILL_LOW")));
SkillCloudQueryService isolatedService = new SkillCloudQueryService(
industryRepository,
occupationRepository,
mock(SkillCategoryRepository.class),
occupationSkillRepository);
SkillCloudResponse response = isolatedService.query(1L, 1L, null);
assertThat(response.items())
.extracting(SkillCloudItem::skillId)
.containsExactly(9001L, 9002L);
}
private SkillCloudSkillProjection projection(Long skillId, String skillCode) {
return new SkillCloudSkillProjection(
skillId,
skillCode,
"同名技能",
"Tie Skill",
"O*NET",
"TEST|Tie Skill",
104L,
"工程化工具",
new BigDecimal("4.88"),
"15-1254.00",
"4.A.3.b.1",
true,
1,
1
);
}
@Test
void categoryFilterReturnsOnlyTheRequestedCategoryAndOmittedCategoryReturnsMore() {
SkillCloudResponse all = service.query(1L, 1L, null);
SkillCloudResponse filtered = service.query(1L, 1L, 101L);
assertThat(filtered.total()).isEqualTo(2);
assertThat(filtered.items())
.extracting(SkillCloudItem::categoryId)
.containsOnly(101L);
assertThat(filtered.items())
.extracting(SkillCloudItem::skillId)
.containsExactlyInAnyOrder(3L, 4L);
assertThat(all.total()).isGreaterThan(filtered.total());
}
@Test
void returnsEmptySuccessForLegalCategoryWithoutSkills() {
jdbcTemplate.update("""
INSERT INTO skill_categories
(id, occupation_id, code, name_zh, suggestion_template_code, sort_order)
VALUES
(9101, 1, 'EMPTY_CATEGORY', '空分类', 'GENERAL', 99)
""");
SkillCloudResponse response = service.query(1L, 1L, 9101L);
assertThat(response.category().id()).isEqualTo(9101L);
assertThat(response.category().name()).isEqualTo("空分类");
assertThat(response.total()).isZero();
assertThat(response.items()).isEmpty();
}
@Test
void mapsSkillAndHeatProvenanceFieldsFromTheDatabase() {
SkillCloudResponse frameworks = service.query(1L, 1L, 103L);
SkillCloudItem react = frameworks.items().stream()
.filter(item -> item.skillId().equals(26L))
.findFirst()
.orElseThrow();
Map<String, Object> row = jdbcTemplate.queryForMap("""
SELECT os.importance, os.importance_source_code, os.importance_element_id,
os.importance_is_proxy
FROM occupation_skills os
WHERE os.occupation_id = 1 AND os.skill_id = 26
""");
assertThat(react.rank()).isEqualTo(1);
assertThat(react.skillCode()).isEqualTo("REACT");
assertThat(react.nameZh()).isEqualTo("React");
assertThat(react.nameEn()).isEqualTo("React");
assertThat(react.categoryId()).isEqualTo(103L);
assertThat(react.categoryName()).isEqualTo("前端框架");
assertThat(react.sourceType()).isEqualTo("O*NET");
assertThat(react.sourceCode()).isEqualTo("15-1254.00|React|2.E.7.h");
assertThat(react.heat()).isEqualByComparingTo((BigDecimal) row.get("importance"));
assertThat(react.heatSourceCode()).isEqualTo(row.get("importance_source_code"));
assertThat(react.heatElementId()).isEqualTo(row.get("importance_element_id"));
assertThat(react.heatProxy()).isEqualTo(toBoolean(row.get("importance_is_proxy")));
SkillCloudResponse languages = service.query(1L, 1L, 101L);
SkillCloudItem javascript = languages.items().stream()
.filter(item -> item.skillId().equals(3L))
.findFirst()
.orElseThrow();
assertThat(javascript.sourceType()).isEqualTo("ESCO");
assertThat(javascript.sourceCode())
.isEqualTo("http://data.europa.eu/esco/skill/3cd569a2-4f88-4c1e-9995-8dce8c5e51a7");
assertThat(javascript.heatSourceCode()).isEqualTo("15-1254.00");
}
@Test
void rejectsMissingOrNonPositiveIdentifiersWithBadRequest() {
assertBadRequest(() -> service.query(null, 1L, null), "industryId");
assertBadRequest(() -> service.query(0L, 1L, null), "industryId");
assertBadRequest(() -> service.query(-1L, 1L, null), "industryId");
assertBadRequest(() -> service.query(1L, null, null), "occupationId");
assertBadRequest(() -> service.query(1L, 0L, null), "occupationId");
assertBadRequest(() -> service.query(1L, -1L, null), "occupationId");
assertBadRequest(() -> service.query(1L, 1L, 0L), "categoryId");
assertBadRequest(() -> service.query(1L, 1L, -1L), "categoryId");
}
@Test
void rejectsUnknownOrMismatchedTaxonomyNodesWithBadRequest() {
assertBadRequest(() -> service.query(999L, 1L, null), "industry not found");
assertBadRequest(() -> service.query(1L, 999L, null), "occupation not found");
assertBadRequest(() -> service.query(2L, 1L, null), "occupation does not belong");
assertBadRequest(() -> service.query(1L, 1L, 201L), "category does not belong");
assertBadRequest(() -> service.query(1L, 1L, 9999L), "category not found");
}
private boolean toBoolean(Object value) {
if (value instanceof Boolean booleanValue) {
return booleanValue;
}
return ((Number) value).intValue() != 0;
}
private void assertBadRequest(ThrowingCallable action, String detailFragment) {
BusinessException exception = catchThrowableOfType(action, BusinessException.class);
assertThat(exception).isNotNull();
assertThat(exception.getErrorCodeInt()).isEqualTo(ErrorCode.BAD_REQUEST.getCode());
assertThat(exception.getErrorMessage()).contains(detailFragment);
}
public static class SkillQuerySchemaFilterProvider implements SchemaFilterProvider {
private static final Set<String> VALIDATED_TABLES = Set.of(
"skill_source_versions",
"skill_industries",
"skill_occupations",
"skill_categories",
"skills",
"skill_aliases",
"occupation_skills"
);
@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;
}
};
}
}
}

1036
mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudSeedDataIntegrationTest.java

File diff suppressed because it is too large

416
mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillLearningPathServiceTest.java

@ -0,0 +1,416 @@
package com.mydreamjob.skill;
import com.mydreamjob.skill.domain.SkillCategory;
import com.mydreamjob.skill.dto.SkillCloudItem;
import com.mydreamjob.skill.dto.SkillLearningPathItem;
import com.mydreamjob.skill.dto.SkillLearningPathResponse;
import com.mydreamjob.skill.dto.SkillMatchResponse;
import com.mydreamjob.skill.repository.SkillCategoryRepository;
import org.hibernate.boot.model.relational.Namespace;
import org.hibernate.boot.model.relational.Sequence;
import org.hibernate.mapping.Table;
import org.hibernate.tool.schema.spi.SchemaFilter;
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@SpringBootTest
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:integrationdb_skill_learning_path;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillLearningPathServiceTest$SkillLearningPathSchemaFilterProvider"
})
@ActiveProfiles("integration-test")
@Transactional
class SkillLearningPathServiceTest {
private static final Long USER_ID = 7001L;
private static final Long INDUSTRY_ID = 1L;
private static final Long OCCUPATION_ID = 1L;
private static final Long CATEGORY_ID = 101L;
private static final Long EDUCATION_INDUSTRY_ID = 3L;
private static final Long INSTRUCTIONAL_DESIGNER_OCCUPATION_ID = 9L;
@Autowired
private SkillLearningPathService learningPathService;
@Autowired
private SkillMatchService matchService;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Test
void v36LearningPathReusesMatchMissingSkillsWithoutReordering() {
SkillMatchResponse matchResponse = matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
SkillLearningPathResponse response = learningPathService.generate(
USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
assertThat(matchResponse.missingSkills()).hasSize(10);
assertThat(response.total()).isEqualTo(matchResponse.missingSkills().size());
assertThat(response.items())
.extracting(SkillLearningPathItem::skillId)
.containsExactlyElementsOf(matchResponse.missingSkills().stream()
.map(SkillCloudItem::skillId)
.toList());
assertThat(response.items())
.extracting(SkillLearningPathItem::priority)
.containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
assertThat(response.items()).allSatisfy(item -> {
assertThat(item.skillName()).isNotBlank();
assertThat(item.categoryId()).isNotNull();
assertThat(item.categoryName()).isNotBlank();
assertThat(item.suggestion()).contains(item.skillName());
assertNoForbiddenContent(item.suggestion());
});
}
@Test
void v36LearningPathPreservesMatchOrderAcrossHeatGroupsWhenNameOrderDiffers() {
SkillMatchResponse matchResponse = matchService.match(
USER_ID, EDUCATION_INDUSTRY_ID, INSTRUCTIONAL_DESIGNER_OCCUPATION_ID, null);
SkillLearningPathResponse response = learningPathService.generate(
USER_ID, EDUCATION_INDUSTRY_ID, INSTRUCTIONAL_DESIGNER_OCCUPATION_ID, null);
List<SkillCloudItem> missingSkills = matchResponse.missingSkills();
List<String> missingNames = missingSkills.stream()
.map(SkillCloudItem::nameZh)
.toList();
List<Long> missingIds = missingSkills.stream()
.map(SkillCloudItem::skillId)
.toList();
List<BigDecimal> distinctHeats = missingSkills.stream()
.map(SkillCloudItem::heat)
.distinct()
.toList();
assertThat(missingSkills).hasSize(10);
assertThat(distinctHeats.size()).isGreaterThan(1);
assertThat(missingNames).isNotEqualTo(missingNames.stream().sorted().toList());
assertThat(missingIds).isNotEqualTo(missingIds.stream().sorted().toList());
assertThat(response.total()).isEqualTo(missingSkills.size());
assertThat(response.items())
.extracting(SkillLearningPathItem::skillId)
.containsExactlyElementsOf(missingIds);
assertThat(response.items())
.extracting(SkillLearningPathItem::skillName)
.containsExactlyElementsOf(missingNames);
assertThat(response.items())
.extracting(SkillLearningPathItem::priority)
.containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
}
@Nested
class TemplateAndOrderingTests {
private final SkillMatchService matchService = mock(SkillMatchService.class);
private final SkillCategoryRepository categoryRepository = mock(SkillCategoryRepository.class);
private final SkillLearningPathService service =
new SkillLearningPathService(matchService, categoryRepository);
@Test
void usesOnlyMissingSkillsAndPreservesMissingOrderWithContiguousPriorities() {
SkillCloudItem matched = skill(11L, "Matched", "Matched", CATEGORY_ID,
"编程语言", new BigDecimal("9.00"));
List<SkillCloudItem> missing = List.of(
skill(30L, "Skill Z", "Skill Z", 101L, "编程语言", new BigDecimal("1.00")),
skill(10L, "Skill A", "Skill A", 102L, "框架", new BigDecimal("10.00")),
skill(20L, "Skill M", "Skill M", 103L, "工具", new BigDecimal("5.00"))
);
List<String> missingNames = missing.stream()
.map(SkillCloudItem::nameZh)
.toList();
List<Long> missingIds = missing.stream()
.map(SkillCloudItem::skillId)
.toList();
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(matched), missing));
stubCategory(101L, "PROGRAMMING_LANGUAGE");
stubCategory(102L, "FRAMEWORK");
stubCategory(103L, "TOOL");
SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
assertThat(response.total()).isEqualTo(3);
assertThat(missingNames).isNotEqualTo(missingNames.stream().sorted().toList());
assertThat(missingIds).isNotEqualTo(missingIds.stream().sorted().toList());
assertThat(response.items())
.extracting(SkillLearningPathItem::skillId)
.containsExactly(30L, 10L, 20L);
assertThat(response.items())
.extracting(SkillLearningPathItem::priority)
.containsExactly(1, 2, 3);
assertThat(response.items())
.extracting(SkillLearningPathItem::skillName)
.containsExactly("Skill Z", "Skill A", "Skill M");
assertThat(response.items())
.noneMatch(item -> item.skillId().equals(matched.skillId()));
verify(matchService).match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
verifyNoMoreInteractions(matchService);
verify(categoryRepository).findByIdAndOccupationId(101L, OCCUPATION_ID);
verify(categoryRepository).findByIdAndOccupationId(102L, OCCUPATION_ID);
verify(categoryRepository).findByIdAndOccupationId(103L, OCCUPATION_ID);
verifyNoMoreInteractions(categoryRepository);
}
@Test
void generatesProgrammingLanguageSuggestion() {
SkillCloudItem missing = skill(41L, "Java", "Java", CATEGORY_ID,
"编程语言", BigDecimal.ONE);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), List.of(missing)));
stubCategory(CATEGORY_ID, "PROGRAMMING_LANGUAGE");
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)
.items().get(0);
assertThat(item.suggestion()).isEqualTo(
"先掌握Java的基础语法和常见数据结构,再完成至少一个编码练习。");
assertNoForbiddenContent(item.suggestion());
}
@Test
void generatesFrameworkSuggestion() {
SkillCloudItem missing = skill(42L, "Spring Boot", "Spring Boot", CATEGORY_ID,
"框架", BigDecimal.ONE);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), List.of(missing)));
stubCategory(CATEGORY_ID, "FRAMEWORK");
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)
.items().get(0);
assertThat(item.suggestion()).isEqualTo(
"先理解Spring Boot的核心概念与生命周期,再通过一个小型项目练习集成。");
assertNoForbiddenContent(item.suggestion());
}
@Test
void generatesToolSuggestion() {
SkillCloudItem missing = skill(43L, "Docker", "Docker", CATEGORY_ID,
"工具", BigDecimal.ONE);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), List.of(missing)));
stubCategory(CATEGORY_ID, "TOOL");
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)
.items().get(0);
assertThat(item.suggestion()).isEqualTo(
"先完成Docker的安装与基础配置,再结合现有工程完成一次实际操作。");
assertNoForbiddenContent(item.suggestion());
}
@Test
void generatesGeneralSuggestion() {
SkillCloudItem missing = skill(44L, "沟通能力", "Communication", CATEGORY_ID,
"通用能力", BigDecimal.ONE);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), List.of(missing)));
stubCategory(CATEGORY_ID, "GENERAL");
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)
.items().get(0);
assertThat(item.suggestion()).isEqualTo(
"先了解沟通能力的核心概念和常见应用场景,再通过一个简短实践巩固。");
assertNoForbiddenContent(item.suggestion());
}
@Test
void fallsBackToGeneralForUnknownNullAndMissingTemplateCodes() {
List<SkillCloudItem> missing = List.of(
skill(51L, "Unknown", "Unknown", 101L, "分类 A", BigDecimal.ONE),
skill(52L, "NullCode", "NullCode", 102L, "分类 B", BigDecimal.ONE),
skill(53L, "MissingCategory", "MissingCategory", 103L, "分类 C", BigDecimal.ONE)
);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), missing));
when(categoryRepository.findByIdAndOccupationId(101L, OCCUPATION_ID))
.thenReturn(Optional.of(category(101L, "UNKNOWN_TEMPLATE")));
when(categoryRepository.findByIdAndOccupationId(102L, OCCUPATION_ID))
.thenReturn(Optional.of(category(102L, null)));
when(categoryRepository.findByIdAndOccupationId(103L, OCCUPATION_ID))
.thenReturn(Optional.empty());
SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
assertThat(response.items())
.extracting(SkillLearningPathItem::suggestion)
.containsExactly(
"先了解Unknown的核心概念和常见应用场景,再通过一个简短实践巩固。",
"先了解NullCode的核心概念和常见应用场景,再通过一个简短实践巩固。",
"先了解MissingCategory的核心概念和常见应用场景,再通过一个简短实践巩固。"
);
response.items().forEach(item -> assertNoForbiddenContent(item.suggestion()));
}
@Test
void returnsEmptyPathAndDoesNotReadCategoriesWhenThereAreNoGaps() {
SkillCloudItem matched = skill(61L, "Java", "Java", CATEGORY_ID,
"编程语言", BigDecimal.ONE);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(matched), List.of()));
SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
assertThat(response.total()).isZero();
assertThat(response.items()).isEmpty();
verifyNoInteractions(categoryRepository);
}
@Test
void repeatedCallsProduceIdenticalOrderPrioritiesAndText() {
List<SkillCloudItem> missing = List.of(
skill(71L, "Java", "Java", 101L, "编程语言", BigDecimal.ONE),
skill(72L, "Docker", "Docker", 102L, "工具", BigDecimal.ONE)
);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), missing));
stubCategory(101L, "PROGRAMMING_LANGUAGE");
stubCategory(102L, "TOOL");
SkillLearningPathResponse first = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
SkillLearningPathResponse second = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
assertThat(second).isEqualTo(first);
assertThat(second.items())
.extracting(SkillLearningPathItem::skillId)
.containsExactly(71L, 72L);
assertThat(second.items())
.extracting(SkillLearningPathItem::priority)
.containsExactly(1, 2);
assertThat(second.items())
.extracting(SkillLearningPathItem::suggestion)
.containsExactly(
"先掌握Java的基础语法和常见数据结构,再完成至少一个编码练习。",
"先完成Docker的安装与基础配置,再结合现有工程完成一次实际操作。"
);
}
private void stubCategory(Long categoryId, String templateCode) {
when(categoryRepository.findByIdAndOccupationId(categoryId, OCCUPATION_ID))
.thenReturn(Optional.of(category(categoryId, templateCode)));
}
}
private static SkillMatchResponse matchResponse(List<SkillCloudItem> matched,
List<SkillCloudItem> missing) {
int total = matched.size() + missing.size();
int score = total == 0 ? 0 : (int) Math.round(matched.size() * 100.0 / total);
return new SkillMatchResponse(score, matched.size(), total, matched, missing);
}
private static SkillCloudItem skill(Long skillId,
String nameZh,
String nameEn,
Long categoryId,
String categoryName,
BigDecimal heat) {
return new SkillCloudItem(
skillId.intValue(),
skillId,
"SKILL_" + skillId,
nameZh,
nameEn,
"ESCO",
"source-" + skillId,
categoryId,
categoryName,
heat,
"15-1254.00",
"2.E.7.h",
false
);
}
private static SkillCategory category(Long categoryId, String templateCode) {
return SkillCategory.builder()
.id(categoryId)
.occupationId(OCCUPATION_ID)
.code("CATEGORY_" + categoryId)
.nameZh("分类 " + categoryId)
.suggestionTemplateCode(templateCode)
.sortOrder(categoryId.intValue())
.build();
}
private static void assertNoForbiddenContent(String suggestion) {
assertThat(suggestion)
.doesNotContain("http://", "https://", "课程", "讲师", "价格", "时长",
"分钟", "小时", "完成度", "AI");
}
public static class SkillLearningPathSchemaFilterProvider implements SchemaFilterProvider {
private static final Set<String> VALIDATED_TABLES = Set.of(
"skill_source_versions",
"skill_industries",
"skill_occupations",
"skill_categories",
"skills",
"skill_aliases",
"occupation_skills"
);
@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;
}
};
}
}
}

482
mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillMatchServiceTest.java

@ -0,0 +1,482 @@
package com.mydreamjob.skill;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import com.mydreamjob.skill.domain.SkillAlias;
import com.mydreamjob.skill.dto.SkillCloudItem;
import com.mydreamjob.skill.dto.SkillCloudNode;
import com.mydreamjob.skill.dto.SkillCloudResponse;
import com.mydreamjob.skill.dto.SkillMatchResponse;
import com.mydreamjob.skill.repository.SkillAliasRepository;
import org.hibernate.boot.model.relational.Namespace;
import org.hibernate.boot.model.relational.Sequence;
import org.hibernate.mapping.Table;
import org.hibernate.tool.schema.spi.SchemaFilter;
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.boot.test.mock.mockito.SpyBean;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
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.math.BigDecimal;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.Mockito.doReturn;
@SpringBootTest
@ExtendWith(OutputCaptureExtension.class)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:integrationdb_skill_match;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillMatchServiceTest$SkillMatchSchemaFilterProvider"
})
@ActiveProfiles("integration-test")
@Transactional
class SkillMatchServiceTest {
private static final Long USER_ID = 7001L;
@Autowired
private SkillMatchService service;
@SpyBean
private SkillCloudQueryService queryService;
@SpyBean
private StudentProfileRepository profileRepository;
@SpyBean
private SkillAliasRepository aliasRepository;
@Autowired
private JdbcTemplate jdbcTemplate;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Test
void normalizerAppliesUnicodeRulesAndPreservesProgrammingSymbols() {
SkillNameNormalizer normalizer = new SkillNameNormalizer();
assertThat(normalizer.normalize(" Spring‐Boot. ")).isEqualTo("spring boot");
assertThat(normalizer.normalize(" JAVA\n\tSCRIPT ")).isEqualTo("java script");
assertThat(normalizer.normalize("Node.js")).isEqualTo("nodejs");
assertThat(normalizer.normalize("C++")).isEqualTo("c++");
assertThat(normalizer.normalize("C#")).isEqualTo("c#");
}
@Test
void usesOnlyTopTenWhenMoreThanTenSkillsExist() {
List<SkillCloudItem> items = IntStream.rangeClosed(1, 11)
.mapToObj(index -> item((long) index, "Skill " + index, "Skill " + index, "SKILL_" + index))
.toList();
stubCloud(cloud(items));
saveProfile(USER_ID, json(items.stream().map(SkillCloudItem::nameZh).toList()));
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
assertThat(result.totalCount()).isEqualTo(10);
assertThat(result.matchedCount()).isEqualTo(10);
assertThat(result.matchScore()).isEqualTo(100);
assertThat(result.matchedSkills())
.extracting(SkillCloudItem::skillId)
.containsExactlyElementsOf(IntStream.rangeClosed(1, 10).mapToLong(Long::valueOf).boxed().toList());
assertThat(result.missingSkills()).isEmpty();
}
@Test
void usesTenAsDenominatorWhenExactlyTenSkillsExist() {
List<SkillCloudItem> items = IntStream.rangeClosed(1, 10)
.mapToObj(index -> item((long) index, "Skill " + index, "Skill " + index, "SKILL_" + index))
.toList();
stubCloud(cloud(items));
saveProfile(USER_ID, json(items.stream().limit(5).map(SkillCloudItem::nameZh).toList()));
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
assertThat(result.totalCount()).isEqualTo(10);
assertThat(result.matchedCount()).isEqualTo(5);
assertThat(result.matchScore()).isEqualTo(50);
assertThat(result.missingSkills()).hasSize(5);
}
@Test
void usesActualSizeAsDenominatorAndRoundsOneThirdToThirtyThree() {
List<SkillCloudItem> items = List.of(
item(1L, "Java", "Java", "JAVA"),
item(2L, "Python", "Python", "PYTHON"),
item(3L, "SQL", "SQL", "SQL")
);
stubCloud(cloud(items));
saveProfile(USER_ID, json(List.of("Java")));
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
assertThat(result.totalCount()).isEqualTo(3);
assertThat(result.matchedCount()).isEqualTo(1);
assertThat(result.matchScore()).isEqualTo(33);
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L);
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(2L, 3L);
}
@Test
void returnsZeroAndEmptyListsWhenCloudIsEmpty() {
stubCloud(cloud(List.of()));
saveProfile(USER_ID, json(List.of("Java")));
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
assertThat(result.matchScore()).isZero();
assertThat(result.matchedCount()).isZero();
assertThat(result.totalCount()).isZero();
assertThat(result.matchedSkills()).isEmpty();
assertThat(result.missingSkills()).isEmpty();
}
@Test
void treatsMissingProfileAsNoSkills() {
stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA"))));
SkillMatchResponse result = service.match(9999L, 1L, 1L, null);
assertThat(result.matchScore()).isZero();
assertThat(result.matchedSkills()).isEmpty();
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L);
}
@Test
void treatsBlankAndEmptyArrayAsNoSkills() {
stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA"), item(2L, "SQL", "SQL", "SQL"))));
doReturn(Optional.of(StudentProfile.builder()
.id(100L)
.userId(USER_ID)
.skillsJson(" ")
.build()))
.when(profileRepository).findByUserId(USER_ID);
SkillMatchResponse blank = service.match(USER_ID, 1L, 1L, null);
assertThat(blank.matchScore()).isZero();
assertThat(blank.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L);
doReturn(Optional.of(StudentProfile.builder()
.id(101L)
.userId(USER_ID)
.skillsJson("[]")
.build()))
.when(profileRepository).findByUserId(USER_ID);
SkillMatchResponse empty = service.match(USER_ID, 1L, 1L, null);
assertThat(empty.matchScore()).isZero();
assertThat(empty.matchedSkills()).isEmpty();
}
@Test
void treatsMalformedJsonAsNoSkillsWithoutLeakingRawJson(CapturedOutput output) {
String malformedJson = "{\"skills\":[\"Java\"]";
stubCloud(cloud(List.of(item(1L, "Java", "Java", "JAVA"))));
doReturn(Optional.of(StudentProfile.builder()
.id(102L)
.userId(USER_ID)
.skillsJson(malformedJson)
.build()))
.when(profileRepository).findByUserId(USER_ID);
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
assertThat(result.matchScore()).isZero();
assertThat(result.matchedSkills()).isEmpty();
assertThat(output.getAll()).contains("Failed to parse student skills JSON");
assertThat(output.getAll()).doesNotContain(malformedJson);
}
@Test
void matchesNameZhNameEnSkillCodeAndAliasAndDeduplicatesStudentSkills() {
List<SkillCloudItem> items = List.of(
item(1L, "数据库", "Database Systems", "DATA_STORE_CLASS"),
item(2L, "中文技能", "database technology", "UNRELATED_CODE"),
item(3L, "代码技能", "Code English", "DATABASE_ENGINE")
);
stubCloud(cloud(items));
doReturn(List.of(alias(1L, "JVM Language", "jvm language")))
.when(aliasRepository).findAllBySkillIdInOrderByIdAsc(anyCollection());
saveProfile(USER_ID, json(List.of(
"jvm-language",
"JVM LANGUAGE",
"数据库",
"DATABASE TECHNOLOGY",
"database_engine"
)));
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
assertThat(result.matchScore()).isEqualTo(100);
assertThat(result.matchedCount()).isEqualTo(3);
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L, 3L);
assertThat(result.missingSkills()).isEmpty();
}
@Test
void matchesOnlyNameZhForV36JavaWhenOtherIdentityFieldsDoNotMatch() {
jdbcTemplate.update("UPDATE skills SET code = ? WHERE id = ?", "JAVA_NAME_ZH_ISOLATED", 1L);
saveProfile(USER_ID, json(List.of("Java")));
SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L);
assertThat(result.totalCount()).isEqualTo(2);
assertThat(result.matchScore()).isEqualTo(50);
assertThat(result.matchedSkills())
.extracting(SkillCloudItem::skillId)
.containsExactly(1L);
assertThat(result.missingSkills())
.extracting(SkillCloudItem::skillId)
.containsExactly(2L);
}
@Test
void matchesOnlyInsertedAliasForV36JavaWhenAliasIsTheOnlyIdentityMatch() {
jdbcTemplate.update("""
INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias)
VALUES (?, ?, ?, ?)
""", 10001L, 1L, "ONLY.ALIAS-JVM-XYZ", "only alias jvm xyz");
saveProfile(USER_ID, json(List.of("ONLY.ALIAS-JVM-XYZ")));
SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L);
assertThat(result.totalCount()).isEqualTo(2);
assertThat(result.matchScore()).isEqualTo(50);
assertThat(result.matchedSkills())
.extracting(SkillCloudItem::skillId)
.containsExactly(1L);
assertThat(result.missingSkills())
.extracting(SkillCloudItem::skillId)
.containsExactly(2L);
}
@Test
void doesNotApplyAliasOwnedByAnotherTopSkillToV36Java() {
jdbcTemplate.update("""
INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias)
VALUES (?, ?, ?, ?)
""", 10002L, 2L, "ONLY.ALIAS-OWNER-SKILL-2", "only alias owner skill 2");
saveProfile(USER_ID, json(List.of("ONLY.ALIAS-OWNER-SKILL-2")));
SkillMatchResponse result = service.match(USER_ID, 1L, 2L, 201L);
assertThat(result.totalCount()).isEqualTo(2);
assertThat(result.matchScore()).isEqualTo(50);
assertThat(result.matchedSkills())
.extracting(SkillCloudItem::skillId)
.containsExactly(2L);
assertThat(result.missingSkills())
.extracting(SkillCloudItem::skillId)
.containsExactly(1L);
}
@Test
void keepsMatchedAndMissingListsOrderedAndTheirUnionEqualToTopTen() {
List<SkillCloudItem> items = List.of(
item(1L, "Java", "Java", "JAVA"),
item(2L, "Python", "Python", "PYTHON"),
item(3L, "SQL", "SQL", "SQL")
);
stubCloud(cloud(items));
saveProfile(USER_ID, json(List.of("Python")));
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
List<Long> union = Stream.concat(result.matchedSkills().stream(), result.missingSkills().stream())
.map(SkillCloudItem::skillId)
.toList();
assertThat(result.matchScore()).isEqualTo(33);
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(2L);
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 3L);
assertThat(union).containsExactlyInAnyOrder(1L, 2L, 3L);
}
@Test
void returnsZeroWhenNoTopSkillMatches() {
List<SkillCloudItem> items = List.of(
item(1L, "Java", "Java", "JAVA"),
item(2L, "SQL", "SQL", "SQL")
);
stubCloud(cloud(items));
saveProfile(USER_ID, json(List.of("Marketing")));
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
assertThat(result.matchScore()).isZero();
assertThat(result.matchedSkills()).isEmpty();
assertThat(result.missingSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L);
}
@Test
void returnsOneHundredAndNoMissingSkillsWhenAllTopSkillsMatch() {
List<SkillCloudItem> items = List.of(
item(1L, "Java", "Java", "JAVA"),
item(2L, "SQL", "SQL", "SQL")
);
stubCloud(cloud(items));
saveProfile(USER_ID, json(List.of("Java", "SQL")));
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
assertThat(result.matchScore()).isEqualTo(100);
assertThat(result.matchedSkills()).extracting(SkillCloudItem::skillId).containsExactly(1L, 2L);
assertThat(result.missingSkills()).isEmpty();
}
@Test
void excludesCrossOccupationCategoryMismatchFromMatchSet() {
jdbcTemplate.update("""
INSERT INTO occupation_skills (
id, occupation_id, skill_id, category_id, importance,
importance_source_code, importance_element_id, importance_is_proxy,
relationship_type, source_version_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
10000L, 1L, 1L, 201L, new BigDecimal("5.00"),
"15-1254.00", "2.E.7.h", true, "ESSENTIAL", 2L);
saveProfile(USER_ID, json(List.of("Java")));
SkillCloudResponse cloud = queryService.query(1L, 1L, null);
SkillMatchResponse result = service.match(USER_ID, 1L, 1L, null);
List<Long> expectedTop = cloud.items().stream().limit(10).map(SkillCloudItem::skillId).toList();
List<Long> actualUnion = Stream.concat(result.matchedSkills().stream(), result.missingSkills().stream())
.map(SkillCloudItem::skillId)
.toList();
assertThat(result.matchedCount()).isZero();
assertThat(actualUnion).containsExactlyElementsOf(expectedTop);
assertThat(actualUnion).doesNotContain(1L);
}
private void stubCloud(SkillCloudResponse response) {
doReturn(response).when(queryService).query(any(), any(), any());
}
private StudentProfile saveProfile(Long userId, String skillsJson) {
jdbcTemplate.update("""
INSERT INTO users (id, phone, role, is_active, is_verified, created_at, updated_at)
VALUES (?, ?, 'STUDENT', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", userId, String.valueOf(userId));
return profileRepository.saveAndFlush(StudentProfile.builder()
.userId(userId)
.skillsJson(skillsJson)
.build());
}
private SkillCloudResponse cloud(List<SkillCloudItem> items) {
return new SkillCloudResponse(
new SkillCloudNode(1L, "INTERNET", "互联网"),
new SkillCloudNode(1L, "FRONTEND_DEVELOPER", "前端开发工程师"),
null,
items.size(),
items
);
}
private SkillCloudItem item(Long id, String nameZh, String nameEn, String code) {
return new SkillCloudItem(
id.intValue(),
id,
code,
nameZh,
nameEn,
"O*NET",
"test-source",
1L,
"测试分类",
BigDecimal.TEN,
null,
null,
false
);
}
private SkillAlias alias(Long skillId, String alias, String normalizedAlias) {
return SkillAlias.builder()
.id(1L)
.skillId(skillId)
.alias(alias)
.normalizedAlias(normalizedAlias)
.build();
}
private String json(List<String> values) {
return values.stream()
.map(value -> "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"")
.collect(java.util.stream.Collectors.joining(",", "[", "]"));
}
public static class SkillMatchSchemaFilterProvider implements SchemaFilterProvider {
private static final Set<String> VALIDATED_TABLES = Set.of(
"skill_source_versions",
"skill_industries",
"skill_occupations",
"skill_categories",
"skills",
"skill_aliases",
"occupation_skills"
);
@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;
}
};
}
}
}

373
mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java

@ -0,0 +1,373 @@
package com.mydreamjob.skill;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.dao.DataAccessException;
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 javax.sql.DataSource;
import java.math.BigDecimal;
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.Locale;
import java.util.Map;
import java.util.TreeMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:integrationdb_skill_taxonomy;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
@ActiveProfiles("integration-test")
class SkillTaxonomyMigrationTest {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private DataSource dataSource;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
private Long sourceVersionId;
private Long industryId;
private Long occupationId;
private Long categoryId;
private Long skillId;
private Long secondSkillId;
private Long occupationSkillId;
@Test
void migrationCreatesTaxonomyTablesColumnsConstraintsAndIndexes() throws SQLException {
assertTableAndColumns("skill_source_versions",
"id", "source_name", "source_version", "license", "attribution_url",
"imported_at", "created_at", "updated_at");
assertTableAndColumns("skill_industries",
"id", "code", "name_zh", "source_code", "sort_order", "created_at", "updated_at");
assertTableAndColumns("skill_occupations",
"id", "industry_id", "code", "name_zh", "name_en", "source_code",
"source_version_id", "sort_order", "created_at", "updated_at");
assertTableAndColumns("skill_categories",
"id", "occupation_id", "code", "name_zh", "suggestion_template_code",
"sort_order", "created_at", "updated_at");
assertTableAndColumns("skills",
"id", "code", "name_zh", "name_en", "source_type", "source_code",
"source_version_id", "sort_order", "created_at", "updated_at");
assertTableAndColumns("skill_aliases",
"id", "skill_id", "alias", "normalized_alias", "created_at");
assertTableAndColumns("occupation_skills",
"id", "occupation_id", "skill_id", "category_id", "importance", "importance_source_code", "importance_element_id", "importance_is_proxy",
"relationship_type", "source_version_id", "created_at", "updated_at");
assertUniqueIndex("skill_source_versions", "source_name", "source_version");
assertUniqueIndex("skill_industries", "code");
assertUniqueIndex("skill_occupations", "industry_id", "code");
assertUniqueIndex("skill_categories", "occupation_id", "code");
assertUniqueIndex("skills", "code");
assertUniqueIndex("skills", "source_type", "source_code");
assertUniqueIndex("skill_aliases", "normalized_alias");
assertUniqueIndex("skill_aliases", "skill_id", "alias");
assertUniqueIndex("occupation_skills", "occupation_id", "skill_id");
assertForeignKey("skill_occupations", "industry_id", "skill_industries", "id");
assertForeignKey("skill_occupations", "source_version_id", "skill_source_versions", "id");
assertForeignKey("skill_categories", "occupation_id", "skill_occupations", "id");
assertForeignKey("skills", "source_version_id", "skill_source_versions", "id");
assertForeignKey("skill_aliases", "skill_id", "skills", "id");
assertForeignKey("occupation_skills", "occupation_id", "skill_occupations", "id");
assertForeignKey("occupation_skills", "skill_id", "skills", "id");
assertForeignKey("occupation_skills", "category_id", "skill_categories", "id");
assertForeignKey("occupation_skills", "source_version_id", "skill_source_versions", "id");
assertIndex("occupation_skills", "occupation_id", "category_id");
assertIndex("occupation_skills", "occupation_id", "importance");
assertBooleanColumnMetadata("occupation_skills", "importance_is_proxy");
assertEquals(1L, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.table_constraints " +
"WHERE UPPER(table_name) = 'OCCUPATION_SKILLS' " +
"AND UPPER(constraint_name) = 'CK_OCCUPATION_SKILLS_IMPORTANCE_PROXY' " +
"AND UPPER(constraint_type) = 'CHECK'",
Long.class));
}
@Test
void minimumTaxonomyChainIsUsableAndCoversCoreConstraints() {
sourceVersionId = insertAndReturnId(
"INSERT INTO skill_source_versions " +
"(source_name, source_version, license, attribution_url, imported_at) " +
"VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)",
"ESCO", "test-1.2.1", "Commission Decision 2011/833/EU",
"https://esco.ec.europa.eu/en/use-esco/download");
industryId = insertAndReturnId(
"INSERT INTO skill_industries (code, name_zh, source_code, sort_order) " +
"VALUES (?, ?, ?, ?)",
"TEST_INTERNET", "互联网", "PROJECT-INTERNET", 1);
occupationId = insertAndReturnId(
"INSERT INTO skill_occupations " +
"(industry_id, code, name_zh, name_en, source_code, source_version_id, sort_order) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)",
industryId, "TEST_FRONTEND", "前端开发工程师", "Frontend Developer",
"ESCO-2513.2", sourceVersionId, 1);
categoryId = insertAndReturnId(
"INSERT INTO skill_categories " +
"(occupation_id, code, name_zh, suggestion_template_code, sort_order) " +
"VALUES (?, ?, ?, ?, ?)",
occupationId, "TEST_PROGRAMMING_LANGUAGE", "编程语言",
"PROGRAMMING_LANGUAGE", 1);
skillId = insertAndReturnId(
"INSERT INTO skills " +
"(code, name_zh, name_en, source_type, source_code, source_version_id, sort_order) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)",
"TEST_SKILL_JAVA", "Java", "Java", "ESCO", "ESCO-SKILL-JAVA",
sourceVersionId, 1);
insertAndReturnId(
"INSERT INTO skill_aliases (skill_id, alias, normalized_alias) VALUES (?, ?, ?)",
skillId, "Java SE", "java se");
occupationSkillId = insertAndReturnId(
"INSERT INTO occupation_skills " +
"(occupation_id, skill_id, category_id, importance, relationship_type, source_version_id) " +
"VALUES (?, ?, ?, ?, ?, ?)",
occupationId, skillId, categoryId, null, "ESSENTIAL", sourceVersionId);
Map<String, Object> joined = jdbcTemplate.queryForMap(
"SELECT s.name_zh AS skill_name, c.name_zh AS category_name, os.importance, " +
"os.importance_source_code, os.importance_element_id, os.importance_is_proxy " +
"FROM occupation_skills os " +
"JOIN skills s ON s.id = os.skill_id " +
"JOIN skill_categories c ON c.id = os.category_id " +
"WHERE os.id = ?", occupationSkillId);
assertEquals("Java", joined.get("skill_name"));
assertEquals("编程语言", joined.get("category_name"));
assertNull(joined.get("importance"));
assertNull(joined.get("importance_source_code"));
assertNull(joined.get("importance_element_id"));
assertEquals(Boolean.FALSE, joined.get("importance_is_proxy"));
assertEquals(1L, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM occupation_skills WHERE id = ? AND importance_is_proxy = FALSE",
Long.class, occupationSkillId));
assertConstraintFailure("UPDATE occupation_skills SET importance = ? WHERE id = ?",
new BigDecimal("4.25"), occupationSkillId);
jdbcTemplate.update("UPDATE occupation_skills SET importance = ?, " +
"importance_source_code = ?, importance_element_id = ?, importance_is_proxy = ? " +
"WHERE id = ?",
new BigDecimal("4.25"), "15-1252.00", "4.A.3.b.1", Boolean.TRUE, occupationSkillId);
Map<String, Object> weighted = jdbcTemplate.queryForMap(
"SELECT importance, importance_source_code, importance_element_id, importance_is_proxy " +
"FROM occupation_skills WHERE id = ?",
occupationSkillId);
assertEquals(new BigDecimal("4.25"), weighted.get("importance"));
assertEquals("15-1252.00", weighted.get("importance_source_code"));
assertEquals("4.A.3.b.1", weighted.get("importance_element_id"));
assertEquals(Boolean.TRUE, weighted.get("importance_is_proxy"));
assertEquals(1L, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM occupation_skills WHERE id = ? AND importance_is_proxy = TRUE",
Long.class, occupationSkillId));
assertConstraintFailure(
"INSERT INTO skill_source_versions " +
"(source_name, source_version, license, attribution_url) VALUES (?, ?, ?, ?)",
"ESCO", "test-1.2.1", "duplicate", "https://example.invalid");
assertConstraintFailure(
"INSERT INTO skill_categories " +
"(occupation_id, code, name_zh, suggestion_template_code, sort_order) " +
"VALUES (?, ?, ?, ?, ?)",
occupationId, "TEST_INVALID_CATEGORY", "非法分类", "INVALID_TEMPLATE", 2);
assertConstraintFailure(
"INSERT INTO skills " +
"(code, name_zh, source_type, source_code, source_version_id, sort_order) " +
"VALUES (?, ?, ?, ?, ?, ?)",
"TEST_INVALID_SKILL", "非法技能", "INVALID_SOURCE", "INVALID-SOURCE-1",
sourceVersionId, 2);
secondSkillId = insertAndReturnId(
"INSERT INTO skills " +
"(code, name_zh, source_type, source_code, source_version_id, sort_order) " +
"VALUES (?, ?, ?, ?, ?, ?)",
"TEST_SKILL_SPRING", "Spring Boot", "ESCO", "ESCO-SKILL-SPRING",
sourceVersionId, 2);
assertConstraintFailure(
"INSERT INTO skills " +
"(code, name_zh, source_type, source_code, source_version_id, sort_order) " +
"VALUES (?, ?, ?, ?, ?, ?)",
"TEST_SKILL_NULL_NAME", null, "PROJECT", "PROJECT-NULL-NAME",
sourceVersionId, 3);
assertConstraintFailure(
"INSERT INTO occupation_skills " +
"(occupation_id, skill_id, category_id, relationship_type, source_version_id) " +
"VALUES (?, ?, ?, ?, ?)",
occupationId, secondSkillId, categoryId, "INVALID_RELATIONSHIP", sourceVersionId);
assertConstraintFailure(
"INSERT INTO skill_aliases (skill_id, alias, normalized_alias) VALUES (?, ?, ?)",
secondSkillId, "Java SE alternate", "java se");
assertConstraintFailure(
"UPDATE occupation_skills SET importance_is_proxy = ? WHERE id = ?",
(Object) null, occupationSkillId);
assertTrue(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM skill_aliases WHERE skill_id = ? AND normalized_alias = ?",
Integer.class, skillId, "java se") == 1);
}
@AfterEach
void cleanUp() {
if (occupationSkillId != null) {
jdbcTemplate.update("DELETE FROM occupation_skills WHERE id = ?", occupationSkillId);
}
if (secondSkillId != null) {
jdbcTemplate.update("DELETE FROM skill_aliases WHERE skill_id = ?", secondSkillId);
jdbcTemplate.update("DELETE FROM skills WHERE id = ?", secondSkillId);
}
if (skillId != null) {
jdbcTemplate.update("DELETE FROM skill_aliases WHERE skill_id = ?", skillId);
jdbcTemplate.update("DELETE FROM skills WHERE id = ?", skillId);
}
if (categoryId != null) {
jdbcTemplate.update("DELETE FROM skill_categories WHERE id = ?", categoryId);
}
if (occupationId != null) {
jdbcTemplate.update("DELETE FROM skill_occupations WHERE id = ?", occupationId);
}
if (industryId != null) {
jdbcTemplate.update("DELETE FROM skill_industries WHERE id = ?", industryId);
}
if (sourceVersionId != null) {
jdbcTemplate.update("DELETE FROM skill_source_versions WHERE id = ?", sourceVersionId);
}
}
private void assertBooleanColumnMetadata(String table, String column) {
Map<String, Object> metadata = jdbcTemplate.queryForMap(
"SELECT data_type, column_default, is_nullable FROM information_schema.columns " +
"WHERE UPPER(table_name) = ? AND UPPER(column_name) = ?",
table.toUpperCase(Locale.ROOT), column.toUpperCase(Locale.ROOT));
assertEquals("BOOLEAN", String.valueOf(metadata.get("data_type")).toUpperCase(Locale.ROOT));
assertEquals("FALSE", String.valueOf(metadata.get("column_default")).toUpperCase(Locale.ROOT));
assertEquals("NO", String.valueOf(metadata.get("is_nullable")).toUpperCase(Locale.ROOT));
}
private void assertTableAndColumns(String table, String... columns) throws SQLException {
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metadata = connection.getMetaData();
String actualTable = findTable(metadata, table);
assertTrue(actualTable != null, "Missing table: " + table);
List<String> actualColumns = new ArrayList<>();
try (ResultSet resultSet = metadata.getColumns(null, null, actualTable, null)) {
while (resultSet.next()) {
actualColumns.add(resultSet.getString("COLUMN_NAME").toLowerCase());
}
}
for (String column : columns) {
assertTrue(actualColumns.contains(column.toLowerCase()),
"Missing column " + table + "." + column + "; actual=" + actualColumns);
}
}
}
private void assertUniqueIndex(String table, String... columns) throws SQLException {
assertTrue(hasIndex(table, true, columns),
"Missing unique index on " + table + "(" + String.join(", ", columns) + ")");
}
private void assertIndex(String table, String... columns) throws SQLException {
assertTrue(hasIndex(table, false, columns),
"Missing index on " + table + "(" + String.join(", ", columns) + ")");
}
private boolean hasIndex(String table, boolean unique, String... expectedColumns) throws SQLException {
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metadata = connection.getMetaData();
String actualTable = findTable(metadata, table);
if (actualTable == null) {
return false;
}
Map<String, TreeMap<Short, String>> indexes = new LinkedHashMap<>();
try (ResultSet resultSet = metadata.getIndexInfo(null, null, actualTable, false, false)) {
while (resultSet.next()) {
String indexName = resultSet.getString("INDEX_NAME");
if (indexName == null || resultSet.getBoolean("NON_UNIQUE") == unique) {
continue;
}
indexes.computeIfAbsent(indexName, ignored -> new TreeMap<>())
.put(resultSet.getShort("ORDINAL_POSITION"),
resultSet.getString("COLUMN_NAME").toLowerCase());
}
}
List<String> expected = new ArrayList<>();
for (String column : expectedColumns) {
expected.add(column.toLowerCase());
}
return indexes.values().stream()
.map(TreeMap::values)
.map(values -> new ArrayList<>(values))
.anyMatch(expected::equals);
}
}
private void assertForeignKey(String childTable, String childColumn,
String parentTable, String parentColumn) throws SQLException {
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metadata = connection.getMetaData();
String actualChildTable = findTable(metadata, childTable);
String actualParentTable = findTable(metadata, parentTable);
if (actualChildTable == null || actualParentTable == null) {
throw new AssertionError("Missing table for foreign key " + childTable + "." + childColumn);
}
try (ResultSet resultSet = metadata.getImportedKeys(null, null, actualChildTable)) {
while (resultSet.next()) {
if (actualParentTable.equalsIgnoreCase(resultSet.getString("PKTABLE_NAME"))
&& childColumn.equalsIgnoreCase(resultSet.getString("FKCOLUMN_NAME"))
&& parentColumn.equalsIgnoreCase(resultSet.getString("PKCOLUMN_NAME"))) {
return;
}
}
}
}
throw new AssertionError("Missing foreign key " + childTable + "." + childColumn
+ " -> " + parentTable + "." + parentColumn);
}
private String findTable(DatabaseMetaData metadata, String expected) throws SQLException {
try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) {
while (resultSet.next()) {
String actual = resultSet.getString("TABLE_NAME");
if (expected.equalsIgnoreCase(actual)) {
return actual;
}
}
}
return null;
}
private Long insertAndReturnId(String sql, Object... args) {
jdbcTemplate.update(sql, args);
String table = extractTable(sql);
return jdbcTemplate.queryForObject("SELECT MAX(id) FROM " + table, Long.class);
}
private String extractTable(String sql) {
String upper = sql.toUpperCase();
int fromIndex = upper.indexOf(" INTO ");
int openIndex = sql.indexOf('(', fromIndex);
return sql.substring(fromIndex + " INTO ".length(), openIndex).trim();
}
private void assertConstraintFailure(String sql, Object... args) {
assertThrows(DataAccessException.class, () -> jdbcTemplate.update(sql, args));
}
}

294
mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyRepositoryTest.java

@ -0,0 +1,294 @@
package com.mydreamjob.skill;
import com.mydreamjob.skill.domain.OccupationSkill;
import com.mydreamjob.skill.domain.Skill;
import com.mydreamjob.skill.domain.SkillAlias;
import com.mydreamjob.skill.dto.SkillCloudSkillProjection;
import com.mydreamjob.skill.repository.OccupationSkillRepository;
import com.mydreamjob.skill.repository.SkillAliasRepository;
import com.mydreamjob.skill.repository.SkillCategoryRepository;
import com.mydreamjob.skill.repository.SkillIndustryRepository;
import com.mydreamjob.skill.repository.SkillOccupationRepository;
import com.mydreamjob.skill.repository.SkillRepository;
import com.mydreamjob.skill.repository.SkillSourceVersionRepository;
import jakarta.persistence.EntityManagerFactory;
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 org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.boot.model.relational.Namespace;
import org.hibernate.boot.model.relational.Sequence;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.persister.entity.EntityPersister;
import org.hibernate.mapping.Table;
import org.hibernate.tool.schema.spi.SchemaFilter;
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:integrationdb_skill_repository;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillTaxonomyRepositoryTest$SkillTaxonomySchemaFilterProvider"
})
@ActiveProfiles("integration-test")
class SkillTaxonomyRepositoryTest {
@Autowired
private SkillSourceVersionRepository sourceVersionRepository;
@Autowired
private SkillIndustryRepository industryRepository;
@Autowired
private SkillOccupationRepository occupationRepository;
@Autowired
private SkillCategoryRepository categoryRepository;
@Autowired
private SkillRepository skillRepository;
@Autowired
private SkillAliasRepository skillAliasRepository;
@Autowired
private OccupationSkillRepository occupationSkillRepository;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private EntityManagerFactory entityManagerFactory;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Test
void entityMappingsAreValidatedAndSeedCountsMatchV36() {
assertThat(sourceVersionRepository.count()).isEqualTo(2L);
assertThat(industryRepository.count()).isEqualTo(3L);
assertThat(occupationRepository.count()).isEqualTo(10L);
assertThat(categoryRepository.count()).isEqualTo(68L);
assertThat(skillRepository.count()).isEqualTo(72L);
assertThat(skillAliasRepository.count()).isEqualTo(72L);
assertThat(occupationSkillRepository.count()).isEqualTo(139L);
}
@Test
void associationChecksAcceptOnlyConsistentTaxonomyPaths() {
assertThat(industryRepository.existsById(1L)).isTrue();
assertThat(occupationRepository.existsByIdAndIndustryId(1L, 1L)).isTrue();
assertThat(categoryRepository.existsByIdAndOccupationId(101L, 1L)).isTrue();
assertThat(occupationRepository.existsByIdAndIndustryId(1L, 2L)).isFalse();
assertThat(categoryRepository.existsByIdAndOccupationId(101L, 2L)).isFalse();
assertThat(occupationRepository.existsByIdAndIndustryId(999L, 1L)).isFalse();
assertThat(categoryRepository.existsByIdAndOccupationId(999L, 1L)).isFalse();
}
@Test
void projectionUsesWholeOccupationWhenCategoryMissingAndFiltersWhenProvided() {
List<SkillCloudSkillProjection> occupationSkills =
occupationSkillRepository.findSkillProjectionsByOccupationId(1L);
assertThat(occupationSkills).hasSize(15);
assertThat(occupationSkills)
.extracting(SkillCloudSkillProjection::skillId)
.containsExactlyInAnyOrder(
3L, 4L, 7L, 8L, 26L, 27L, 28L, 35L,
54L, 55L, 38L, 39L, 32L, 24L, 50L);
List<SkillCloudSkillProjection> languageSkills =
occupationSkillRepository.findSkillProjectionsByOccupationIdAndCategoryId(1L, 101L);
assertThat(languageSkills).hasSize(2);
assertThat(languageSkills)
.extracting(SkillCloudSkillProjection::skillId)
.containsExactlyInAnyOrder(3L, 4L);
assertThat(languageSkills)
.extracting(SkillCloudSkillProjection::categoryId)
.containsOnly(101L);
assertThat(occupationSkillRepository
.findSkillProjectionsByOccupationIdAndCategoryId(2L, 101L))
.isEmpty();
}
@Test
@Transactional
void projectionQueriesRejectCrossOccupationCategoryMismatch() {
jdbcTemplate.update("""
INSERT INTO occupation_skills (
occupation_id, skill_id, category_id, importance,
importance_source_code, importance_element_id,
importance_is_proxy, relationship_type, source_version_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
1L, 1L, 201L, new BigDecimal("4.61"),
"15-1252.00", "4.A.3.b.1", Boolean.TRUE, "ESSENTIAL", 2L);
List<SkillCloudSkillProjection> occupationOneSkills =
occupationSkillRepository.findSkillProjectionsByOccupationId(1L);
assertThat(occupationOneSkills)
.extracting(SkillCloudSkillProjection::skillId)
.contains(3L)
.doesNotContain(1L);
assertThat(occupationSkillRepository
.findSkillProjectionsByOccupationIdAndCategoryId(1L, 201L))
.isEmpty();
assertThat(occupationSkillRepository
.findSkillProjectionsByOccupationIdAndCategoryId(2L, 201L))
.extracting(SkillCloudSkillProjection::skillId)
.contains(1L);
}
@Test
void projectionCarriesOnetProvenanceNullImportanceAndSortOrders() {
SkillCloudSkillProjection react = occupationSkillRepository
.findSkillProjectionsByOccupationIdAndCategoryId(1L, 103L)
.stream()
.filter(item -> item.skillId().equals(26L))
.findFirst()
.orElseThrow();
assertThat(react.skillCode()).isEqualTo("REACT");
assertThat(react.nameZh()).isEqualTo("React");
assertThat(react.nameEn()).isEqualTo("React");
assertThat(react.sourceType()).isEqualTo("O*NET");
assertThat(react.sourceCode()).isEqualTo("15-1254.00|React|2.E.7.h");
assertThat(react.categoryName()).isEqualTo("前端框架");
assertThat(react.importance()).isEqualByComparingTo(new BigDecimal("4.88"));
assertThat(react.importanceSourceCode()).isEqualTo("15-1254.00");
assertThat(react.importanceElementId()).isEqualTo("4.A.3.b.1");
assertThat(react.importanceIsProxy()).isTrue();
assertThat(react.skillSortOrder()).isEqualTo(26);
assertThat(react.categorySortOrder()).isEqualTo(3);
SkillCloudSkillProjection figma = occupationSkillRepository
.findSkillProjectionsByOccupationIdAndCategoryId(1L, 104L)
.stream()
.filter(item -> item.skillId().equals(50L))
.findFirst()
.orElseThrow();
assertThat(figma.sourceType()).isEqualTo("O*NET");
assertThat(figma.sourceCode()).isEqualTo("15-1254.00|Figma|2.E.7.d");
assertThat(figma.importance()).isNull();
assertThat(figma.importanceSourceCode()).isNull();
assertThat(figma.importanceElementId()).isNull();
assertThat(figma.importanceIsProxy()).isFalse();
}
@Test
void aliasAndSkillLookupsReturnOnlyRequestedRecords() {
Optional<SkillAlias> normalizedAlias = skillAliasRepository.findByNormalizedAlias("react");
assertThat(normalizedAlias).isPresent();
assertThat(normalizedAlias.orElseThrow().getSkillId()).isEqualTo(26L);
List<SkillAlias> aliases = skillAliasRepository
.findAllBySkillIdInOrderByIdAsc(List.of(3L, 26L));
assertThat(aliases).hasSize(2);
assertThat(aliases)
.extracting(SkillAlias::getSkillId)
.containsExactly(3L, 26L);
Optional<Skill> react = skillRepository.findByCode("REACT");
assertThat(react).isPresent();
assertThat(react.orElseThrow().getSourceType()).isEqualTo("O*NET");
List<Skill> skills = skillRepository.findAllByIdInOrderByIdAsc(List.of(4L, 3L));
assertThat(skills)
.extracting(Skill::getId)
.containsExactly(3L, 4L);
}
@Test
void importanceIsProxyUsesDefaultBooleanMapping() throws NoSuchFieldException {
SessionFactoryImplementor sessionFactory = entityManagerFactory.unwrap(SessionFactoryImplementor.class);
EntityPersister persister = sessionFactory.getMappingMetamodel().getEntityDescriptor(OccupationSkill.class);
org.hibernate.type.Type propertyType = persister.getPropertyType("importanceIsProxy");
assertThat(propertyType.getReturnedClass()).isEqualTo(Boolean.class);
JdbcTypeCode explicitJdbcType = OccupationSkill.class
.getDeclaredField("importanceIsProxy")
.getAnnotation(JdbcTypeCode.class);
assertThat(explicitJdbcType)
.as("importanceIsProxy must use the default Boolean mapping without a forced JDBC type")
.isNull();
}
/**
* The legacy H2 schema has TINYINT/Boolean mismatches outside the V35 taxonomy.
* Scope validation to the seven T4 tables without changing historical migrations or entities.
*/
public static class SkillTaxonomySchemaFilterProvider implements SchemaFilterProvider {
private static final Set<String> VALIDATED_TABLES = Set.of(
"skill_source_versions",
"skill_industries",
"skill_occupations",
"skill_categories",
"skills",
"skill_aliases",
"occupation_skills"
);
@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;
}
};
}
}
}

93
mydreamjob-server/src/test/resources/skill-cloud/onet-31-reference.csv

@ -0,0 +1,93 @@
evidence_type,source_soc,element_id,data_value,workplace_example,source_file
SOFTWARE,11-1011.00,2.E.5.b,,Adobe Acrobat,software_skills.csv
SOFTWARE,11-1021.00,2.E.6.f,,Amazon Web Services AWS software,software_skills.csv
SOFTWARE,11-3021.00,2.E.11.a,,Nagios,software_skills.csv
SOFTWARE,13-1199.07,2.E.14.d,,Nmap,software_skills.csv
SOFTWARE,15-1241.01,2.E.11.a,,Wireshark,software_skills.csv
SOFTWARE,15-1252.00,2.E.13.c,,Bash,software_skills.csv
SOFTWARE,15-1252.00,2.E.7.e,,C++,software_skills.csv
SOFTWARE,15-1252.00,2.E.7.h,,Django,software_skills.csv
SOFTWARE,15-1252.00,2.E.10.a,,Docker,software_skills.csv
SOFTWARE,15-1252.00,2.E.5.c,,Git,software_skills.csv
SOFTWARE,15-1252.00,2.E.7.b,,Go,software_skills.csv
SOFTWARE,15-1252.00,2.E.7.e,,Gradle,software_skills.csv
SOFTWARE,15-1252.00,2.E.10.a,,Kubernetes,software_skills.csv
SOFTWARE,15-1252.00,2.E.6.d,,MongoDB,software_skills.csv
SOFTWARE,15-1252.00,2.E.6.d,,Redis,software_skills.csv
SOFTWARE,15-1252.00,2.E.7.f,,SonarQube,software_skills.csv
SOFTWARE,15-1252.00,2.E.10.a,,Spring Boot,software_skills.csv
SOFTWARE,15-1253.00,2.E.7.e,,Apache JMeter,software_skills.csv
SOFTWARE,15-1253.00,2.E.7.f,,JUnit,software_skills.csv
SOFTWARE,15-1253.00,2.E.13.c,,Postman,software_skills.csv
SOFTWARE,15-1253.00,2.E.7.f,,Selenium,software_skills.csv
SOFTWARE,15-1254.00,2.E.7.d,,Figma,software_skills.csv
SOFTWARE,15-1254.00,2.E.7.h,,Node.js,software_skills.csv
SOFTWARE,15-1254.00,2.E.7.h,,React,software_skills.csv
SOFTWARE,15-1254.00,2.E.7.h,,Vue.js,software_skills.csv
SOFTWARE,15-1255.00,2.E.4.g,,Axure RP,software_skills.csv
SOFTWARE,15-1255.00,2.E.7.a,,Chef,software_skills.csv
SOFTWARE,15-1299.07,2.E.7.b,,Apache Kafka,software_skills.csv
SOFTWARE,15-1299.08,2.E.10.a,,GitLab,software_skills.csv
SOFTWARE,15-2051.00,2.E.6.m,,Apache Spark,software_skills.csv
SOFTWARE,15-2051.00,2.E.7.b,,Flask,software_skills.csv
SOFTWARE,15-2051.00,2.E.4.i,,Microsoft Excel,software_skills.csv
SOFTWARE,15-2051.00,2.E.6.m,,Microsoft Power BI,software_skills.csv
SOFTWARE,15-2051.00,2.E.6.f,,NumPy,software_skills.csv
SOFTWARE,15-2051.00,2.E.6.f,,PyTorch,software_skills.csv
SOFTWARE,15-2051.00,2.E.7.b,,Scikit-learn,software_skills.csv
SOFTWARE,15-2051.00,2.E.6.m,,Tableau,software_skills.csv
SOFTWARE,15-2051.00,2.E.9.d,,TensorFlow,software_skills.csv
SOFTWARE,15-2051.00,2.E.6.f,,pandas,software_skills.csv
SOFTWARE,17-2051.02,2.E.10.e,,Google Chrome,software_skills.csv
SOFTWARE,17-2071.00,2.E.7.f,,Debugging software,software_skills.csv
SOFTWARE,19-3092.00,2.E.4.b,,Adobe Photoshop,software_skills.csv
SOFTWARE,25-2022.00,2.E.8.b,,Moodle,software_skills.csv
SOFTWARE,25-9031.00,2.E.8.b,,Articulate Storyline,software_skills.csv
SOFTWARE,27-4012.00,2.E.4.f,,Microsoft PowerPoint,software_skills.csv
SOFTWARE,41-3031.00,2.E.2.d,,Bloomberg Professional,software_skills.csv
SOFTWARE,41-4011.00,2.E.7.b,,Microsoft Azure software,software_skills.csv
IMPORTANCE,13-1082.00,2.C.6,2.52,,knowledge.csv
IMPORTANCE,13-1082.00,4.A.2.a.4,3.94,,work_activities.csv
IMPORTANCE,13-1082.00,4.A.2.b.4,3.73,,work_activities.csv
IMPORTANCE,13-1082.00,4.A.3.b.1,4.45,,work_activities.csv
IMPORTANCE,13-1082.00,4.A.4.a.2,4.73,,work_activities.csv
IMPORTANCE,13-2031.00,2.C.1.c,4.42,,knowledge.csv
IMPORTANCE,13-2031.00,2.C.4.a,4.03,,knowledge.csv
IMPORTANCE,13-2031.00,4.A.2.a.2,4.44,,work_activities.csv
IMPORTANCE,13-2031.00,4.A.2.a.4,4.01,,work_activities.csv
IMPORTANCE,13-2031.00,4.A.3.b.1,4.48,,work_activities.csv
IMPORTANCE,13-2053.00,2.C.4.a,3.27,,knowledge.csv
IMPORTANCE,13-2053.00,4.A.2.a.2,4.13,,work_activities.csv
IMPORTANCE,13-2053.00,4.A.2.a.3,3.90,,work_activities.csv
IMPORTANCE,13-2053.00,4.A.2.a.4,4.43,,work_activities.csv
IMPORTANCE,13-2053.00,4.A.3.b.1,4.41,,work_activities.csv
IMPORTANCE,13-2099.01,2.C.1.c,4.20,,knowledge.csv
IMPORTANCE,13-2099.01,4.A.1.b.3,3.53,,work_activities.csv
IMPORTANCE,13-2099.01,4.A.2.a.2,4.50,,work_activities.csv
IMPORTANCE,13-2099.01,4.A.2.a.3,3.05,,work_activities.csv
IMPORTANCE,13-2099.01,4.A.3.b.1,4.68,,work_activities.csv
IMPORTANCE,15-1244.00,2.C.8.a,2.34,,knowledge.csv
IMPORTANCE,15-1244.00,4.A.1.a.2,3.60,,work_activities.csv
IMPORTANCE,15-1244.00,4.A.2.a.2,4.03,,work_activities.csv
IMPORTANCE,15-1244.00,4.A.3.b.1,4.83,,work_activities.csv
IMPORTANCE,15-1252.00,2.C.8.a,2.57,,knowledge.csv
IMPORTANCE,15-1252.00,4.A.2.a.2,4.38,,work_activities.csv
IMPORTANCE,15-1252.00,4.A.2.a.3,3.94,,work_activities.csv
IMPORTANCE,15-1252.00,4.A.3.b.1,4.61,,work_activities.csv
IMPORTANCE,15-1253.00,4.A.1.b.3,2.94,,work_activities.csv
IMPORTANCE,15-1253.00,4.A.2.a.2,4.38,,work_activities.csv
IMPORTANCE,15-1253.00,4.A.2.a.3,4.24,,work_activities.csv
IMPORTANCE,15-1253.00,4.A.3.b.1,4.94,,work_activities.csv
IMPORTANCE,15-1254.00,4.A.2.a.3,3.33,,work_activities.csv
IMPORTANCE,15-1254.00,4.A.3.b.1,4.88,,work_activities.csv
IMPORTANCE,15-1254.00,4.A.4.a.2,3.88,,work_activities.csv
IMPORTANCE,15-2051.00,2.C.4.a,4.52,,knowledge.csv
IMPORTANCE,15-2051.00,2.C.6,2.28,,knowledge.csv
IMPORTANCE,15-2051.00,4.A.2.a.2,4.71,,work_activities.csv
IMPORTANCE,15-2051.00,4.A.2.a.4,4.83,,work_activities.csv
IMPORTANCE,15-2051.00,4.A.3.b.1,4.75,,work_activities.csv
IMPORTANCE,25-9031.00,2.C.6,4.78,,knowledge.csv
IMPORTANCE,25-9031.00,4.A.2.a.3,3.83,,work_activities.csv
IMPORTANCE,25-9031.00,4.A.2.a.4,4.19,,work_activities.csv
IMPORTANCE,25-9031.00,4.A.2.b.2,3.72,,work_activities.csv
IMPORTANCE,25-9031.00,4.A.3.b.1,4.45,,work_activities.csv
1 evidence_type source_soc element_id data_value workplace_example source_file
2 SOFTWARE 11-1011.00 2.E.5.b Adobe Acrobat software_skills.csv
3 SOFTWARE 11-1021.00 2.E.6.f Amazon Web Services AWS software software_skills.csv
4 SOFTWARE 11-3021.00 2.E.11.a Nagios software_skills.csv
5 SOFTWARE 13-1199.07 2.E.14.d Nmap software_skills.csv
6 SOFTWARE 15-1241.01 2.E.11.a Wireshark software_skills.csv
7 SOFTWARE 15-1252.00 2.E.13.c Bash software_skills.csv
8 SOFTWARE 15-1252.00 2.E.7.e C++ software_skills.csv
9 SOFTWARE 15-1252.00 2.E.7.h Django software_skills.csv
10 SOFTWARE 15-1252.00 2.E.10.a Docker software_skills.csv
11 SOFTWARE 15-1252.00 2.E.5.c Git software_skills.csv
12 SOFTWARE 15-1252.00 2.E.7.b Go software_skills.csv
13 SOFTWARE 15-1252.00 2.E.7.e Gradle software_skills.csv
14 SOFTWARE 15-1252.00 2.E.10.a Kubernetes software_skills.csv
15 SOFTWARE 15-1252.00 2.E.6.d MongoDB software_skills.csv
16 SOFTWARE 15-1252.00 2.E.6.d Redis software_skills.csv
17 SOFTWARE 15-1252.00 2.E.7.f SonarQube software_skills.csv
18 SOFTWARE 15-1252.00 2.E.10.a Spring Boot software_skills.csv
19 SOFTWARE 15-1253.00 2.E.7.e Apache JMeter software_skills.csv
20 SOFTWARE 15-1253.00 2.E.7.f JUnit software_skills.csv
21 SOFTWARE 15-1253.00 2.E.13.c Postman software_skills.csv
22 SOFTWARE 15-1253.00 2.E.7.f Selenium software_skills.csv
23 SOFTWARE 15-1254.00 2.E.7.d Figma software_skills.csv
24 SOFTWARE 15-1254.00 2.E.7.h Node.js software_skills.csv
25 SOFTWARE 15-1254.00 2.E.7.h React software_skills.csv
26 SOFTWARE 15-1254.00 2.E.7.h Vue.js software_skills.csv
27 SOFTWARE 15-1255.00 2.E.4.g Axure RP software_skills.csv
28 SOFTWARE 15-1255.00 2.E.7.a Chef software_skills.csv
29 SOFTWARE 15-1299.07 2.E.7.b Apache Kafka software_skills.csv
30 SOFTWARE 15-1299.08 2.E.10.a GitLab software_skills.csv
31 SOFTWARE 15-2051.00 2.E.6.m Apache Spark software_skills.csv
32 SOFTWARE 15-2051.00 2.E.7.b Flask software_skills.csv
33 SOFTWARE 15-2051.00 2.E.4.i Microsoft Excel software_skills.csv
34 SOFTWARE 15-2051.00 2.E.6.m Microsoft Power BI software_skills.csv
35 SOFTWARE 15-2051.00 2.E.6.f NumPy software_skills.csv
36 SOFTWARE 15-2051.00 2.E.6.f PyTorch software_skills.csv
37 SOFTWARE 15-2051.00 2.E.7.b Scikit-learn software_skills.csv
38 SOFTWARE 15-2051.00 2.E.6.m Tableau software_skills.csv
39 SOFTWARE 15-2051.00 2.E.9.d TensorFlow software_skills.csv
40 SOFTWARE 15-2051.00 2.E.6.f pandas software_skills.csv
41 SOFTWARE 17-2051.02 2.E.10.e Google Chrome software_skills.csv
42 SOFTWARE 17-2071.00 2.E.7.f Debugging software software_skills.csv
43 SOFTWARE 19-3092.00 2.E.4.b Adobe Photoshop software_skills.csv
44 SOFTWARE 25-2022.00 2.E.8.b Moodle software_skills.csv
45 SOFTWARE 25-9031.00 2.E.8.b Articulate Storyline software_skills.csv
46 SOFTWARE 27-4012.00 2.E.4.f Microsoft PowerPoint software_skills.csv
47 SOFTWARE 41-3031.00 2.E.2.d Bloomberg Professional software_skills.csv
48 SOFTWARE 41-4011.00 2.E.7.b Microsoft Azure software software_skills.csv
49 IMPORTANCE 13-1082.00 2.C.6 2.52 knowledge.csv
50 IMPORTANCE 13-1082.00 4.A.2.a.4 3.94 work_activities.csv
51 IMPORTANCE 13-1082.00 4.A.2.b.4 3.73 work_activities.csv
52 IMPORTANCE 13-1082.00 4.A.3.b.1 4.45 work_activities.csv
53 IMPORTANCE 13-1082.00 4.A.4.a.2 4.73 work_activities.csv
54 IMPORTANCE 13-2031.00 2.C.1.c 4.42 knowledge.csv
55 IMPORTANCE 13-2031.00 2.C.4.a 4.03 knowledge.csv
56 IMPORTANCE 13-2031.00 4.A.2.a.2 4.44 work_activities.csv
57 IMPORTANCE 13-2031.00 4.A.2.a.4 4.01 work_activities.csv
58 IMPORTANCE 13-2031.00 4.A.3.b.1 4.48 work_activities.csv
59 IMPORTANCE 13-2053.00 2.C.4.a 3.27 knowledge.csv
60 IMPORTANCE 13-2053.00 4.A.2.a.2 4.13 work_activities.csv
61 IMPORTANCE 13-2053.00 4.A.2.a.3 3.90 work_activities.csv
62 IMPORTANCE 13-2053.00 4.A.2.a.4 4.43 work_activities.csv
63 IMPORTANCE 13-2053.00 4.A.3.b.1 4.41 work_activities.csv
64 IMPORTANCE 13-2099.01 2.C.1.c 4.20 knowledge.csv
65 IMPORTANCE 13-2099.01 4.A.1.b.3 3.53 work_activities.csv
66 IMPORTANCE 13-2099.01 4.A.2.a.2 4.50 work_activities.csv
67 IMPORTANCE 13-2099.01 4.A.2.a.3 3.05 work_activities.csv
68 IMPORTANCE 13-2099.01 4.A.3.b.1 4.68 work_activities.csv
69 IMPORTANCE 15-1244.00 2.C.8.a 2.34 knowledge.csv
70 IMPORTANCE 15-1244.00 4.A.1.a.2 3.60 work_activities.csv
71 IMPORTANCE 15-1244.00 4.A.2.a.2 4.03 work_activities.csv
72 IMPORTANCE 15-1244.00 4.A.3.b.1 4.83 work_activities.csv
73 IMPORTANCE 15-1252.00 2.C.8.a 2.57 knowledge.csv
74 IMPORTANCE 15-1252.00 4.A.2.a.2 4.38 work_activities.csv
75 IMPORTANCE 15-1252.00 4.A.2.a.3 3.94 work_activities.csv
76 IMPORTANCE 15-1252.00 4.A.3.b.1 4.61 work_activities.csv
77 IMPORTANCE 15-1253.00 4.A.1.b.3 2.94 work_activities.csv
78 IMPORTANCE 15-1253.00 4.A.2.a.2 4.38 work_activities.csv
79 IMPORTANCE 15-1253.00 4.A.2.a.3 4.24 work_activities.csv
80 IMPORTANCE 15-1253.00 4.A.3.b.1 4.94 work_activities.csv
81 IMPORTANCE 15-1254.00 4.A.2.a.3 3.33 work_activities.csv
82 IMPORTANCE 15-1254.00 4.A.3.b.1 4.88 work_activities.csv
83 IMPORTANCE 15-1254.00 4.A.4.a.2 3.88 work_activities.csv
84 IMPORTANCE 15-2051.00 2.C.4.a 4.52 knowledge.csv
85 IMPORTANCE 15-2051.00 2.C.6 2.28 knowledge.csv
86 IMPORTANCE 15-2051.00 4.A.2.a.2 4.71 work_activities.csv
87 IMPORTANCE 15-2051.00 4.A.2.a.4 4.83 work_activities.csv
88 IMPORTANCE 15-2051.00 4.A.3.b.1 4.75 work_activities.csv
89 IMPORTANCE 25-9031.00 2.C.6 4.78 knowledge.csv
90 IMPORTANCE 25-9031.00 4.A.2.a.3 3.83 work_activities.csv
91 IMPORTANCE 25-9031.00 4.A.2.a.4 4.19 work_activities.csv
92 IMPORTANCE 25-9031.00 4.A.2.b.2 3.72 work_activities.csv
93 IMPORTANCE 25-9031.00 4.A.3.b.1 4.45 work_activities.csv

222
mydreamjob-server/src/test/resources/skill-cloud/skill-cloud-expected-mappings.csv

@ -0,0 +1,222 @@
record_type,entity_key,expected_source_type,expected_source_code,expected_element_id,expected_importance,expected_is_proxy
OCCUPATION_SOURCE,FRONTEND_DEVELOPER,ESCO,http://data.europa.eu/esco/occupation/c40a2919-48a9-40ea-b506-1f34f693496d,,,
OCCUPATION_SOURCE,BACKEND_DEVELOPER,ESCO,http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1,,,
OCCUPATION_SOURCE,QA_ENGINEER,ESCO,http://data.europa.eu/esco/occupation/106f79e4-6264-45f1-9e7a-297435cd684b,,,
OCCUPATION_SOURCE,DEVOPS_ENGINEER,ESCO,http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1,,,
OCCUPATION_SOURCE,FINANCIAL_DATA_ANALYST,ESCO,http://data.europa.eu/esco/occupation/8d586ee9-0ab1-4155-a3b0-2ca786c8e48c,,,
OCCUPATION_SOURCE,RISK_MODELING_ENGINEER,ESCO,http://data.europa.eu/esco/occupation/b09acdda-6680-49d1-a411-129610ee3bc0,,,
OCCUPATION_SOURCE,QUANTITATIVE_DEVELOPER,ESCO,http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1,,,
OCCUPATION_SOURCE,EDUCATION_PRODUCT_MANAGER,ESCO,http://data.europa.eu/esco/occupation/a79170ad-0b7b-4697-864f-1748359aa10a,,,
OCCUPATION_SOURCE,INSTRUCTIONAL_DESIGNER,ESCO,http://data.europa.eu/esco/occupation/a9c30651-ccbc-4a80-900c-7880615cdf6e,,,
OCCUPATION_SOURCE,EDUCATION_DATA_ANALYST,ESCO,http://data.europa.eu/esco/occupation/d3edb8f8-3a06-47a0-8fb9-9b212c006aa2,,,
SKILL_SOURCE,JAVA,ESCO,http://data.europa.eu/esco/skill/19a8293b-8e95-4de3-983f-77484079c389,,,
SKILL_SOURCE,PYTHON,ESCO,http://data.europa.eu/esco/skill/ccd0a1d9-afda-43d9-b901-96344886e14d,,,
SKILL_SOURCE,JAVASCRIPT,ESCO,http://data.europa.eu/esco/skill/3cd569a2-4f88-4c1e-9995-8dce8c5e51a7,,,
SKILL_SOURCE,TYPESCRIPT,ESCO,http://data.europa.eu/esco/skill/867137fb-ff1b-4ca3-99f3-cb6969aa2c68,,,
SKILL_SOURCE,SQL,ESCO,http://data.europa.eu/esco/skill/598de5b0-5b58-4ea7-8058-a4bc4d18c742,,,
SKILL_SOURCE,CSHARP,ESCO,http://data.europa.eu/esco/skill/4c016b68-4116-468c-9dc6-42710c239e4a,,,
SKILL_SOURCE,HTML,ESCO,http://data.europa.eu/esco/skill/0af062de-eb43-41e9-9b96-249e2cd22d26,,,
SKILL_SOURCE,CSS,ESCO,http://data.europa.eu/esco/skill/e5d1f825-60ed-4bdd-872a-e748c387f777,,,
SKILL_SOURCE,R,ESCO,http://data.europa.eu/esco/skill/51586df8-1c46-4b47-8583-773cb63bf00b,,,
SKILL_SOURCE,MYSQL,ESCO,http://data.europa.eu/esco/skill/4da171e5-779c-4983-a76f-91c16751e99f,,,
SKILL_SOURCE,POSTGRESQL,ESCO,http://data.europa.eu/esco/skill/a8d07b5a-c1a1-42c6-9d53-db9c7a2ca996,,,
SKILL_SOURCE,STATISTICS,ESCO,http://data.europa.eu/esco/skill/7ee4c2ea-b349-4bd2-81a3-ec31475d4833,,,
SKILL_SOURCE,MACHINE_LEARNING,ESCO,http://data.europa.eu/esco/skill/8369c2d6-c100-4cf6-bd83-9668d8678433,,,
SKILL_SOURCE,FINANCIAL_ANALYSIS,ESCO,http://data.europa.eu/esco/skill/99571e68-801f-49af-a897-5f75996642e1,,,
SKILL_SOURCE,RISK_MANAGEMENT,ESCO,http://data.europa.eu/esco/skill/6eff134b-e34f-4d6e-a6e8-5e47cf2228d0,,,
SKILL_SOURCE,INSTRUCTIONAL_DESIGN,ESCO,http://data.europa.eu/esco/skill/e3145bd7-7f35-44ec-a5dc-4417c5c9ab9a,,,
SKILL_SOURCE,CURRICULUM_DEVELOPMENT,ESCO,http://data.europa.eu/esco/skill/142a5319-60cb-42a2-891f-90ba92af18b1,,,
SKILL_SOURCE,LEARNING_MANAGEMENT_SYSTEMS,ESCO,http://data.europa.eu/esco/skill/6c80d53c-d8c9-41fe-998f-091fca208834,,,
SKILL_SOURCE,EDUCATIONAL_TECHNOLOGY,ESCO,http://data.europa.eu/esco/skill/8a6394d1-f44f-4b0d-81d5-7201fc025ba8,,,
SKILL_SOURCE,COMMUNICATION,ESCO,http://data.europa.eu/esco/skill/15d76317-c71a-4fa2-aadc-2ecc34e627b7,,,
SKILL_SOURCE,CRITICAL_THINKING,ESCO,http://data.europa.eu/esco/skill/a9e6718b-b9d8-44c2-b06e-85ceed9d144c,,,
SKILL_SOURCE,COLLABORATION,ESCO,http://data.europa.eu/esco/skill/082967d4-b94c-4305-aa13-35a6aa8c5c7c,,,
SKILL_SOURCE,PROJECT_MANAGEMENT,ESCO,http://data.europa.eu/esco/skill/7111b95d-0ce3-441a-9d92-4c75d05c4388,,,
SKILL_SOURCE,AGILE,ESCO,http://data.europa.eu/esco/skill/dba46f87-0831-49cd-a1c7-340a653c0221,,,
SKILL_SOURCE,JENKINS,ESCO,http://data.europa.eu/esco/skill/f47a1998-0beb-43be-9f46-380aa4d183da,,,
SKILL_SOURCE,REACT,O*NET,15-1254.00|React|2.E.7.h,,,
SKILL_SOURCE,VUE_JS,O*NET,15-1254.00|Vue.js|2.E.7.h,,,
SKILL_SOURCE,NODE_JS,O*NET,15-1254.00|Node.js|2.E.7.h,,,
SKILL_SOURCE,SPRING_BOOT,O*NET,15-1252.00|Spring Boot|2.E.10.a,,,
SKILL_SOURCE,DJANGO,O*NET,15-1252.00|Django|2.E.7.h,,,
SKILL_SOURCE,FLASK,O*NET,15-2051.00|Flask|2.E.7.b,,,
SKILL_SOURCE,GIT,O*NET,15-1252.00|Git|2.E.5.c,,,
SKILL_SOURCE,DOCKER,O*NET,15-1252.00|Docker|2.E.10.a,,,
SKILL_SOURCE,KUBERNETES,O*NET,15-1252.00|Kubernetes|2.E.10.a,,,
SKILL_SOURCE,GRADLE,O*NET,15-1252.00|Gradle|2.E.7.e,,,
SKILL_SOURCE,MONGODB,O*NET,15-1252.00|MongoDB|2.E.6.d,,,
SKILL_SOURCE,REDIS,O*NET,15-1252.00|Redis|2.E.6.d,,,
SKILL_SOURCE,SELENIUM,O*NET,15-1253.00|Selenium|2.E.7.f,,,
SKILL_SOURCE,JUNIT,O*NET,15-1253.00|JUnit|2.E.7.f,,,
SKILL_SOURCE,POSTMAN,O*NET,15-1253.00|Postman|2.E.13.c,,,
SKILL_SOURCE,PANDAS,O*NET,15-2051.00|pandas|2.E.6.f,,,
SKILL_SOURCE,NUMPY,O*NET,15-2051.00|NumPy|2.E.6.f,,,
SKILL_SOURCE,TABLEAU,O*NET,15-2051.00|Tableau|2.E.6.m,,,
SKILL_SOURCE,POWER_BI,O*NET,15-2051.00|Microsoft Power BI|2.E.6.m,,,
SKILL_SOURCE,MICROSOFT_EXCEL,O*NET,15-2051.00|Microsoft Excel|2.E.4.i,,,
SKILL_SOURCE,TENSORFLOW,O*NET,15-2051.00|TensorFlow|2.E.9.d,,,
SKILL_SOURCE,PYTORCH,O*NET,15-2051.00|PyTorch|2.E.6.f,,,
SKILL_SOURCE,SCIKIT_LEARN,O*NET,15-2051.00|Scikit-learn|2.E.7.b,,,
SKILL_SOURCE,APACHE_SPARK,O*NET,15-2051.00|Apache Spark|2.E.6.m,,,
SKILL_SOURCE,FIGMA,O*NET,15-1254.00|Figma|2.E.7.d,,,
SKILL_SOURCE,BASH,O*NET,15-1252.00|Bash|2.E.13.c,,,
SKILL_SOURCE,GO,O*NET,15-1252.00|Go|2.E.7.b,,,
SKILL_SOURCE,CPP,O*NET,15-1252.00|C++|2.E.7.e,,,
SKILL_SOURCE,GOOGLE_CHROME,O*NET,17-2051.02|Google Chrome|2.E.10.e,,,
SKILL_SOURCE,DEBUGGING_SOFTWARE,O*NET,17-2071.00|Debugging software|2.E.7.f,,,
SKILL_SOURCE,APACHE_KAFKA,O*NET,15-1299.07|Apache Kafka|2.E.7.b,,,
SKILL_SOURCE,SONARQUBE,O*NET,15-1252.00|SonarQube|2.E.7.f,,,
SKILL_SOURCE,CHEF,O*NET,15-1255.00|Chef|2.E.7.a,,,
SKILL_SOURCE,GITLAB,O*NET,15-1299.08|GitLab|2.E.10.a,,,
SKILL_SOURCE,NAGIOS,O*NET,11-3021.00|Nagios|2.E.11.a,,,
SKILL_SOURCE,NMAP,O*NET,13-1199.07|Nmap|2.E.14.d,,,
SKILL_SOURCE,BLOOMBERG_PROFESSIONAL,O*NET,41-3031.00|Bloomberg Professional|2.E.2.d,,,
SKILL_SOURCE,MOODLE,O*NET,25-2022.00|Moodle|2.E.8.b,,,
SKILL_SOURCE,ARTICULATE_STORYLINE,O*NET,25-9031.00|Articulate Storyline|2.E.8.b,,,
SKILL_SOURCE,MICROSOFT_POWERPOINT,O*NET,27-4012.00|Microsoft PowerPoint|2.E.4.f,,,
SKILL_SOURCE,AXURE_RP,O*NET,15-1255.00|Axure RP|2.E.4.g,,,
SKILL_SOURCE,ADOBE_PHOTOSHOP,O*NET,19-3092.00|Adobe Photoshop|2.E.4.b,,,
SKILL_SOURCE,APACHE_JMETER,O*NET,15-1253.00|Apache JMeter|2.E.7.e,,,
SKILL_SOURCE,ADOBE_ACROBAT,O*NET,11-1011.00|Adobe Acrobat|2.E.5.b,,,
SKILL_SOURCE,MICROSOFT_AZURE,O*NET,41-4011.00|Microsoft Azure software|2.E.7.b,,,
SKILL_SOURCE,AWS,O*NET,11-1021.00|Amazon Web Services AWS software|2.E.6.f,,,
SKILL_SOURCE,WIRESHARK,O*NET,15-1241.01|Wireshark|2.E.11.a,,,
RELATION,FRONTEND_DEVELOPER|JAVASCRIPT,O*NET,15-1254.00,4.A.3.b.1,4.88,1
RELATION,FRONTEND_DEVELOPER|TYPESCRIPT,O*NET,15-1254.00,4.A.3.b.1,4.88,1
RELATION,FRONTEND_DEVELOPER|HTML,O*NET,15-1254.00,4.A.3.b.1,4.88,1
RELATION,FRONTEND_DEVELOPER|CSS,O*NET,15-1254.00,4.A.3.b.1,4.88,1
RELATION,FRONTEND_DEVELOPER|REACT,O*NET,15-1254.00,4.A.3.b.1,4.88,1
RELATION,FRONTEND_DEVELOPER|VUE_JS,O*NET,15-1254.00,4.A.3.b.1,4.88,1
RELATION,FRONTEND_DEVELOPER|NODE_JS,O*NET,15-1254.00,4.A.3.b.1,4.88,1
RELATION,FRONTEND_DEVELOPER|GRADLE,O*NET,15-1254.00,4.A.3.b.1,4.88,1
RELATION,FRONTEND_DEVELOPER|GOOGLE_CHROME,O*NET,15-1254.00,4.A.3.b.1,4.88,1
RELATION,FRONTEND_DEVELOPER|DEBUGGING_SOFTWARE,O*NET,15-1254.00,4.A.3.b.1,4.88,1
RELATION,FRONTEND_DEVELOPER|SELENIUM,O*NET,15-1254.00,4.A.2.a.3,3.33,1
RELATION,FRONTEND_DEVELOPER|JUNIT,O*NET,15-1254.00,4.A.2.a.3,3.33,1
RELATION,FRONTEND_DEVELOPER|GIT,O*NET,15-1254.00,4.A.4.a.2,3.88,1
RELATION,FRONTEND_DEVELOPER|AGILE,O*NET,15-1254.00,4.A.4.a.2,3.88,1
RELATION,BACKEND_DEVELOPER|JAVA,O*NET,15-1252.00,4.A.3.b.1,4.61,1
RELATION,BACKEND_DEVELOPER|PYTHON,O*NET,15-1252.00,4.A.3.b.1,4.61,1
RELATION,BACKEND_DEVELOPER|SPRING_BOOT,O*NET,15-1252.00,4.A.3.b.1,4.61,1
RELATION,BACKEND_DEVELOPER|DJANGO,O*NET,15-1252.00,4.A.3.b.1,4.61,1
RELATION,BACKEND_DEVELOPER|MYSQL,O*NET,15-1252.00,4.A.2.a.2,4.38,1
RELATION,BACKEND_DEVELOPER|POSTGRESQL,O*NET,15-1252.00,4.A.2.a.2,4.38,1
RELATION,BACKEND_DEVELOPER|REDIS,O*NET,15-1252.00,4.A.2.a.2,4.38,1
RELATION,BACKEND_DEVELOPER|APACHE_KAFKA,O*NET,15-1252.00,4.A.2.a.2,4.38,1
RELATION,BACKEND_DEVELOPER|DOCKER,O*NET,15-1252.00,4.A.3.b.1,4.61,1
RELATION,BACKEND_DEVELOPER|KUBERNETES,O*NET,15-1252.00,4.A.3.b.1,4.61,1
RELATION,BACKEND_DEVELOPER|JUNIT,O*NET,15-1252.00,4.A.2.a.3,3.94,1
RELATION,BACKEND_DEVELOPER|POSTMAN,O*NET,15-1252.00,4.A.2.a.3,3.94,1
RELATION,BACKEND_DEVELOPER|GIT,O*NET,15-1252.00,4.A.3.b.1,4.61,1
RELATION,BACKEND_DEVELOPER|GRADLE,O*NET,15-1252.00,4.A.3.b.1,4.61,1
RELATION,BACKEND_DEVELOPER|NMAP,O*NET,15-1252.00,2.C.8.a,2.57,1
RELATION,BACKEND_DEVELOPER|SONARQUBE,O*NET,15-1252.00,2.C.8.a,2.57,1
RELATION,QA_ENGINEER|JAVA,O*NET,15-1253.00,4.A.3.b.1,4.94,1
RELATION,QA_ENGINEER|PYTHON,O*NET,15-1253.00,4.A.3.b.1,4.94,1
RELATION,QA_ENGINEER|POSTMAN,O*NET,15-1253.00,4.A.2.a.3,4.24,1
RELATION,QA_ENGINEER|CRITICAL_THINKING,O*NET,15-1253.00,4.A.2.a.3,4.24,1
RELATION,QA_ENGINEER|SELENIUM,O*NET,15-1253.00,4.A.3.b.1,4.94,1
RELATION,QA_ENGINEER|JUNIT,O*NET,15-1253.00,4.A.3.b.1,4.94,1
RELATION,QA_ENGINEER|SQL,O*NET,15-1253.00,4.A.2.a.2,4.38,1
RELATION,QA_ENGINEER|MYSQL,O*NET,15-1253.00,4.A.2.a.2,4.38,1
RELATION,QA_ENGINEER|APACHE_JMETER,O*NET,15-1253.00,4.A.1.b.3,2.94,1
RELATION,QA_ENGINEER|BASH,O*NET,15-1253.00,4.A.1.b.3,2.94,1
RELATION,QA_ENGINEER|JENKINS,O*NET,15-1253.00,4.A.3.b.1,4.94,1
RELATION,QA_ENGINEER|GITLAB,O*NET,15-1253.00,4.A.3.b.1,4.94,1
RELATION,DEVOPS_ENGINEER|PYTHON,O*NET,15-1244.00,4.A.3.b.1,4.83,1
RELATION,DEVOPS_ENGINEER|BASH,O*NET,15-1244.00,4.A.3.b.1,4.83,1
RELATION,DEVOPS_ENGINEER|AWS,O*NET,15-1244.00,4.A.3.b.1,4.83,1
RELATION,DEVOPS_ENGINEER|MICROSOFT_AZURE,O*NET,15-1244.00,4.A.3.b.1,4.83,1
RELATION,DEVOPS_ENGINEER|DOCKER,O*NET,15-1244.00,4.A.3.b.1,4.83,1
RELATION,DEVOPS_ENGINEER|KUBERNETES,O*NET,15-1244.00,4.A.3.b.1,4.83,1
RELATION,DEVOPS_ENGINEER|JENKINS,O*NET,15-1244.00,4.A.3.b.1,4.83,1
RELATION,DEVOPS_ENGINEER|GITLAB,O*NET,15-1244.00,4.A.3.b.1,4.83,1
RELATION,DEVOPS_ENGINEER|CHEF,O*NET,15-1244.00,4.A.3.b.1,4.83,1
RELATION,DEVOPS_ENGINEER|GIT,O*NET,15-1244.00,4.A.3.b.1,4.83,1
RELATION,DEVOPS_ENGINEER|NAGIOS,O*NET,15-1244.00,4.A.1.a.2,3.60,1
RELATION,DEVOPS_ENGINEER|WIRESHARK,O*NET,15-1244.00,4.A.1.a.2,3.60,1
RELATION,DEVOPS_ENGINEER|NMAP,O*NET,15-1244.00,2.C.8.a,2.34,1
RELATION,DEVOPS_ENGINEER|SONARQUBE,O*NET,15-1244.00,2.C.8.a,2.34,1
RELATION,DEVOPS_ENGINEER|MYSQL,O*NET,15-1244.00,4.A.2.a.2,4.03,1
RELATION,DEVOPS_ENGINEER|POSTGRESQL,O*NET,15-1244.00,4.A.2.a.2,4.03,1
RELATION,FINANCIAL_DATA_ANALYST|PYTHON,O*NET,13-2031.00,4.A.3.b.1,4.48,1
RELATION,FINANCIAL_DATA_ANALYST|R,O*NET,13-2031.00,4.A.3.b.1,4.48,1
RELATION,FINANCIAL_DATA_ANALYST|PANDAS,O*NET,13-2031.00,4.A.2.a.4,4.01,1
RELATION,FINANCIAL_DATA_ANALYST|TABLEAU,O*NET,13-2031.00,4.A.2.a.4,4.01,1
RELATION,FINANCIAL_DATA_ANALYST|SQL,O*NET,13-2031.00,4.A.2.a.2,4.44,1
RELATION,FINANCIAL_DATA_ANALYST|POSTGRESQL,O*NET,13-2031.00,4.A.2.a.2,4.44,1
RELATION,FINANCIAL_DATA_ANALYST|STATISTICS,O*NET,13-2031.00,2.C.4.a,4.03,1
RELATION,FINANCIAL_DATA_ANALYST|MACHINE_LEARNING,O*NET,13-2031.00,2.C.4.a,4.03,1
RELATION,FINANCIAL_DATA_ANALYST|BLOOMBERG_PROFESSIONAL,O*NET,13-2031.00,4.A.3.b.1,4.48,1
RELATION,FINANCIAL_DATA_ANALYST|MICROSOFT_EXCEL,O*NET,13-2031.00,4.A.3.b.1,4.48,1
RELATION,FINANCIAL_DATA_ANALYST|FINANCIAL_ANALYSIS,O*NET,13-2031.00,2.C.1.c,4.42,1
RELATION,FINANCIAL_DATA_ANALYST|RISK_MANAGEMENT,O*NET,13-2031.00,2.C.1.c,4.42,1
RELATION,FINANCIAL_DATA_ANALYST|GIT,O*NET,13-2031.00,4.A.3.b.1,4.48,1
RELATION,FINANCIAL_DATA_ANALYST|POWER_BI,O*NET,13-2031.00,4.A.3.b.1,4.48,1
RELATION,RISK_MODELING_ENGINEER|PYTHON,O*NET,13-2053.00,4.A.3.b.1,4.41,1
RELATION,RISK_MODELING_ENGINEER|R,O*NET,13-2053.00,4.A.3.b.1,4.41,1
RELATION,RISK_MODELING_ENGINEER|STATISTICS,O*NET,13-2053.00,2.C.4.a,3.27,1
RELATION,RISK_MODELING_ENGINEER|MACHINE_LEARNING,O*NET,13-2053.00,2.C.4.a,3.27,1
RELATION,RISK_MODELING_ENGINEER|RISK_MANAGEMENT,O*NET,13-2053.00,4.A.2.a.4,4.43,1
RELATION,RISK_MODELING_ENGINEER|CRITICAL_THINKING,O*NET,13-2053.00,4.A.2.a.4,4.43,1
RELATION,RISK_MODELING_ENGINEER|TENSORFLOW,O*NET,13-2053.00,4.A.3.b.1,4.41,1
RELATION,RISK_MODELING_ENGINEER|SCIKIT_LEARN,O*NET,13-2053.00,4.A.3.b.1,4.41,1
RELATION,RISK_MODELING_ENGINEER|SQL,O*NET,13-2053.00,4.A.2.a.2,4.13,1
RELATION,RISK_MODELING_ENGINEER|APACHE_SPARK,O*NET,13-2053.00,4.A.2.a.2,4.13,1
RELATION,RISK_MODELING_ENGINEER|JUNIT,O*NET,13-2053.00,4.A.2.a.3,3.90,1
RELATION,RISK_MODELING_ENGINEER|SONARQUBE,O*NET,13-2053.00,4.A.2.a.3,3.90,1
RELATION,RISK_MODELING_ENGINEER|DOCKER,O*NET,13-2053.00,4.A.3.b.1,4.41,1
RELATION,RISK_MODELING_ENGINEER|GIT,O*NET,13-2053.00,4.A.3.b.1,4.41,1
RELATION,QUANTITATIVE_DEVELOPER|PYTHON,O*NET,13-2099.01,4.A.3.b.1,4.68,1
RELATION,QUANTITATIVE_DEVELOPER|CPP,O*NET,13-2099.01,4.A.3.b.1,4.68,1
RELATION,QUANTITATIVE_DEVELOPER|GO,O*NET,13-2099.01,4.A.3.b.1,4.68,1
RELATION,QUANTITATIVE_DEVELOPER|NUMPY,O*NET,13-2099.01,4.A.3.b.1,4.68,1
RELATION,QUANTITATIVE_DEVELOPER|SCIKIT_LEARN,O*NET,13-2099.01,4.A.3.b.1,4.68,1
RELATION,QUANTITATIVE_DEVELOPER|BLOOMBERG_PROFESSIONAL,O*NET,13-2099.01,2.C.1.c,4.20,1
RELATION,QUANTITATIVE_DEVELOPER|FINANCIAL_ANALYSIS,O*NET,13-2099.01,2.C.1.c,4.20,1
RELATION,QUANTITATIVE_DEVELOPER|POSTGRESQL,O*NET,13-2099.01,4.A.2.a.2,4.50,1
RELATION,QUANTITATIVE_DEVELOPER|SQL,O*NET,13-2099.01,4.A.2.a.2,4.50,1
RELATION,QUANTITATIVE_DEVELOPER|APACHE_SPARK,O*NET,13-2099.01,4.A.1.b.3,3.53,1
RELATION,QUANTITATIVE_DEVELOPER|APACHE_KAFKA,O*NET,13-2099.01,4.A.1.b.3,3.53,1
RELATION,QUANTITATIVE_DEVELOPER|JUNIT,O*NET,13-2099.01,4.A.2.a.3,3.05,1
RELATION,QUANTITATIVE_DEVELOPER|APACHE_JMETER,O*NET,13-2099.01,4.A.2.a.3,3.05,1
RELATION,QUANTITATIVE_DEVELOPER|DOCKER,O*NET,13-2099.01,4.A.3.b.1,4.68,1
RELATION,QUANTITATIVE_DEVELOPER|KUBERNETES,O*NET,13-2099.01,4.A.3.b.1,4.68,1
RELATION,EDUCATION_PRODUCT_MANAGER|INSTRUCTIONAL_DESIGN,O*NET,13-1082.00,2.C.6,2.52,1
RELATION,EDUCATION_PRODUCT_MANAGER|CURRICULUM_DEVELOPMENT,O*NET,13-1082.00,2.C.6,2.52,1
RELATION,EDUCATION_PRODUCT_MANAGER|AXURE_RP,O*NET,13-1082.00,4.A.2.a.4,3.94,1
RELATION,EDUCATION_PRODUCT_MANAGER|POWER_BI,O*NET,13-1082.00,4.A.2.a.4,3.94,1
RELATION,EDUCATION_PRODUCT_MANAGER|PROJECT_MANAGEMENT,O*NET,13-1082.00,4.A.2.b.4,3.73,1
RELATION,EDUCATION_PRODUCT_MANAGER|AGILE,O*NET,13-1082.00,4.A.2.b.4,3.73,1
RELATION,EDUCATION_PRODUCT_MANAGER|TABLEAU,O*NET,13-1082.00,4.A.2.a.4,3.94,1
RELATION,EDUCATION_PRODUCT_MANAGER|PYTHON,O*NET,13-1082.00,4.A.2.a.4,3.94,1
RELATION,EDUCATION_PRODUCT_MANAGER|LEARNING_MANAGEMENT_SYSTEMS,O*NET,13-1082.00,4.A.3.b.1,4.45,1
RELATION,EDUCATION_PRODUCT_MANAGER|MOODLE,O*NET,13-1082.00,4.A.3.b.1,4.45,1
RELATION,EDUCATION_PRODUCT_MANAGER|GIT,O*NET,13-1082.00,4.A.4.a.2,4.73,1
RELATION,EDUCATION_PRODUCT_MANAGER|MICROSOFT_EXCEL,O*NET,13-1082.00,4.A.4.a.2,4.73,1
RELATION,INSTRUCTIONAL_DESIGNER|INSTRUCTIONAL_DESIGN,O*NET,25-9031.00,2.C.6,4.78,1
RELATION,INSTRUCTIONAL_DESIGNER|CURRICULUM_DEVELOPMENT,O*NET,25-9031.00,2.C.6,4.78,1
RELATION,INSTRUCTIONAL_DESIGNER|LEARNING_MANAGEMENT_SYSTEMS,O*NET,25-9031.00,4.A.3.b.1,4.45,1
RELATION,INSTRUCTIONAL_DESIGNER|MOODLE,O*NET,25-9031.00,4.A.3.b.1,4.45,1
RELATION,INSTRUCTIONAL_DESIGNER|ARTICULATE_STORYLINE,O*NET,25-9031.00,4.A.2.b.2,3.72,1
RELATION,INSTRUCTIONAL_DESIGNER|FIGMA,O*NET,25-9031.00,4.A.2.b.2,3.72,1
RELATION,INSTRUCTIONAL_DESIGNER|EDUCATIONAL_TECHNOLOGY,O*NET,25-9031.00,4.A.2.a.3,3.83,1
RELATION,INSTRUCTIONAL_DESIGNER|CRITICAL_THINKING,O*NET,25-9031.00,4.A.2.a.3,3.83,1
RELATION,INSTRUCTIONAL_DESIGNER|STATISTICS,O*NET,25-9031.00,4.A.2.a.4,4.19,1
RELATION,INSTRUCTIONAL_DESIGNER|MICROSOFT_EXCEL,O*NET,25-9031.00,4.A.2.a.4,4.19,1
RELATION,INSTRUCTIONAL_DESIGNER|ADOBE_ACROBAT,O*NET,25-9031.00,4.A.2.a.3,3.83,1
RELATION,INSTRUCTIONAL_DESIGNER|COMMUNICATION,O*NET,25-9031.00,4.A.2.a.3,3.83,1
RELATION,EDUCATION_DATA_ANALYST|PYTHON,O*NET,15-2051.00,4.A.3.b.1,4.75,1
RELATION,EDUCATION_DATA_ANALYST|R,O*NET,15-2051.00,4.A.3.b.1,4.75,1
RELATION,EDUCATION_DATA_ANALYST|TABLEAU,O*NET,15-2051.00,4.A.2.a.4,4.83,1
RELATION,EDUCATION_DATA_ANALYST|POWER_BI,O*NET,15-2051.00,4.A.2.a.4,4.83,1
RELATION,EDUCATION_DATA_ANALYST|SQL,O*NET,15-2051.00,4.A.2.a.2,4.71,1
RELATION,EDUCATION_DATA_ANALYST|POSTGRESQL,O*NET,15-2051.00,4.A.2.a.2,4.71,1
RELATION,EDUCATION_DATA_ANALYST|STATISTICS,O*NET,15-2051.00,2.C.4.a,4.52,1
RELATION,EDUCATION_DATA_ANALYST|MACHINE_LEARNING,O*NET,15-2051.00,2.C.4.a,4.52,1
RELATION,EDUCATION_DATA_ANALYST|CURRICULUM_DEVELOPMENT,O*NET,15-2051.00,2.C.6,2.28,1
RELATION,EDUCATION_DATA_ANALYST|EDUCATIONAL_TECHNOLOGY,O*NET,15-2051.00,2.C.6,2.28,1
RELATION,EDUCATION_DATA_ANALYST|MICROSOFT_EXCEL,O*NET,15-2051.00,4.A.3.b.1,4.75,1
RELATION,EDUCATION_DATA_ANALYST|MICROSOFT_POWERPOINT,O*NET,15-2051.00,4.A.3.b.1,4.75,1
RELATION,FRONTEND_DEVELOPER|FIGMA,O*NET,,,,0
RELATION,INSTRUCTIONAL_DESIGNER|ADOBE_PHOTOSHOP,O*NET,,,,0
1 record_type entity_key expected_source_type expected_source_code expected_element_id expected_importance expected_is_proxy
2 OCCUPATION_SOURCE FRONTEND_DEVELOPER ESCO http://data.europa.eu/esco/occupation/c40a2919-48a9-40ea-b506-1f34f693496d
3 OCCUPATION_SOURCE BACKEND_DEVELOPER ESCO http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1
4 OCCUPATION_SOURCE QA_ENGINEER ESCO http://data.europa.eu/esco/occupation/106f79e4-6264-45f1-9e7a-297435cd684b
5 OCCUPATION_SOURCE DEVOPS_ENGINEER ESCO http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1
6 OCCUPATION_SOURCE FINANCIAL_DATA_ANALYST ESCO http://data.europa.eu/esco/occupation/8d586ee9-0ab1-4155-a3b0-2ca786c8e48c
7 OCCUPATION_SOURCE RISK_MODELING_ENGINEER ESCO http://data.europa.eu/esco/occupation/b09acdda-6680-49d1-a411-129610ee3bc0
8 OCCUPATION_SOURCE QUANTITATIVE_DEVELOPER ESCO http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1
9 OCCUPATION_SOURCE EDUCATION_PRODUCT_MANAGER ESCO http://data.europa.eu/esco/occupation/a79170ad-0b7b-4697-864f-1748359aa10a
10 OCCUPATION_SOURCE INSTRUCTIONAL_DESIGNER ESCO http://data.europa.eu/esco/occupation/a9c30651-ccbc-4a80-900c-7880615cdf6e
11 OCCUPATION_SOURCE EDUCATION_DATA_ANALYST ESCO http://data.europa.eu/esco/occupation/d3edb8f8-3a06-47a0-8fb9-9b212c006aa2
12 SKILL_SOURCE JAVA ESCO http://data.europa.eu/esco/skill/19a8293b-8e95-4de3-983f-77484079c389
13 SKILL_SOURCE PYTHON ESCO http://data.europa.eu/esco/skill/ccd0a1d9-afda-43d9-b901-96344886e14d
14 SKILL_SOURCE JAVASCRIPT ESCO http://data.europa.eu/esco/skill/3cd569a2-4f88-4c1e-9995-8dce8c5e51a7
15 SKILL_SOURCE TYPESCRIPT ESCO http://data.europa.eu/esco/skill/867137fb-ff1b-4ca3-99f3-cb6969aa2c68
16 SKILL_SOURCE SQL ESCO http://data.europa.eu/esco/skill/598de5b0-5b58-4ea7-8058-a4bc4d18c742
17 SKILL_SOURCE CSHARP ESCO http://data.europa.eu/esco/skill/4c016b68-4116-468c-9dc6-42710c239e4a
18 SKILL_SOURCE HTML ESCO http://data.europa.eu/esco/skill/0af062de-eb43-41e9-9b96-249e2cd22d26
19 SKILL_SOURCE CSS ESCO http://data.europa.eu/esco/skill/e5d1f825-60ed-4bdd-872a-e748c387f777
20 SKILL_SOURCE R ESCO http://data.europa.eu/esco/skill/51586df8-1c46-4b47-8583-773cb63bf00b
21 SKILL_SOURCE MYSQL ESCO http://data.europa.eu/esco/skill/4da171e5-779c-4983-a76f-91c16751e99f
22 SKILL_SOURCE POSTGRESQL ESCO http://data.europa.eu/esco/skill/a8d07b5a-c1a1-42c6-9d53-db9c7a2ca996
23 SKILL_SOURCE STATISTICS ESCO http://data.europa.eu/esco/skill/7ee4c2ea-b349-4bd2-81a3-ec31475d4833
24 SKILL_SOURCE MACHINE_LEARNING ESCO http://data.europa.eu/esco/skill/8369c2d6-c100-4cf6-bd83-9668d8678433
25 SKILL_SOURCE FINANCIAL_ANALYSIS ESCO http://data.europa.eu/esco/skill/99571e68-801f-49af-a897-5f75996642e1
26 SKILL_SOURCE RISK_MANAGEMENT ESCO http://data.europa.eu/esco/skill/6eff134b-e34f-4d6e-a6e8-5e47cf2228d0
27 SKILL_SOURCE INSTRUCTIONAL_DESIGN ESCO http://data.europa.eu/esco/skill/e3145bd7-7f35-44ec-a5dc-4417c5c9ab9a
28 SKILL_SOURCE CURRICULUM_DEVELOPMENT ESCO http://data.europa.eu/esco/skill/142a5319-60cb-42a2-891f-90ba92af18b1
29 SKILL_SOURCE LEARNING_MANAGEMENT_SYSTEMS ESCO http://data.europa.eu/esco/skill/6c80d53c-d8c9-41fe-998f-091fca208834
30 SKILL_SOURCE EDUCATIONAL_TECHNOLOGY ESCO http://data.europa.eu/esco/skill/8a6394d1-f44f-4b0d-81d5-7201fc025ba8
31 SKILL_SOURCE COMMUNICATION ESCO http://data.europa.eu/esco/skill/15d76317-c71a-4fa2-aadc-2ecc34e627b7
32 SKILL_SOURCE CRITICAL_THINKING ESCO http://data.europa.eu/esco/skill/a9e6718b-b9d8-44c2-b06e-85ceed9d144c
33 SKILL_SOURCE COLLABORATION ESCO http://data.europa.eu/esco/skill/082967d4-b94c-4305-aa13-35a6aa8c5c7c
34 SKILL_SOURCE PROJECT_MANAGEMENT ESCO http://data.europa.eu/esco/skill/7111b95d-0ce3-441a-9d92-4c75d05c4388
35 SKILL_SOURCE AGILE ESCO http://data.europa.eu/esco/skill/dba46f87-0831-49cd-a1c7-340a653c0221
36 SKILL_SOURCE JENKINS ESCO http://data.europa.eu/esco/skill/f47a1998-0beb-43be-9f46-380aa4d183da
37 SKILL_SOURCE REACT O*NET 15-1254.00|React|2.E.7.h
38 SKILL_SOURCE VUE_JS O*NET 15-1254.00|Vue.js|2.E.7.h
39 SKILL_SOURCE NODE_JS O*NET 15-1254.00|Node.js|2.E.7.h
40 SKILL_SOURCE SPRING_BOOT O*NET 15-1252.00|Spring Boot|2.E.10.a
41 SKILL_SOURCE DJANGO O*NET 15-1252.00|Django|2.E.7.h
42 SKILL_SOURCE FLASK O*NET 15-2051.00|Flask|2.E.7.b
43 SKILL_SOURCE GIT O*NET 15-1252.00|Git|2.E.5.c
44 SKILL_SOURCE DOCKER O*NET 15-1252.00|Docker|2.E.10.a
45 SKILL_SOURCE KUBERNETES O*NET 15-1252.00|Kubernetes|2.E.10.a
46 SKILL_SOURCE GRADLE O*NET 15-1252.00|Gradle|2.E.7.e
47 SKILL_SOURCE MONGODB O*NET 15-1252.00|MongoDB|2.E.6.d
48 SKILL_SOURCE REDIS O*NET 15-1252.00|Redis|2.E.6.d
49 SKILL_SOURCE SELENIUM O*NET 15-1253.00|Selenium|2.E.7.f
50 SKILL_SOURCE JUNIT O*NET 15-1253.00|JUnit|2.E.7.f
51 SKILL_SOURCE POSTMAN O*NET 15-1253.00|Postman|2.E.13.c
52 SKILL_SOURCE PANDAS O*NET 15-2051.00|pandas|2.E.6.f
53 SKILL_SOURCE NUMPY O*NET 15-2051.00|NumPy|2.E.6.f
54 SKILL_SOURCE TABLEAU O*NET 15-2051.00|Tableau|2.E.6.m
55 SKILL_SOURCE POWER_BI O*NET 15-2051.00|Microsoft Power BI|2.E.6.m
56 SKILL_SOURCE MICROSOFT_EXCEL O*NET 15-2051.00|Microsoft Excel|2.E.4.i
57 SKILL_SOURCE TENSORFLOW O*NET 15-2051.00|TensorFlow|2.E.9.d
58 SKILL_SOURCE PYTORCH O*NET 15-2051.00|PyTorch|2.E.6.f
59 SKILL_SOURCE SCIKIT_LEARN O*NET 15-2051.00|Scikit-learn|2.E.7.b
60 SKILL_SOURCE APACHE_SPARK O*NET 15-2051.00|Apache Spark|2.E.6.m
61 SKILL_SOURCE FIGMA O*NET 15-1254.00|Figma|2.E.7.d
62 SKILL_SOURCE BASH O*NET 15-1252.00|Bash|2.E.13.c
63 SKILL_SOURCE GO O*NET 15-1252.00|Go|2.E.7.b
64 SKILL_SOURCE CPP O*NET 15-1252.00|C++|2.E.7.e
65 SKILL_SOURCE GOOGLE_CHROME O*NET 17-2051.02|Google Chrome|2.E.10.e
66 SKILL_SOURCE DEBUGGING_SOFTWARE O*NET 17-2071.00|Debugging software|2.E.7.f
67 SKILL_SOURCE APACHE_KAFKA O*NET 15-1299.07|Apache Kafka|2.E.7.b
68 SKILL_SOURCE SONARQUBE O*NET 15-1252.00|SonarQube|2.E.7.f
69 SKILL_SOURCE CHEF O*NET 15-1255.00|Chef|2.E.7.a
70 SKILL_SOURCE GITLAB O*NET 15-1299.08|GitLab|2.E.10.a
71 SKILL_SOURCE NAGIOS O*NET 11-3021.00|Nagios|2.E.11.a
72 SKILL_SOURCE NMAP O*NET 13-1199.07|Nmap|2.E.14.d
73 SKILL_SOURCE BLOOMBERG_PROFESSIONAL O*NET 41-3031.00|Bloomberg Professional|2.E.2.d
74 SKILL_SOURCE MOODLE O*NET 25-2022.00|Moodle|2.E.8.b
75 SKILL_SOURCE ARTICULATE_STORYLINE O*NET 25-9031.00|Articulate Storyline|2.E.8.b
76 SKILL_SOURCE MICROSOFT_POWERPOINT O*NET 27-4012.00|Microsoft PowerPoint|2.E.4.f
77 SKILL_SOURCE AXURE_RP O*NET 15-1255.00|Axure RP|2.E.4.g
78 SKILL_SOURCE ADOBE_PHOTOSHOP O*NET 19-3092.00|Adobe Photoshop|2.E.4.b
79 SKILL_SOURCE APACHE_JMETER O*NET 15-1253.00|Apache JMeter|2.E.7.e
80 SKILL_SOURCE ADOBE_ACROBAT O*NET 11-1011.00|Adobe Acrobat|2.E.5.b
81 SKILL_SOURCE MICROSOFT_AZURE O*NET 41-4011.00|Microsoft Azure software|2.E.7.b
82 SKILL_SOURCE AWS O*NET 11-1021.00|Amazon Web Services AWS software|2.E.6.f
83 SKILL_SOURCE WIRESHARK O*NET 15-1241.01|Wireshark|2.E.11.a
84 RELATION FRONTEND_DEVELOPER|JAVASCRIPT O*NET 15-1254.00 4.A.3.b.1 4.88 1
85 RELATION FRONTEND_DEVELOPER|TYPESCRIPT O*NET 15-1254.00 4.A.3.b.1 4.88 1
86 RELATION FRONTEND_DEVELOPER|HTML O*NET 15-1254.00 4.A.3.b.1 4.88 1
87 RELATION FRONTEND_DEVELOPER|CSS O*NET 15-1254.00 4.A.3.b.1 4.88 1
88 RELATION FRONTEND_DEVELOPER|REACT O*NET 15-1254.00 4.A.3.b.1 4.88 1
89 RELATION FRONTEND_DEVELOPER|VUE_JS O*NET 15-1254.00 4.A.3.b.1 4.88 1
90 RELATION FRONTEND_DEVELOPER|NODE_JS O*NET 15-1254.00 4.A.3.b.1 4.88 1
91 RELATION FRONTEND_DEVELOPER|GRADLE O*NET 15-1254.00 4.A.3.b.1 4.88 1
92 RELATION FRONTEND_DEVELOPER|GOOGLE_CHROME O*NET 15-1254.00 4.A.3.b.1 4.88 1
93 RELATION FRONTEND_DEVELOPER|DEBUGGING_SOFTWARE O*NET 15-1254.00 4.A.3.b.1 4.88 1
94 RELATION FRONTEND_DEVELOPER|SELENIUM O*NET 15-1254.00 4.A.2.a.3 3.33 1
95 RELATION FRONTEND_DEVELOPER|JUNIT O*NET 15-1254.00 4.A.2.a.3 3.33 1
96 RELATION FRONTEND_DEVELOPER|GIT O*NET 15-1254.00 4.A.4.a.2 3.88 1
97 RELATION FRONTEND_DEVELOPER|AGILE O*NET 15-1254.00 4.A.4.a.2 3.88 1
98 RELATION BACKEND_DEVELOPER|JAVA O*NET 15-1252.00 4.A.3.b.1 4.61 1
99 RELATION BACKEND_DEVELOPER|PYTHON O*NET 15-1252.00 4.A.3.b.1 4.61 1
100 RELATION BACKEND_DEVELOPER|SPRING_BOOT O*NET 15-1252.00 4.A.3.b.1 4.61 1
101 RELATION BACKEND_DEVELOPER|DJANGO O*NET 15-1252.00 4.A.3.b.1 4.61 1
102 RELATION BACKEND_DEVELOPER|MYSQL O*NET 15-1252.00 4.A.2.a.2 4.38 1
103 RELATION BACKEND_DEVELOPER|POSTGRESQL O*NET 15-1252.00 4.A.2.a.2 4.38 1
104 RELATION BACKEND_DEVELOPER|REDIS O*NET 15-1252.00 4.A.2.a.2 4.38 1
105 RELATION BACKEND_DEVELOPER|APACHE_KAFKA O*NET 15-1252.00 4.A.2.a.2 4.38 1
106 RELATION BACKEND_DEVELOPER|DOCKER O*NET 15-1252.00 4.A.3.b.1 4.61 1
107 RELATION BACKEND_DEVELOPER|KUBERNETES O*NET 15-1252.00 4.A.3.b.1 4.61 1
108 RELATION BACKEND_DEVELOPER|JUNIT O*NET 15-1252.00 4.A.2.a.3 3.94 1
109 RELATION BACKEND_DEVELOPER|POSTMAN O*NET 15-1252.00 4.A.2.a.3 3.94 1
110 RELATION BACKEND_DEVELOPER|GIT O*NET 15-1252.00 4.A.3.b.1 4.61 1
111 RELATION BACKEND_DEVELOPER|GRADLE O*NET 15-1252.00 4.A.3.b.1 4.61 1
112 RELATION BACKEND_DEVELOPER|NMAP O*NET 15-1252.00 2.C.8.a 2.57 1
113 RELATION BACKEND_DEVELOPER|SONARQUBE O*NET 15-1252.00 2.C.8.a 2.57 1
114 RELATION QA_ENGINEER|JAVA O*NET 15-1253.00 4.A.3.b.1 4.94 1
115 RELATION QA_ENGINEER|PYTHON O*NET 15-1253.00 4.A.3.b.1 4.94 1
116 RELATION QA_ENGINEER|POSTMAN O*NET 15-1253.00 4.A.2.a.3 4.24 1
117 RELATION QA_ENGINEER|CRITICAL_THINKING O*NET 15-1253.00 4.A.2.a.3 4.24 1
118 RELATION QA_ENGINEER|SELENIUM O*NET 15-1253.00 4.A.3.b.1 4.94 1
119 RELATION QA_ENGINEER|JUNIT O*NET 15-1253.00 4.A.3.b.1 4.94 1
120 RELATION QA_ENGINEER|SQL O*NET 15-1253.00 4.A.2.a.2 4.38 1
121 RELATION QA_ENGINEER|MYSQL O*NET 15-1253.00 4.A.2.a.2 4.38 1
122 RELATION QA_ENGINEER|APACHE_JMETER O*NET 15-1253.00 4.A.1.b.3 2.94 1
123 RELATION QA_ENGINEER|BASH O*NET 15-1253.00 4.A.1.b.3 2.94 1
124 RELATION QA_ENGINEER|JENKINS O*NET 15-1253.00 4.A.3.b.1 4.94 1
125 RELATION QA_ENGINEER|GITLAB O*NET 15-1253.00 4.A.3.b.1 4.94 1
126 RELATION DEVOPS_ENGINEER|PYTHON O*NET 15-1244.00 4.A.3.b.1 4.83 1
127 RELATION DEVOPS_ENGINEER|BASH O*NET 15-1244.00 4.A.3.b.1 4.83 1
128 RELATION DEVOPS_ENGINEER|AWS O*NET 15-1244.00 4.A.3.b.1 4.83 1
129 RELATION DEVOPS_ENGINEER|MICROSOFT_AZURE O*NET 15-1244.00 4.A.3.b.1 4.83 1
130 RELATION DEVOPS_ENGINEER|DOCKER O*NET 15-1244.00 4.A.3.b.1 4.83 1
131 RELATION DEVOPS_ENGINEER|KUBERNETES O*NET 15-1244.00 4.A.3.b.1 4.83 1
132 RELATION DEVOPS_ENGINEER|JENKINS O*NET 15-1244.00 4.A.3.b.1 4.83 1
133 RELATION DEVOPS_ENGINEER|GITLAB O*NET 15-1244.00 4.A.3.b.1 4.83 1
134 RELATION DEVOPS_ENGINEER|CHEF O*NET 15-1244.00 4.A.3.b.1 4.83 1
135 RELATION DEVOPS_ENGINEER|GIT O*NET 15-1244.00 4.A.3.b.1 4.83 1
136 RELATION DEVOPS_ENGINEER|NAGIOS O*NET 15-1244.00 4.A.1.a.2 3.60 1
137 RELATION DEVOPS_ENGINEER|WIRESHARK O*NET 15-1244.00 4.A.1.a.2 3.60 1
138 RELATION DEVOPS_ENGINEER|NMAP O*NET 15-1244.00 2.C.8.a 2.34 1
139 RELATION DEVOPS_ENGINEER|SONARQUBE O*NET 15-1244.00 2.C.8.a 2.34 1
140 RELATION DEVOPS_ENGINEER|MYSQL O*NET 15-1244.00 4.A.2.a.2 4.03 1
141 RELATION DEVOPS_ENGINEER|POSTGRESQL O*NET 15-1244.00 4.A.2.a.2 4.03 1
142 RELATION FINANCIAL_DATA_ANALYST|PYTHON O*NET 13-2031.00 4.A.3.b.1 4.48 1
143 RELATION FINANCIAL_DATA_ANALYST|R O*NET 13-2031.00 4.A.3.b.1 4.48 1
144 RELATION FINANCIAL_DATA_ANALYST|PANDAS O*NET 13-2031.00 4.A.2.a.4 4.01 1
145 RELATION FINANCIAL_DATA_ANALYST|TABLEAU O*NET 13-2031.00 4.A.2.a.4 4.01 1
146 RELATION FINANCIAL_DATA_ANALYST|SQL O*NET 13-2031.00 4.A.2.a.2 4.44 1
147 RELATION FINANCIAL_DATA_ANALYST|POSTGRESQL O*NET 13-2031.00 4.A.2.a.2 4.44 1
148 RELATION FINANCIAL_DATA_ANALYST|STATISTICS O*NET 13-2031.00 2.C.4.a 4.03 1
149 RELATION FINANCIAL_DATA_ANALYST|MACHINE_LEARNING O*NET 13-2031.00 2.C.4.a 4.03 1
150 RELATION FINANCIAL_DATA_ANALYST|BLOOMBERG_PROFESSIONAL O*NET 13-2031.00 4.A.3.b.1 4.48 1
151 RELATION FINANCIAL_DATA_ANALYST|MICROSOFT_EXCEL O*NET 13-2031.00 4.A.3.b.1 4.48 1
152 RELATION FINANCIAL_DATA_ANALYST|FINANCIAL_ANALYSIS O*NET 13-2031.00 2.C.1.c 4.42 1
153 RELATION FINANCIAL_DATA_ANALYST|RISK_MANAGEMENT O*NET 13-2031.00 2.C.1.c 4.42 1
154 RELATION FINANCIAL_DATA_ANALYST|GIT O*NET 13-2031.00 4.A.3.b.1 4.48 1
155 RELATION FINANCIAL_DATA_ANALYST|POWER_BI O*NET 13-2031.00 4.A.3.b.1 4.48 1
156 RELATION RISK_MODELING_ENGINEER|PYTHON O*NET 13-2053.00 4.A.3.b.1 4.41 1
157 RELATION RISK_MODELING_ENGINEER|R O*NET 13-2053.00 4.A.3.b.1 4.41 1
158 RELATION RISK_MODELING_ENGINEER|STATISTICS O*NET 13-2053.00 2.C.4.a 3.27 1
159 RELATION RISK_MODELING_ENGINEER|MACHINE_LEARNING O*NET 13-2053.00 2.C.4.a 3.27 1
160 RELATION RISK_MODELING_ENGINEER|RISK_MANAGEMENT O*NET 13-2053.00 4.A.2.a.4 4.43 1
161 RELATION RISK_MODELING_ENGINEER|CRITICAL_THINKING O*NET 13-2053.00 4.A.2.a.4 4.43 1
162 RELATION RISK_MODELING_ENGINEER|TENSORFLOW O*NET 13-2053.00 4.A.3.b.1 4.41 1
163 RELATION RISK_MODELING_ENGINEER|SCIKIT_LEARN O*NET 13-2053.00 4.A.3.b.1 4.41 1
164 RELATION RISK_MODELING_ENGINEER|SQL O*NET 13-2053.00 4.A.2.a.2 4.13 1
165 RELATION RISK_MODELING_ENGINEER|APACHE_SPARK O*NET 13-2053.00 4.A.2.a.2 4.13 1
166 RELATION RISK_MODELING_ENGINEER|JUNIT O*NET 13-2053.00 4.A.2.a.3 3.90 1
167 RELATION RISK_MODELING_ENGINEER|SONARQUBE O*NET 13-2053.00 4.A.2.a.3 3.90 1
168 RELATION RISK_MODELING_ENGINEER|DOCKER O*NET 13-2053.00 4.A.3.b.1 4.41 1
169 RELATION RISK_MODELING_ENGINEER|GIT O*NET 13-2053.00 4.A.3.b.1 4.41 1
170 RELATION QUANTITATIVE_DEVELOPER|PYTHON O*NET 13-2099.01 4.A.3.b.1 4.68 1
171 RELATION QUANTITATIVE_DEVELOPER|CPP O*NET 13-2099.01 4.A.3.b.1 4.68 1
172 RELATION QUANTITATIVE_DEVELOPER|GO O*NET 13-2099.01 4.A.3.b.1 4.68 1
173 RELATION QUANTITATIVE_DEVELOPER|NUMPY O*NET 13-2099.01 4.A.3.b.1 4.68 1
174 RELATION QUANTITATIVE_DEVELOPER|SCIKIT_LEARN O*NET 13-2099.01 4.A.3.b.1 4.68 1
175 RELATION QUANTITATIVE_DEVELOPER|BLOOMBERG_PROFESSIONAL O*NET 13-2099.01 2.C.1.c 4.20 1
176 RELATION QUANTITATIVE_DEVELOPER|FINANCIAL_ANALYSIS O*NET 13-2099.01 2.C.1.c 4.20 1
177 RELATION QUANTITATIVE_DEVELOPER|POSTGRESQL O*NET 13-2099.01 4.A.2.a.2 4.50 1
178 RELATION QUANTITATIVE_DEVELOPER|SQL O*NET 13-2099.01 4.A.2.a.2 4.50 1
179 RELATION QUANTITATIVE_DEVELOPER|APACHE_SPARK O*NET 13-2099.01 4.A.1.b.3 3.53 1
180 RELATION QUANTITATIVE_DEVELOPER|APACHE_KAFKA O*NET 13-2099.01 4.A.1.b.3 3.53 1
181 RELATION QUANTITATIVE_DEVELOPER|JUNIT O*NET 13-2099.01 4.A.2.a.3 3.05 1
182 RELATION QUANTITATIVE_DEVELOPER|APACHE_JMETER O*NET 13-2099.01 4.A.2.a.3 3.05 1
183 RELATION QUANTITATIVE_DEVELOPER|DOCKER O*NET 13-2099.01 4.A.3.b.1 4.68 1
184 RELATION QUANTITATIVE_DEVELOPER|KUBERNETES O*NET 13-2099.01 4.A.3.b.1 4.68 1
185 RELATION EDUCATION_PRODUCT_MANAGER|INSTRUCTIONAL_DESIGN O*NET 13-1082.00 2.C.6 2.52 1
186 RELATION EDUCATION_PRODUCT_MANAGER|CURRICULUM_DEVELOPMENT O*NET 13-1082.00 2.C.6 2.52 1
187 RELATION EDUCATION_PRODUCT_MANAGER|AXURE_RP O*NET 13-1082.00 4.A.2.a.4 3.94 1
188 RELATION EDUCATION_PRODUCT_MANAGER|POWER_BI O*NET 13-1082.00 4.A.2.a.4 3.94 1
189 RELATION EDUCATION_PRODUCT_MANAGER|PROJECT_MANAGEMENT O*NET 13-1082.00 4.A.2.b.4 3.73 1
190 RELATION EDUCATION_PRODUCT_MANAGER|AGILE O*NET 13-1082.00 4.A.2.b.4 3.73 1
191 RELATION EDUCATION_PRODUCT_MANAGER|TABLEAU O*NET 13-1082.00 4.A.2.a.4 3.94 1
192 RELATION EDUCATION_PRODUCT_MANAGER|PYTHON O*NET 13-1082.00 4.A.2.a.4 3.94 1
193 RELATION EDUCATION_PRODUCT_MANAGER|LEARNING_MANAGEMENT_SYSTEMS O*NET 13-1082.00 4.A.3.b.1 4.45 1
194 RELATION EDUCATION_PRODUCT_MANAGER|MOODLE O*NET 13-1082.00 4.A.3.b.1 4.45 1
195 RELATION EDUCATION_PRODUCT_MANAGER|GIT O*NET 13-1082.00 4.A.4.a.2 4.73 1
196 RELATION EDUCATION_PRODUCT_MANAGER|MICROSOFT_EXCEL O*NET 13-1082.00 4.A.4.a.2 4.73 1
197 RELATION INSTRUCTIONAL_DESIGNER|INSTRUCTIONAL_DESIGN O*NET 25-9031.00 2.C.6 4.78 1
198 RELATION INSTRUCTIONAL_DESIGNER|CURRICULUM_DEVELOPMENT O*NET 25-9031.00 2.C.6 4.78 1
199 RELATION INSTRUCTIONAL_DESIGNER|LEARNING_MANAGEMENT_SYSTEMS O*NET 25-9031.00 4.A.3.b.1 4.45 1
200 RELATION INSTRUCTIONAL_DESIGNER|MOODLE O*NET 25-9031.00 4.A.3.b.1 4.45 1
201 RELATION INSTRUCTIONAL_DESIGNER|ARTICULATE_STORYLINE O*NET 25-9031.00 4.A.2.b.2 3.72 1
202 RELATION INSTRUCTIONAL_DESIGNER|FIGMA O*NET 25-9031.00 4.A.2.b.2 3.72 1
203 RELATION INSTRUCTIONAL_DESIGNER|EDUCATIONAL_TECHNOLOGY O*NET 25-9031.00 4.A.2.a.3 3.83 1
204 RELATION INSTRUCTIONAL_DESIGNER|CRITICAL_THINKING O*NET 25-9031.00 4.A.2.a.3 3.83 1
205 RELATION INSTRUCTIONAL_DESIGNER|STATISTICS O*NET 25-9031.00 4.A.2.a.4 4.19 1
206 RELATION INSTRUCTIONAL_DESIGNER|MICROSOFT_EXCEL O*NET 25-9031.00 4.A.2.a.4 4.19 1
207 RELATION INSTRUCTIONAL_DESIGNER|ADOBE_ACROBAT O*NET 25-9031.00 4.A.2.a.3 3.83 1
208 RELATION INSTRUCTIONAL_DESIGNER|COMMUNICATION O*NET 25-9031.00 4.A.2.a.3 3.83 1
209 RELATION EDUCATION_DATA_ANALYST|PYTHON O*NET 15-2051.00 4.A.3.b.1 4.75 1
210 RELATION EDUCATION_DATA_ANALYST|R O*NET 15-2051.00 4.A.3.b.1 4.75 1
211 RELATION EDUCATION_DATA_ANALYST|TABLEAU O*NET 15-2051.00 4.A.2.a.4 4.83 1
212 RELATION EDUCATION_DATA_ANALYST|POWER_BI O*NET 15-2051.00 4.A.2.a.4 4.83 1
213 RELATION EDUCATION_DATA_ANALYST|SQL O*NET 15-2051.00 4.A.2.a.2 4.71 1
214 RELATION EDUCATION_DATA_ANALYST|POSTGRESQL O*NET 15-2051.00 4.A.2.a.2 4.71 1
215 RELATION EDUCATION_DATA_ANALYST|STATISTICS O*NET 15-2051.00 2.C.4.a 4.52 1
216 RELATION EDUCATION_DATA_ANALYST|MACHINE_LEARNING O*NET 15-2051.00 2.C.4.a 4.52 1
217 RELATION EDUCATION_DATA_ANALYST|CURRICULUM_DEVELOPMENT O*NET 15-2051.00 2.C.6 2.28 1
218 RELATION EDUCATION_DATA_ANALYST|EDUCATIONAL_TECHNOLOGY O*NET 15-2051.00 2.C.6 2.28 1
219 RELATION EDUCATION_DATA_ANALYST|MICROSOFT_EXCEL O*NET 15-2051.00 4.A.3.b.1 4.75 1
220 RELATION EDUCATION_DATA_ANALYST|MICROSOFT_POWERPOINT O*NET 15-2051.00 4.A.3.b.1 4.75 1
221 RELATION FRONTEND_DEVELOPER|FIGMA O*NET 0
222 RELATION INSTRUCTIONAL_DESIGNER|ADOBE_PHOTOSHOP O*NET 0

88
specs/learning-path/spec.md

@ -0,0 +1,88 @@
# learning path Specification
## Purpose
定义基于缺失 TOP10 技能的确定性学习路径生成行为。
## Requirements
### Requirement: Learning path consumes unmatched TOP10 skills
学习路径接口 MUST 复用个人技能匹配结果,并只包含当前 TOP10 中未命中的技能;不得包含已命中技能或 TOP10 之外的技能。
#### Scenario: Student has skill gaps
- **WHEN** 当前 TOP10 中部分技能未命中
- **THEN** 学习路径 MUST 包含全部未命中技能且不含已命中技能
#### Scenario: Student has no skill gaps
- **WHEN** 当前 TOP10 全部命中
- **THEN** 学习路径 MUST 返回空列表和总数 0
#### Scenario: Student has no matching skill evidence
- **WHEN** 学生没有技能数据
- **THEN** 学习路径 MUST 包含当前 TOP10 的全部技能
### Requirement: Learning priority follows heat order
学习路径项 MUST 按与 TOP10 相同的热度顺序排列,并从 1 开始连续编号;空热度技能继续遵守词云的末位排序规则。
#### Scenario: Multiple missing skills are ordered
- **WHEN** 缺失技能包含多个热度不同的技能
- **THEN** 热度最高的技能 MUST 获得优先级 1,后续技能依次编号
#### Scenario: Missing skills have equal heat
- **WHEN** 缺失技能热度相同
- **THEN** 优先级 MUST 沿用词云的稳定次级排序,重复请求结果一致
#### Scenario: Only one skill is missing
- **WHEN** 当前 TOP10 只有一项未命中
- **THEN** 该技能 MUST 返回优先级 1
### Requirement: Learning suggestions are deterministic and industry-neutral
每项学习建议 MUST 由后端按技能分类的通用模板生成,不调用 AI、知识库问答或外部课程服务;建议不得包含课程链接、讲师、价格、学习时长或完成度。
#### Scenario: Programming language gap is returned
- **WHEN** 缺失技能属于编程语言分类
- **THEN** 建议 MUST 描述基础语法学习和编码练习方向
#### Scenario: Framework gap is returned
- **WHEN** 缺失技能属于框架分类
- **THEN** 建议 MUST 描述核心概念学习和小型项目实践方向
#### Scenario: Tool gap is returned
- **WHEN** 缺失技能属于工具分类
- **THEN** 建议 MUST 描述安装配置和工程实践方向
#### Scenario: Unknown category is returned
- **WHEN** 缺失技能无法匹配特定模板分类
- **THEN** 系统 MUST 返回一条行业无关的通用学习建议
### Requirement: Learning path response is actionable and minimal
每个学习路径项 MUST 返回技能 ID、技能名称、技能分类、优先级和简化学习建议;响应 MUST 不包含个人隐私、简历原文或模型推理内容。
#### Scenario: A learning item is generated
- **WHEN** 存在未命中技能
- **THEN** 每个路径项 MUST 同时具备技能身份、分类、连续优先级和建议文本
#### Scenario: Repeated request is made
- **WHEN** 学生资料、词库数据和筛选条件均未变化
- **THEN** 接口 MUST 返回相同顺序、相同优先级和相同建议
#### Scenario: Response contains no private profile data
- **WHEN** 学习路径成功返回
- **THEN** 响应 MUST 不含手机号、邮箱、简历文件、原始 `skills_json` 或其他无关个人数据

102
specs/skill-cloud/spec.md

@ -0,0 +1,102 @@
# skill cloud Specification
## Purpose
定义行业技能词云查询、筛选、热度排序与来源追溯行为。
## Requirements
### Requirement: STUDENT-only skill cloud access
系统 MUST 只允许已登录 STUDENT 调用技能词云接口,并从 JWT 解析当前用户,不接受客户端传入学生身份参数。
#### Scenario: Anonymous request is rejected
- **WHEN** 未携带有效 JWT 请求 `GET /api/v1/skills/word-cloud`
- **THEN** 系统 MUST 返回 401,且不得返回任何词库数据
#### Scenario: Company or administrator request is rejected
- **WHEN** COMPANY 或 ADMIN 用户携带有效 JWT 请求技能词云
- **THEN** 系统 MUST 返回 403,且不得返回任何词库数据
#### Scenario: Student request is accepted
- **WHEN** STUDENT 用户携带有效 JWT 请求技能词云
- **THEN** 系统 MUST 按筛选条件返回词库数据
### Requirement: Skill cloud filter validation
接口 MUST 要求 `industryId` 和 `occupationId` 为必填正整数,`categoryId` 为可选正整数;行业与职位必须存在且互为关联,技能分类存在时必须属于该职位。
#### Scenario: Required filters are missing
- **WHEN** 请求未传 `industryId` 或 `occupationId`
- **THEN** 系统 MUST 返回 400,并说明缺失的筛选参数
#### Scenario: Occupation does not belong to industry
- **WHEN** 请求传入存在但不属于目标行业的职位
- **THEN** 系统 MUST 返回 400,且不得返回技能数据
#### Scenario: Category does not belong to occupation
- **WHEN** 请求传入存在但不属于目标职位的技能分类
- **THEN** 系统 MUST 返回 400,且不得返回技能数据
#### Scenario: Category is omitted
- **WHEN** 请求只传行业和职位
- **THEN** 系统 MUST 返回该职位下所有技能分类的技能
### Requirement: Complete ranked skill cloud
系统 MUST 返回当前筛选范围内全部技能,并按 `heat` 降序排列;`heat` 为空时排在非空值之后。同 `heat` 或同为空值时,系统 MUST 使用稳定的中文技能名称和技能 ID 作为次级排序条件。
#### Scenario: Skills are ordered by heat
- **WHEN** 筛选结果存在多个带 O*NET Importance 的技能
- **THEN** 返回列表 MUST 按数值从高到低排列
#### Scenario: Skills without heat are retained
- **WHEN** 筛选结果包含无 O*NET Importance 的 ESCO 技能
- **THEN** 这些技能 MUST 保留在列表中,`heat` 为 null,并排在所有非 null 热度技能之后
#### Scenario: Equal heat has deterministic order
- **WHEN** 两个技能具有相同 `heat`
- **THEN** 系统 MUST 按稳定次级规则返回一致顺序,重复请求不得随机变化
#### Scenario: Empty scope returns empty list
- **WHEN** 合法筛选范围内没有技能
- **THEN** 系统 MUST 返回成功响应、空技能列表和总数 0
### Requirement: TOP10 is derived from the skill cloud
系统 MUST NOT 为 TOP10 维护独立排序或独立数据源;前端必须能够直接使用词云返回列表的前十条得到 TOP10。
#### Scenario: Scope has at least ten skills
- **WHEN** 词云结果包含至少十条技能
- **THEN** 前十条 MUST 与同一筛选条件下的热门技能 TOP10 一致
#### Scenario: Scope has fewer than ten skills
- **WHEN** 词云结果少于十条技能
- **THEN** 全部结果 MUST 构成实际 TOP10 集合
### Requirement: Skill cloud response exposes source-linked metadata
每个技能项 MUST 至少返回技能 ID、中文名称、技能分类 ID、技能分类名称、`heat` 和稳定排名。技能仍可通过 ID 追溯至 ESCO/O*NET 来源。
#### Scenario: Skill item is returned
- **WHEN** 查询返回任一技能
- **THEN** 响应 MUST 包含该技能的身份、分类、热度和排名字段
#### Scenario: Response is wrapped consistently
- **WHEN** 请求成功
- **THEN** 响应 MUST 使用项目统一 `ApiResponse` 结构,并在 `data` 中返回筛选条件和技能列表

102
specs/skill-match/spec.md

@ -0,0 +1,102 @@
# skill match Specification
## Purpose
定义个人技能与当前热门 TOP10 的确定性匹配行为。
## Requirements
### Requirement: Personal matching uses the current ranked TOP10
个人技能匹配接口 MUST 复用技能词云相同的筛选参数、数据范围和热度排序;匹配集合 MUST 是当前筛选结果的前 `min(10, 实际技能数)` 项。
#### Scenario: Filtered result has at least ten skills
- **WHEN** 当前筛选返回十条或更多技能
- **THEN** 匹配集合 MUST 恰好包含热度最高的十条技能
#### Scenario: Filtered result has fewer than ten skills
- **WHEN** 当前筛选返回六条技能
- **THEN** 匹配集合 MUST 包含全部六条技能,分母 MUST 为 6
#### Scenario: Filtered result is empty
- **WHEN** 当前合法筛选范围内没有技能
- **THEN** 匹配度 MUST 返回 0,命中和缺失列表 MUST 均为空
### Requirement: Student skills are read from the authenticated profile
系统 MUST 从当前登录 STUDENT 的 `student_profiles.skills_json` 读取技能;不得接受客户端另行提交的技能列表作为个人匹配依据。
#### Scenario: Student profile has valid skills
- **WHEN** 当前学生存在合法技能数组
- **THEN** 系统 MUST 使用该数组计算匹配结果
#### Scenario: Student profile is absent or has no skills
- **WHEN** 当前学生没有资料记录、技能字段为空或技能数组为空
- **THEN** 匹配度 MUST 返回 0,命中列表为空,缺失列表为当前 TOP10
#### Scenario: Skills JSON is malformed
- **WHEN** 当前学生技能字段无法解析为字符串数组
- **THEN** 系统 MUST 按无技能处理,不得导致 500 响应或泄露原始字段内容
### Requirement: Skill matching normalizes aliases
系统 MUST 使用技能标准和别名进行匹配,归一化至少覆盖首尾空白、大小写、连续空格和常见标点差异;每个标准技能命中一次即可。
#### Scenario: Alias matches canonical skill
- **WHEN** 学生技能为 `SpringBoot`,TOP10 标准技能为 `Spring Boot` 且存在对应别名
- **THEN** 系统 MUST 将该技能计为命中
#### Scenario: Case and spacing differ
- **WHEN** 学生技能与标准技能的字母大小写或空格数量不同
- **THEN** 系统 MUST 在归一化后判断为同一技能
#### Scenario: Duplicate student skills exist
- **WHEN** 学生技能数组包含多个指向同一标准技能的别名
- **THEN** 系统 MUST 只计一次命中
### Requirement: Match score uses the actual TOP10 size
匹配度 MUST 使用公式 `round(命中数 × 100 / min(10, 实际技能数))` 计算,并返回 0 到 100 的整数。实际技能数为 0 时,匹配度 MUST 为 0。
#### Scenario: All skills match
- **WHEN** 当前 TOP10 有 8 项,学生命中全部 8 项
- **THEN** 匹配度 MUST 返回 100
#### Scenario: Partial match is rounded
- **WHEN** 当前 TOP10 有 3 项,学生命中 1 项
- **THEN** 匹配度 MUST 返回 33
#### Scenario: No skill matches
- **WHEN** 当前 TOP10 非空且学生没有命中任何技能
- **THEN** 匹配度 MUST 返回 0
### Requirement: Match response separates matched and missing skills
响应 MUST 返回匹配度、命中数量、实际匹配集合大小、命中技能列表和缺失技能列表;缺失技能 MUST 保持 TOP10 的热度排序。
#### Scenario: Some skills are matched
- **WHEN** 学生命中 TOP10 的部分技能
- **THEN** 命中列表和缺失列表 MUST 分别返回对应技能,且并集等于 TOP10
#### Scenario: All skills are matched
- **WHEN** 学生命中 TOP10 的全部技能
- **THEN** 缺失技能列表 MUST 为空,匹配度 MUST 为 100
#### Scenario: No skills are matched
- **WHEN** 学生没有命中 TOP10 的任何技能
- **THEN** 命中技能列表 MUST 为空,缺失列表 MUST 等于 TOP10
Loading…
Cancel
Save