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": {
"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": {

16
docs/skill-cloud-api.md

@ -11,7 +11,7 @@
- 仅允许 `STUDENT` 访问。
- 必须携带 `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. 错误响应示例
所有错误也使用统一 `ApiResponse`:
`400` 和 `403` 使用统一 `ApiResponse`;`401` 由全局 Security 入口返回裸 JSON。以下示例与运行时契约一致。
```json
{
@ -227,21 +227,21 @@ Authorization: Bearer <accessToken>
| 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

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'])}"
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'},

3
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 可访问。"
)

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

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.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<String, List<String>> locationsByOperationId = new HashMap<>();
paths.fields().forEachRemaining(path -> path.getValue().fields().forEachRemaining(operation -> {
String operationId = operation.getValue().path("operationId").asText();
if (!operationId.isBlank()) {
locationsByOperationId
.computeIfAbsent(operationId, ignored -> new ArrayList<>())
.add(operation.getKey().toUpperCase(Locale.ROOT) + " " + path.getKey());
}
}));
Map<String, String> expectedOperationIds = Map.of(
WORD_CLOUD_PATH, "getWordCloud",
MATCH_PATH, "getSkillMatch",
LEARNING_PATH, "getLearningPath");
expectedOperationIds.forEach((path, operationId) -> {
assertThat(paths.path(path).path("get").path("operationId").asText()).isEqualTo(operationId);
assertThat(locationsByOperationId.getOrDefault(operationId, List.of()))
.containsExactly("GET " + path);
});
}
@Test
void openApiDocumentsStudentProtectedSkillEndpoints() throws Exception {
String json = mockMvc.perform(get("/v3/api-docs"))

Loading…
Cancel
Save