Browse Source

fix: align skill cloud openapi contracts

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
411def40c3
  1. 68
      docs/openapi-rc.json
  2. 16
      docs/skill-cloud-api.md
  3. 35
      docs/verify_openapi.py
  4. 3
      mydreamjob-server/src/main/java/com/mydreamjob/skill/SkillCloudController.java
  5. 24
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudAuthorizationIntegrationTest.java
  6. 47
      mydreamjob-server/src/test/java/com/mydreamjob/skill/SkillCloudControllerTest.java

68
docs/openapi-rc.json

@ -6414,18 +6414,16 @@
} }
}, },
"401": { "401": {
"description": "未登录或令牌无效", "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/ApiResponse" "$ref": "#/components/schemas/UnauthorizedResponse"
}, },
"example": { "example": {
"code": 2001, "code": 401,
"message": "unauthorized", "message": "not authenticated",
"data": null, "timestamp": ""
"requestId": "req-2001",
"timestamp": "2026-09-15T10:03:00Z"
} }
} }
} }
@ -6457,7 +6455,7 @@
], ],
"summary": "查询个人技能匹配度", "summary": "查询个人技能匹配度",
"description": "使用 JWT 对应学生的 skills_json,与当前筛选结果 TOP10 比对;matchScore=round(matchedCount*100/min(10,total))。仅 STUDENT 可访问。", "description": "使用 JWT 对应学生的 skills_json,与当前筛选结果 TOP10 比对;matchScore=round(matchedCount*100/min(10,total))。仅 STUDENT 可访问。",
"operationId": "getMatch", "operationId": "getSkillMatch",
"security": [ "security": [
{ {
"bearerAuth": [] "bearerAuth": []
@ -6575,18 +6573,16 @@
} }
}, },
"401": { "401": {
"description": "未登录或令牌无效", "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/ApiResponse" "$ref": "#/components/schemas/UnauthorizedResponse"
}, },
"example": { "example": {
"code": 2001, "code": 401,
"message": "unauthorized", "message": "not authenticated",
"data": null, "timestamp": ""
"requestId": "req-2001",
"timestamp": "2026-09-15T10:03:00Z"
} }
} }
} }
@ -6710,18 +6706,16 @@
} }
}, },
"401": { "401": {
"description": "未登录或令牌无效", "description": "未登录或令牌无效,由全局 Security 入口返回裸 JSON",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/ApiResponse" "$ref": "#/components/schemas/UnauthorizedResponse"
}, },
"example": { "example": {
"code": 2001, "code": 401,
"message": "unauthorized", "message": "not authenticated",
"data": null, "timestamp": ""
"requestId": "req-2001",
"timestamp": "2026-09-15T10:03:00Z"
} }
} }
} }
@ -10377,6 +10371,36 @@
"format": "date-time" "format": "date-time"
} }
} }
},
"UnauthorizedResponse": {
"type": "object",
"additionalProperties": false,
"required": [
"code",
"message",
"timestamp"
],
"properties": {
"code": {
"type": "integer",
"format": "int32",
"enum": [
401
]
},
"message": {
"type": "string",
"enum": [
"not authenticated"
]
},
"timestamp": {
"type": "string",
"enum": [
""
]
}
}
} }
}, },
"securitySchemes": { "securitySchemes": {

16
docs/skill-cloud-api.md

@ -11,7 +11,7 @@
- 仅允许 `STUDENT` 访问。 - 仅允许 `STUDENT` 访问。
- 必须携带 `Authorization: Bearer <accessToken>`,身份只从 JWT/SecurityContext 获取,不接受 `userId`、`studentId` 等身份注入参数。 - 必须携带 `Authorization: Bearer <accessToken>`,身份只从 JWT/SecurityContext 获取,不接受 `userId`、`studentId` 等身份注入参数。
- 使用统一 `ApiResponse` 包装结构。 - 成功、`400` 和 `403` 响应使用统一 `ApiResponse`;匿名或无效 JWT 的 `401` 由全局 Security 入口直接返回裸 JSON,不包含 `data` 和 `requestId`。
- 只读,不写入学习进度、学生资料或词库数据。 - 只读,不写入学习进度、学生资料或词库数据。
查询参数: 查询参数:
@ -212,7 +212,7 @@ Authorization: Bearer <accessToken>
## 6. 错误响应示例 ## 6. 错误响应示例
所有错误也使用统一 `ApiResponse`: `400` 和 `403` 使用统一 `ApiResponse`;`401` 由全局 Security 入口返回裸 JSON。以下示例与运行时契约一致。
```json ```json
{ {
@ -227,21 +227,21 @@ Authorization: Bearer <accessToken>
| HTTP | 场景 | 典型 `code` | | HTTP | 场景 | 典型 `code` |
|---|---|---| |---|---|---|
| `400` | `industryId`/`occupationId` 缺失、非正整数,或节点关系非法 | `1001` | | `400` | `industryId`/`occupationId` 缺失、非正整数,或节点关系非法 | `1001` |
| `401` | 未携带有效 JWT 或令牌无效/过期 | `2001` | | `401` | 未携带有效 JWT 或令牌无效/过期 | `401` |
| `403` | 已登录但不是 STUDENT | `2002` | | `403` | 已登录但不是 STUDENT | `2002` |
示例 401: 示例 401:
```json ```json
{ {
"code": 2001, "code": 401,
"message": "unauthorized", "message": "not authenticated",
"data": null, "timestamp": ""
"requestId": "req-401",
"timestamp": "2026-09-15T10:04:00Z"
} }
``` ```
`401` 响应不包含 `data` 和 `requestId`。
示例 403: 示例 403:
```json ```json

35
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'])}" assert len(d['paths']) == 129, f"Expected 129 paths, got {len(d['paths'])}"
paths = d['paths'] paths = d['paths']
required = [ required = [
'/review-weight-templates', '/review-weight-templates',
'/profile/me/review-weights', '/profile/me/review-weights',
@ -142,14 +143,42 @@ print(" V33 report/block endpoints present (9 operations / 8 paths)")
# Skill cloud endpoints (3 paths / 3 GET operations) # Skill cloud endpoints (3 paths / 3 GET operations)
for skill_path in ('/skills/word-cloud', '/skills/match', '/skills/learning-path'): for skill_path in ('/skills/word-cloud', '/skills/match', '/skills/learning-path'):
assert 'get' in paths[skill_path], f"Missing GET {skill_path}" assert 'get' in paths[skill_path], f"Missing GET {skill_path}"
assert paths['/skills/word-cloud']['get'].get('operationId') == 'getWordCloud' skill_operation_ids = {
assert paths['/skills/match']['get'].get('operationId') == 'getMatch' '/skills/word-cloud': 'getWordCloud',
assert paths['/skills/learning-path']['get'].get('operationId') == 'getLearningPath' '/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/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/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' 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'] 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 = { skill_schema_fields = {
'SkillCloudNode': {'id', 'code', 'name'}, 'SkillCloudNode': {'id', 'code', 'name'},
'SkillCloudItem': {'rank', 'skillId', 'skillCode', 'nameZh', 'nameEn', 'sourceType', 'sourceCode', 'categoryId', 'categoryName', 'heat', 'heatSourceCode', 'heatElementId', 'heatProxy'}, 'SkillCloudItem': {'rank', 'skillId', 'skillCode', 'nameZh', 'nameEn', 'sourceType', 'sourceCode', 'categoryId', 'categoryName', 'heat', 'heatSourceCode', 'heatElementId', 'heatProxy'},

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

@ -41,6 +41,7 @@ public class SkillCloudController {
private final ProfileService profileService; private final ProfileService profileService;
@Operation( @Operation(
operationId = "getWordCloud",
summary = "查询行业技能词云", summary = "查询行业技能词云",
description = "返回当前筛选范围内的全部技能,按热度降序排列;前端取前十条作为 TOP10。仅 STUDENT 可访问。" description = "返回当前筛选范围内的全部技能,按热度降序排列;前端取前十条作为 TOP10。仅 STUDENT 可访问。"
) )
@ -88,6 +89,7 @@ public class SkillCloudController {
} }
@Operation( @Operation(
operationId = "getSkillMatch",
summary = "查询个人技能匹配度", summary = "查询个人技能匹配度",
description = "使用当前 STUDENT 的个人技能,与筛选结果热度 TOP10 比对并返回命中、缺失和匹配度。仅 STUDENT 可访问。" description = "使用当前 STUDENT 的个人技能,与筛选结果热度 TOP10 比对并返回命中、缺失和匹配度。仅 STUDENT 可访问。"
) )
@ -135,6 +137,7 @@ public class SkillCloudController {
} }
@Operation( @Operation(
operationId = "getLearningPath",
summary = "生成个人学习路径", summary = "生成个人学习路径",
description = "仅针对个人 TOP10 中未命中的技能,按热度顺序返回优先级和通用学习建议。仅 STUDENT 可访问。" description = "仅针对个人 TOP10 中未命中的技能,按热度顺序返回优先级和通用学习建议。仅 STUDENT 可访问。"
) )

24
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 @Test
void identityQueryParametersCannotOverrideAuthenticatedSubject() throws Exception { void identityQueryParametersCannotOverrideAuthenticatedSubject() throws Exception {
Long student = createUser(Set.of(User.Role.STUDENT)); Long student = createUser(Set.of(User.Role.STUDENT));
@ -129,6 +137,22 @@ class SkillCloudAuthorizationIntegrationTest {
verify(skillLearningPathService).generate(student, 1L, 1L, null); verify(skillLearningPathService).generate(student, 1L, 1L, null);
} }
private void assertUnauthorized(String path, String authorization) throws Exception {
var request = get(path)
.param("industryId", "1")
.param("occupationId", "1");
if (authorization != null) {
request.header("Authorization", authorization);
}
mockMvc.perform(request)
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401))
.andExpect(jsonPath("$.message").value("not authenticated"))
.andExpect(jsonPath("$.timestamp").value(""))
.andExpect(jsonPath("$.requestId").doesNotExist())
.andExpect(jsonPath("$.data").doesNotExist());
}
private void assertStatus(String path, Long userId, int expectedStatus) throws Exception { private void assertStatus(String path, Long userId, int expectedStatus) throws Exception {
var request = get(path) var request = get(path)
.param("industryId", "1") .param("industryId", "1")

47
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.math.BigDecimal;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
@ -155,13 +158,21 @@ class SkillCloudControllerTest {
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = {WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH}) @ValueSource(strings = {WORD_CLOUD_PATH, MATCH_PATH, LEARNING_PATH})
void anonymousRequestIsUnauthorized(String path) throws Exception { void anonymousRequestUsesSecurityEntryPointContract(String path) throws Exception {
mockMvc.perform(get(path) var result = mockMvc.perform(get(path)
.param("industryId", "1") .param("industryId", "1")
.param("occupationId", "1")) .param("occupationId", "1"))
.andExpect(status().isUnauthorized()) .andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401)) .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"); .doesNotContain("resume");
} }
@Test
void skillOperationIdsAreExactAndGloballyUnique() throws Exception {
String json = mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8);
JsonNode paths = objectMapper.readTree(json).path("paths");
Map<String, List<String>> locationsByOperationId = new HashMap<>();
paths.fields().forEachRemaining(path -> path.getValue().fields().forEachRemaining(operation -> {
String operationId = operation.getValue().path("operationId").asText();
if (!operationId.isBlank()) {
locationsByOperationId
.computeIfAbsent(operationId, ignored -> new ArrayList<>())
.add(operation.getKey().toUpperCase(Locale.ROOT) + " " + path.getKey());
}
}));
Map<String, String> expectedOperationIds = Map.of(
WORD_CLOUD_PATH, "getWordCloud",
MATCH_PATH, "getSkillMatch",
LEARNING_PATH, "getLearningPath");
expectedOperationIds.forEach((path, operationId) -> {
assertThat(paths.path(path).path("get").path("operationId").asText()).isEqualTo(operationId);
assertThat(locationsByOperationId.getOrDefault(operationId, List.of()))
.containsExactly("GET " + path);
});
}
@Test @Test
void openApiDocumentsStudentProtectedSkillEndpoints() throws Exception { void openApiDocumentsStudentProtectedSkillEndpoints() throws Exception {
String json = mockMvc.perform(get("/v3/api-docs")) String json = mockMvc.perform(get("/v3/api-docs"))

Loading…
Cancel
Save