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
+ );
+ }
+}
From 7873a42dc8b013eea12aa2225d34b836e86cb6e1 Mon Sep 17 00:00:00 2001
From: pwklppg <123456@.com>
Date: Tue, 15 Sep 2026 11:17:13 +0800
Subject: [PATCH 16/21] test: add skill cloud end-to-end regression
---
.../SkillCloudEndToEndIntegrationTest.java | 451 ++++++++++++++++++
1 file changed, 451 insertions(+)
create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudEndToEndIntegrationTest.java
new file mode 100644
index 0000000..3399e5b
--- /dev/null
+++ b/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.
+ *
+ * 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.
+ */
+@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 redisTemplate;
+
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ void setUpRedisMock() {
+ ValueOperations 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 countsBefore = taxonomyAndProfileCounts();
+
+ JsonNode cloud = data(getOkBody(
+ WORD_CLOUD_PATH, BACKEND_INDUSTRY_ID, BACKEND_OCCUPATION_ID, null, student.authorization(), null));
+ List allSkills = nodes(cloud.path("items"));
+ assertThat(cloud.path("total").asInt()).isEqualTo(16);
+ assertThat(allSkills).hasSize(16);
+ assertRanksAreContinuous(allSkills);
+ assertHeatOrder(allSkills);
+
+ List 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 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 expectedTop10) {
+ List expectedIds = ids(expectedTop10);
+ List matchedIds = ids(match.path("matchedSkills"));
+ List 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 expectedMatched = expectedIds.stream().filter(matchedIds::contains).toList();
+ List expectedMissing = expectedIds.stream().filter(id -> !matchedIds.contains(id)).toList();
+ assertThat(matchedIds).containsExactlyElementsOf(expectedMatched);
+ assertThat(missingIds).containsExactlyElementsOf(expectedMissing);
+
+ List 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 items) {
+ for (int index = 0; index < items.size(); index++) {
+ assertThat(items.get(index).path("rank").asInt()).isEqualTo(index + 1);
+ }
+ }
+
+ private void assertHeatOrder(List 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 nodes(JsonNode array) {
+ List nodes = new ArrayList<>();
+ array.forEach(nodes::add);
+ return nodes;
+ }
+
+ private List ids(JsonNode array) {
+ return ids(nodes(array));
+ }
+
+ private List ids(List 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 taxonomyAndProfileCounts() {
+ Map 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;
+ }
+ }
+}
From cd9220b506181d8747d4bd57936e3fbd268117e0 Mon Sep 17 00:00:00 2001
From: pwklppg <123456@.com>
Date: Tue, 15 Sep 2026 11:21:57 +0800
Subject: [PATCH 17/21] docs: publish skill cloud api contract
---
docs/frontend-integration.md | 19 +
docs/openapi-rc.json | 698 +++++++++++++++++++++++++++++++++++
docs/skill-cloud-api.md | 275 ++++++++++++++
docs/verify_openapi.py | 39 +-
4 files changed, 1030 insertions(+), 1 deletion(-)
create mode 100644 docs/skill-cloud-api.md
diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md
index 599cdf3..d4b397e 100644
--- a/docs/frontend-integration.md
+++ b/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`**。
- **只读**:`GET`,不写任何业务表;`size` 上限 50,聚合查询次数与单页条数无关(无 N+1)。
- 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 文本、学习时长或进度记录。
diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json
index 24c8fc5..0180660 100644
--- a/docs/openapi-rc.json
+++ b/docs/openapi-rc.json
@@ -6296,6 +6296,455 @@
"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": "未登录或令牌无效",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse"
+ },
+ "example": {
+ "code": 2001,
+ "message": "unauthorized",
+ "data": null,
+ "requestId": "req-2001",
+ "timestamp": "2026-09-15T10:03:00Z"
+ }
+ }
+ }
+ },
+ "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": "getMatch",
+ "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": "未登录或令牌无效",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse"
+ },
+ "example": {
+ "code": 2001,
+ "message": "unauthorized",
+ "data": null,
+ "requestId": "req-2001",
+ "timestamp": "2026-09-15T10:03:00Z"
+ }
+ }
+ }
+ },
+ "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": "未登录或令牌无效",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponse"
+ },
+ "example": {
+ "code": 2001,
+ "message": "unauthorized",
+ "data": null,
+ "requestId": "req-2001",
+ "timestamp": "2026-09-15T10:03:00Z"
+ }
+ }
+ }
+ },
+ "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": {
@@ -9679,6 +10128,255 @@
"requestId": { "type": "string" },
"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"
+ }
+ }
}
},
"securitySchemes": {
diff --git a/docs/skill-cloud-api.md b/docs/skill-cloud-api.md
new file mode 100644
index 0000000..6c1ba80
--- /dev/null
+++ b/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 `,身份只从 JWT/SecurityContext 获取,不接受 `userId`、`studentId` 等身份注入参数。
+- 使用统一 `ApiResponse` 包装结构。
+- 只读,不写入学习进度、学生资料或词库数据。
+
+查询参数:
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `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
+```
+
+`categoryId` 可省略:
+
+```http
+GET /api/v1/skills/word-cloud?industryId=1&occupationId=2
+```
+
+成功响应为 `ApiResponse`。`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
+```
+
+匹配集合严格取当前筛选结果的 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`:
+
+```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
+```
+
+学习路径只消费个人匹配结果中的 `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. 错误响应示例
+
+所有错误也使用统一 `ApiResponse`:
+
+```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 或令牌无效/过期 | `2001` |
+| `403` | 已登录但不是 STUDENT | `2002` |
+
+示例 401:
+
+```json
+{
+ "code": 2001,
+ "message": "unauthorized",
+ "data": null,
+ "requestId": "req-401",
+ "timestamp": "2026-09-15T10:04:00Z"
+}
+```
+
+示例 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`。
diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py
index 7470085..c4e8318 100644
--- a/docs/verify_openapi.py
+++ b/docs/verify_openapi.py
@@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
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']
required = [
@@ -66,6 +66,9 @@ required = [
'/admin/reports',
'/admin/reports/{id}',
'/admin/reports/{id}/decision',
+ '/skills/word-cloud',
+ '/skills/match',
+ '/skills/learning-path',
]
for path in required:
assert path in paths, f"Missing path: {path}"
@@ -135,6 +138,40 @@ assert 'get' in paths['/admin/reports'], "Missing GET /admin/reports"
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"
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}"
+assert paths['/skills/word-cloud']['get'].get('operationId') == 'getWordCloud'
+assert paths['/skills/match']['get'].get('operationId') == 'getMatch'
+assert paths['/skills/learning-path']['get'].get('operationId') == 'getLearningPath'
+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'
+
+schemas = d['components']['schemas']
+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(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")
From 411def40c31af7cea23aa17cf0654681e5029c66 Mon Sep 17 00:00:00 2001
From: pwklppg <123456@.com>
Date: Tue, 15 Sep 2026 11:46:01 +0800
Subject: [PATCH 18/21] fix: align skill cloud openapi contracts
---
docs/openapi-rc.json | 68 +++++++++++++------
docs/skill-cloud-api.md | 16 ++---
docs/verify_openapi.py | 35 +++++++++-
.../skill/SkillCloudController.java | 3 +
...killCloudAuthorizationIntegrationTest.java | 24 +++++++
.../skill/SkillCloudControllerTest.java | 47 ++++++++++++-
6 files changed, 157 insertions(+), 36 deletions(-)
diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json
index 0180660..2c8a229 100644
--- a/docs/openapi-rc.json
+++ b/docs/openapi-rc.json
@@ -6414,18 +6414,16 @@
}
},
"401": {
- "description": "未登录或令牌无效",
+ "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ApiResponse"
+ "$ref": "#/components/schemas/UnauthorizedResponse"
},
"example": {
- "code": 2001,
- "message": "unauthorized",
- "data": null,
- "requestId": "req-2001",
- "timestamp": "2026-09-15T10:03:00Z"
+ "code": 401,
+ "message": "not authenticated",
+ "timestamp": ""
}
}
}
@@ -6457,7 +6455,7 @@
],
"summary": "查询个人技能匹配度",
"description": "使用 JWT 对应学生的 skills_json,与当前筛选结果 TOP10 比对;matchScore=round(matchedCount*100/min(10,total))。仅 STUDENT 可访问。",
- "operationId": "getMatch",
+ "operationId": "getSkillMatch",
"security": [
{
"bearerAuth": []
@@ -6575,18 +6573,16 @@
}
},
"401": {
- "description": "未登录或令牌无效",
+ "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ApiResponse"
+ "$ref": "#/components/schemas/UnauthorizedResponse"
},
"example": {
- "code": 2001,
- "message": "unauthorized",
- "data": null,
- "requestId": "req-2001",
- "timestamp": "2026-09-15T10:03:00Z"
+ "code": 401,
+ "message": "not authenticated",
+ "timestamp": ""
}
}
}
@@ -6710,18 +6706,16 @@
}
},
"401": {
- "description": "未登录或令牌无效",
+ "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ApiResponse"
+ "$ref": "#/components/schemas/UnauthorizedResponse"
},
"example": {
- "code": 2001,
- "message": "unauthorized",
- "data": null,
- "requestId": "req-2001",
- "timestamp": "2026-09-15T10:03:00Z"
+ "code": 401,
+ "message": "not authenticated",
+ "timestamp": ""
}
}
}
@@ -10377,6 +10371,36 @@
"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": {
diff --git a/docs/skill-cloud-api.md b/docs/skill-cloud-api.md
index 6c1ba80..476de9d 100644
--- a/docs/skill-cloud-api.md
+++ b/docs/skill-cloud-api.md
@@ -11,7 +11,7 @@
- 仅允许 `STUDENT` 访问。
- 必须携带 `Authorization: Bearer `,身份只从 JWT/SecurityContext 获取,不接受 `userId`、`studentId` 等身份注入参数。
-- 使用统一 `ApiResponse` 包装结构。
+- 成功、`400` 和 `403` 响应使用统一 `ApiResponse`;匿名或无效 JWT 的 `401` 由全局 Security 入口直接返回裸 JSON,不包含 `data` 和 `requestId`。
- 只读,不写入学习进度、学生资料或词库数据。
查询参数:
@@ -212,7 +212,7 @@ Authorization: Bearer
## 6. 错误响应示例
-所有错误也使用统一 `ApiResponse`:
+`400` 和 `403` 使用统一 `ApiResponse`;`401` 由全局 Security 入口返回裸 JSON。以下示例与运行时契约一致。
```json
{
@@ -227,21 +227,21 @@ Authorization: Bearer
| HTTP | 场景 | 典型 `code` |
|---|---|---|
| `400` | `industryId`/`occupationId` 缺失、非正整数,或节点关系非法 | `1001` |
-| `401` | 未携带有效 JWT 或令牌无效/过期 | `2001` |
+| `401` | 未携带有效 JWT 或令牌无效/过期 | `401` |
| `403` | 已登录但不是 STUDENT | `2002` |
示例 401:
```json
{
- "code": 2001,
- "message": "unauthorized",
- "data": null,
- "requestId": "req-401",
- "timestamp": "2026-09-15T10:04:00Z"
+ "code": 401,
+ "message": "not authenticated",
+ "timestamp": ""
}
```
+`401` 响应不包含 `data` 和 `requestId`。
+
示例 403:
```json
diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py
index c4e8318..6f3b5ca 100644
--- a/docs/verify_openapi.py
+++ b/docs/verify_openapi.py
@@ -9,6 +9,7 @@ print('paths:', len(d['paths']))
assert len(d['paths']) == 129, f"Expected 129 paths, got {len(d['paths'])}"
paths = d['paths']
+
required = [
'/review-weight-templates',
'/profile/me/review-weights',
@@ -142,14 +143,42 @@ 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}"
-assert paths['/skills/word-cloud']['get'].get('operationId') == 'getWordCloud'
-assert paths['/skills/match']['get'].get('operationId') == 'getMatch'
-assert paths['/skills/learning-path']['get'].get('operationId') == 'getLearningPath'
+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'},
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java
index 9f5ad94..01aa843 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java
@@ -41,6 +41,7 @@ public class SkillCloudController {
private final ProfileService profileService;
@Operation(
+ operationId = "getWordCloud",
summary = "查询行业技能词云",
description = "返回当前筛选范围内的全部技能,按热度降序排列;前端取前十条作为 TOP10。仅 STUDENT 可访问。"
)
@@ -88,6 +89,7 @@ public class SkillCloudController {
}
@Operation(
+ operationId = "getSkillMatch",
summary = "查询个人技能匹配度",
description = "使用当前 STUDENT 的个人技能,与筛选结果热度 TOP10 比对并返回命中、缺失和匹配度。仅 STUDENT 可访问。"
)
@@ -135,6 +137,7 @@ public class SkillCloudController {
}
@Operation(
+ operationId = "getLearningPath",
summary = "生成个人学习路径",
description = "仅针对个人 TOP10 中未命中的技能,按热度顺序返回优先级和通用学习建议。仅 STUDENT 可访问。"
)
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java
index d9ef3a4..1678bf7 100644
--- a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java
@@ -108,6 +108,14 @@ class SkillCloudAuthorizationIntegrationTest {
}
}
+ @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));
@@ -129,6 +137,22 @@ class SkillCloudAuthorizationIntegrationTest {
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")
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java
index bd67449..032189b 100644
--- a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java
@@ -27,8 +27,11 @@ 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;
@@ -155,13 +158,21 @@ class SkillCloudControllerTest {
@ParameterizedTest
@ValueSource(strings = {WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH})
- void anonymousRequestIsUnauthorized(String path) throws Exception {
- mockMvc.perform(get(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("$.data").doesNotExist());
+ .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"));
}
@@ -238,6 +249,36 @@ class SkillCloudControllerTest {
.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> 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 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"))
From c0f938c7df8b801e00d6f8f697b97b14f154a627 Mon Sep 17 00:00:00 2001
From: pwklppg <123456@.com>
Date: Tue, 15 Sep 2026 11:46:28 +0800
Subject: [PATCH 19/21] docs: record W3 reviews and verification blocker
---
changes/n03-skill-cloud/.spec-superflow.yaml | 5 +-
.../.superpowers/sdd/execution-plan.yaml | 9 +-
.../.superpowers/sdd/progress.md | 6 +
.../sdd/reviews/W3-T8-rereview.md | 105 ++++++++++
.../.superpowers/sdd/reviews/W3-T8.md | 87 ++++++++
.../.superpowers/sdd/reviews/W3-T9.md | 177 ++++++++++++++++
.../sdd/reviews/W3-wave-final-review.md | 168 +++++++++++++++
.../sdd/reviews/W3-wave-rereview.md | 178 ++++++++++++++++
.../.superpowers/sdd/reviews/W3-wave.md | 198 ++++++++++++++++++
9 files changed, 929 insertions(+), 4 deletions(-)
create mode 100644 changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T8-rereview.md
create mode 100644 changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T8.md
create mode 100644 changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T9.md
create mode 100644 changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave-final-review.md
create mode 100644 changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave-rereview.md
create mode 100644 changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave.md
diff --git a/changes/n03-skill-cloud/.spec-superflow.yaml b/changes/n03-skill-cloud/.spec-superflow.yaml
index c52748b..0ef0613 100644
--- a/changes/n03-skill-cloud/.spec-superflow.yaml
+++ b/changes/n03-skill-cloud/.spec-superflow.yaml
@@ -27,10 +27,13 @@ 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: 2
+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: true
+verification_blocker: full mvn test has 2 failures + 9 errors in unrelated CareerProgress/JobReview/LocalResume tests
+closure_requires: user-approved waiver or authorized repair
paused_at: 2026-09-14T09:44:45Z
resumed_at: 2026-09-15T01:18:50Z
resume_count: 1
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/execution-plan.yaml b/changes/n03-skill-cloud/.superpowers/sdd/execution-plan.yaml
index cf332c5..50dfe1d 100644
--- a/changes/n03-skill-cloud/.superpowers/sdd/execution-plan.yaml
+++ b/changes/n03-skill-cloud/.superpowers/sdd/execution-plan.yaml
@@ -4,7 +4,7 @@ contract_hash: 819762ae94f2cb3d11820904d013947c367e4bbb32fe8773a3510a253be9ca97
created_at: 2026-09-14T08:00:29Z
paused: false
resumed_at: 2026-09-15T01:18:50Z
-current: W3
+current: verification
waves:
- id: W1
strategy: serial
@@ -22,5 +22,8 @@ waves:
strategy: serial
tasks: [T8, T9, T10]
depends_on: [W2]
- state: eligible
- receipt: null
\ No newline at end of file
+ state: passed_with_external_blocker
+ receipt: pass (W3 final review; 73 targeted tests, OpenAPI 129 paths)
+verification_blocked: true
+verification_blocker: full mvn test has 2 failures + 9 errors in unrelated CareerProgress/JobReview/LocalResume tests
+closure_requires: user-approved waiver or authorized repair of unrelated failures
\ No newline at end of file
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/progress.md b/changes/n03-skill-cloud/.superpowers/sdd/progress.md
index 678d8cf..0804835 100644
--- a/changes/n03-skill-cloud/.superpowers/sdd/progress.md
+++ b/changes/n03-skill-cloud/.superpowers/sdd/progress.md
@@ -21,3 +21,9 @@
- 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.
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T8-rereview.md b/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T8-rereview.md
new file mode 100644
index 0000000..00a4409
--- /dev/null
+++ b/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。
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T8.md b/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T8.md
new file mode 100644
index 0000000..1df8931
--- /dev/null
+++ b/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 阻断项,因为它与仓库既有安全配置约定一致。
\ No newline at end of file
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T9.md b/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-T9.md
new file mode 100644
index 0000000..c9bea9a
--- /dev/null
+++ b/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/发布级验证门禁中完成。
+- 本次评审未修改任何实现或测试源码。
\ No newline at end of file
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave-final-review.md b/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave-final-review.md
new file mode 100644
index 0000000..7b7c20f
--- /dev/null
+++ b/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 范围内的文件,避免混入无关变更。
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave-rereview.md b/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave-rereview.md
new file mode 100644
index 0000000..9f72513
--- /dev/null
+++ b/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 漂移。
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave.md b/changes/n03-skill-cloud/.superpowers/sdd/reviews/W3-wave.md
new file mode 100644
index 0000000..f00cc0d
--- /dev/null
+++ b/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>`,并保留 `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` 还需全绿或形成正式豁免记录。
From e4a1482d6431a11057c990e49049bdc9381fa3a6 Mon Sep 17 00:00:00 2001
From: pwklppg <123456@.com>
Date: Tue, 15 Sep 2026 14:20:09 +0800
Subject: [PATCH 20/21] fix: normalize skill proxy boolean column
---
.../.superpowers/sdd/progress.md | 3 +
.../sdd/reviews/runtime-startup-fix-review.md | 151 ++++++++++++++++++
.../sdd/runtime-startup-failure.txt | 14 ++
.../skill/domain/OccupationSkill.java | 4 -
.../V37__skill_cloud_boolean_type.sql | 12 ++
.../skill/SkillCloudQueryServiceTest.java | 9 +-
.../SkillCloudSeedDataIntegrationTest.java | 16 +-
.../skill/SkillTaxonomyMigrationTest.java | 33 +++-
.../skill/SkillTaxonomyRepositoryTest.java | 26 ++-
9 files changed, 253 insertions(+), 15 deletions(-)
create mode 100644 changes/n03-skill-cloud/.superpowers/sdd/reviews/runtime-startup-fix-review.md
create mode 100644 changes/n03-skill-cloud/.superpowers/sdd/runtime-startup-failure.txt
create mode 100644 mydreamjob-server/src/main/resources/db/migration/V37__skill_cloud_boolean_type.sql
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/progress.md b/changes/n03-skill-cloud/.superpowers/sdd/progress.md
index 0804835..266f2cd 100644
--- a/changes/n03-skill-cloud/.superpowers/sdd/progress.md
+++ b/changes/n03-skill-cloud/.superpowers/sdd/progress.md
@@ -27,3 +27,6 @@
- 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.
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/reviews/runtime-startup-fix-review.md b/changes/n03-skill-cloud/.superpowers/sdd/reviews/runtime-startup-fix-review.md
new file mode 100644
index 0000000..46b17fa
--- /dev/null
+++ b/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;需要修复这些既有测试,或由项目按流程正式记录豁免后,才能推进全量回归结论和最终关闭。
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/runtime-startup-failure.txt b/changes/n03-skill-cloud/.superpowers/sdd/runtime-startup-failure.txt
new file mode 100644
index 0000000..321ef80
--- /dev/null
+++ b/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.
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/OccupationSkill.java b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/OccupationSkill.java
index 9b77bba..38046c6 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/OccupationSkill.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/skill/domain/OccupationSkill.java
@@ -11,8 +11,6 @@ import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
-import org.hibernate.annotations.JdbcTypeCode;
-import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -47,8 +45,6 @@ public class OccupationSkill {
@Column(name = "importance_element_id", length = 30)
private String importanceElementId;
-
- @JdbcTypeCode(SqlTypes.TINYINT)
@Column(name = "importance_is_proxy", nullable = false)
private Boolean importanceIsProxy;
diff --git a/mydreamjob-server/src/main/resources/db/migration/V37__skill_cloud_boolean_type.sql b/mydreamjob-server/src/main/resources/db/migration/V37__skill_cloud_boolean_type.sql
new file mode 100644
index 0000000..6fdc95d
--- /dev/null
+++ b/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));
\ No newline at end of file
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java
index daa3253..c207304 100644
--- a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudQueryServiceTest.java
@@ -221,7 +221,7 @@ class SkillCloudQueryServiceTest {
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(((Number) row.get("importance_is_proxy")).intValue() == 1);
+ assertThat(react.heatProxy()).isEqualTo(toBoolean(row.get("importance_is_proxy")));
SkillCloudResponse languages = service.query(1L, 1L, 101L);
SkillCloudItem javascript = languages.items().stream()
@@ -256,6 +256,13 @@ class SkillCloudQueryServiceTest {
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();
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudSeedDataIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudSeedDataIntegrationTest.java
index 59aaab9..4df5e59 100644
--- a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudSeedDataIntegrationTest.java
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudSeedDataIntegrationTest.java
@@ -202,7 +202,7 @@ class SkillCloudSeedDataIntegrationTest {
"OR importance_element_id IS NULL OR TRIM(importance_element_id) = '')"));
assertEquals(0L, count(
"SELECT COUNT(*) FROM occupation_skills " +
- "WHERE importance_is_proxy IS NULL OR importance_is_proxy NOT IN (0, 1)"));
+ "WHERE importance_is_proxy IS NULL OR importance_is_proxy NOT IN (FALSE, TRUE)"));
assertEquals(0L, count(
"SELECT COUNT(*) FROM occupation_skills os " +
"JOIN skill_source_versions sv ON sv.id = os.source_version_id " +
@@ -226,7 +226,7 @@ class SkillCloudSeedDataIntegrationTest {
String sourceCode = (String) row.get("importance_source_code");
String elementId = (String) row.get("importance_element_id");
BigDecimal importance = (BigDecimal) row.get("importance");
- int proxy = ((Number) row.get("importance_is_proxy")).intValue();
+ int proxy = toInteger(row.get("importance_is_proxy"));
assertTrue(proxy == 0 || proxy == 1,
"Invalid importance_is_proxy: relationId=" + relationId
@@ -581,7 +581,13 @@ class SkillCloudSeedDataIntegrationTest {
}
private Integer toInteger(Object value) {
- return value == null ? null : ((Number) value).intValue();
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof Boolean booleanValue) {
+ return booleanValue ? 1 : 0;
+ }
+ return ((Number) value).intValue();
}
private void assertSameMappingKeys(
@@ -979,7 +985,7 @@ class SkillCloudSeedDataIntegrationTest {
assertEquals(new BigDecimal(expectedValue), ((BigDecimal) row.get("importance")).setScale(2));
assertEquals(sourceCode, row.get("importance_source_code"));
assertEquals(elementId, row.get("importance_element_id"));
- assertEquals(1, ((Number) row.get("importance_is_proxy")).intValue());
+ assertEquals(1, toInteger(row.get("importance_is_proxy")));
}
private void assertNullImportance(String occupationCode, String skillCode) {
@@ -994,7 +1000,7 @@ class SkillCloudSeedDataIntegrationTest {
assertNull(row.get("importance"));
assertNull(row.get("importance_source_code"));
assertNull(row.get("importance_element_id"));
- assertEquals(0, ((Number) row.get("importance_is_proxy")).intValue());
+ assertEquals(0, toInteger(row.get("importance_is_proxy")));
}
private boolean hasSkillInIndustry(String industryCode, String skillCode) {
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java
index 2bb97e1..70b4242 100644
--- a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyMigrationTest.java
@@ -20,6 +20,7 @@ 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;
@@ -95,6 +96,13 @@ class SkillTaxonomyMigrationTest {
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
@@ -148,14 +156,17 @@ class SkillTaxonomyMigrationTest {
assertNull(joined.get("importance"));
assertNull(joined.get("importance_source_code"));
assertNull(joined.get("importance_element_id"));
- assertEquals(0, ((Number) joined.get("importance_is_proxy")).intValue());
+ 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", 1, occupationSkillId);
+ new BigDecimal("4.25"), "15-1252.00", "4.A.3.b.1", Boolean.TRUE, occupationSkillId);
Map weighted = jdbcTemplate.queryForMap(
"SELECT importance, importance_source_code, importance_element_id, importance_is_proxy " +
"FROM occupation_skills WHERE id = ?",
@@ -163,7 +174,10 @@ class SkillTaxonomyMigrationTest {
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(1, ((Number) weighted.get("importance_is_proxy")).intValue());
+ 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 (?, ?, ?, ?)",
@@ -202,7 +216,8 @@ class SkillTaxonomyMigrationTest {
assertConstraintFailure(
"UPDATE occupation_skills SET importance_is_proxy = ? WHERE id = ?",
- 2, occupationSkillId); assertTrue(jdbcTemplate.queryForObject(
+ (Object) null, occupationSkillId);
+ assertTrue(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM skill_aliases WHERE skill_id = ? AND normalized_alias = ?",
Integer.class, skillId, "java se") == 1);
}
@@ -234,6 +249,16 @@ class SkillTaxonomyMigrationTest {
}
}
+ private void assertBooleanColumnMetadata(String table, String column) {
+ Map 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();
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyRepositoryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyRepositoryTest.java
index 322abba..40f4103 100644
--- a/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyRepositoryTest.java
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillTaxonomyRepositoryTest.java
@@ -1,5 +1,6 @@
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;
@@ -10,6 +11,7 @@ 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;
@@ -19,8 +21,11 @@ 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;
@@ -66,6 +71,9 @@ class SkillTaxonomyRepositoryTest {
@Autowired
private JdbcTemplate jdbcTemplate;
+ @Autowired
+ private EntityManagerFactory entityManagerFactory;
+
@MockBean
private RedisTemplate redisTemplate;
@@ -131,7 +139,7 @@ class SkillTaxonomyRepositoryTest {
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
1L, 1L, 201L, new BigDecimal("4.61"),
- "15-1252.00", "4.A.3.b.1", 1, "ESSENTIAL", 2L);
+ "15-1252.00", "4.A.3.b.1", Boolean.TRUE, "ESSENTIAL", 2L);
List occupationOneSkills =
occupationSkillRepository.findSkillProjectionsByOccupationId(1L);
@@ -211,6 +219,22 @@ class SkillTaxonomyRepositoryTest {
.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.
From 67d83863f1d608e770ce5b56480ea4b8693e2fb5 Mon Sep 17 00:00:00 2001
From: pwklppg <123456@.com>
Date: Tue, 15 Sep 2026 16:40:14 +0800
Subject: [PATCH 21/21] docs: publish skill cloud specs and release audit
---
changes/n03-skill-cloud/.spec-superflow.yaml | 8 +-
.../.superpowers/sdd/execution-plan.yaml | 6 +-
.../n03-skill-cloud/decision-point-audit.md | 55 ++++++++++
specs/learning-path/spec.md | 88 +++++++++++++++
specs/skill-cloud/spec.md | 102 ++++++++++++++++++
specs/skill-match/spec.md | 102 ++++++++++++++++++
6 files changed, 355 insertions(+), 6 deletions(-)
create mode 100644 changes/n03-skill-cloud/decision-point-audit.md
create mode 100644 specs/learning-path/spec.md
create mode 100644 specs/skill-cloud/spec.md
create mode 100644 specs/skill-match/spec.md
diff --git a/changes/n03-skill-cloud/.spec-superflow.yaml b/changes/n03-skill-cloud/.spec-superflow.yaml
index 0ef0613..37096d7 100644
--- a/changes/n03-skill-cloud/.spec-superflow.yaml
+++ b/changes/n03-skill-cloud/.spec-superflow.yaml
@@ -31,9 +31,11 @@ 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: true
-verification_blocker: full mvn test has 2 failures + 9 errors in unrelated CareerProgress/JobReview/LocalResume tests
-closure_requires: user-approved waiver or authorized repair
+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
diff --git a/changes/n03-skill-cloud/.superpowers/sdd/execution-plan.yaml b/changes/n03-skill-cloud/.superpowers/sdd/execution-plan.yaml
index 50dfe1d..0495356 100644
--- a/changes/n03-skill-cloud/.superpowers/sdd/execution-plan.yaml
+++ b/changes/n03-skill-cloud/.superpowers/sdd/execution-plan.yaml
@@ -24,6 +24,6 @@ waves:
depends_on: [W2]
state: passed_with_external_blocker
receipt: pass (W3 final review; 73 targeted tests, OpenAPI 129 paths)
-verification_blocked: true
-verification_blocker: full mvn test has 2 failures + 9 errors in unrelated CareerProgress/JobReview/LocalResume tests
-closure_requires: user-approved waiver or authorized repair of unrelated failures
\ No newline at end of file
+verification_blocked: false
+verification_outcome: conditional-pass-with-formal-waiver
+closure_requires: DP-7 confirmation pending
\ No newline at end of file
diff --git a/changes/n03-skill-cloud/decision-point-audit.md b/changes/n03-skill-cloud/decision-point-audit.md
new file mode 100644
index 0000000..ebc7f93
--- /dev/null
+++ b/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 不可用;物理归档/合并需在获得用户确认后手工执行,且必须排除仓库中已有的无关未提交文件。
\ No newline at end of file
diff --git a/specs/learning-path/spec.md b/specs/learning-path/spec.md
new file mode 100644
index 0000000..613d2ca
--- /dev/null
+++ b/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` 或其他无关个人数据
diff --git a/specs/skill-cloud/spec.md b/specs/skill-cloud/spec.md
new file mode 100644
index 0000000..5201287
--- /dev/null
+++ b/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` 中返回筛选条件和技能列表
diff --git a/specs/skill-match/spec.md b/specs/skill-match/spec.md
new file mode 100644
index 0000000..823208d
--- /dev/null
+++ b/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