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"))