修复来源消息列表酒店参数鉴权

This commit is contained in:
andy
2026-07-16 11:45:58 +07:00
parent 5c0a5d21d0
commit 93ecfb08a9
4 changed files with 90 additions and 16 deletions

View File

@@ -63,7 +63,7 @@
| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | 执行 OPERA 模拟操作 | 当前是模拟,不调用真实 OPERA。 |
| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry` | 重试失败 OPERA 模拟操作 | 重试会追加 attempt 历史,前端不要覆盖旧失败记录。 |
| `GET /api/reservation/tasks/{taskId}/audits` | 查询任务审计流水 | 必须带 Bearer token需要 `RESERVATION_AUDIT_READ`,后端按任务所属酒店做访问校验;用于展示人工确认、转换、模拟操作等轨迹。 |
| `GET /api/source-messages` | 查询来源消息安全摘要 | 必须带 Bearer token需要 `SOURCE_MESSAGE_READ`列表不返回邮件正文、HTML、附件 URL 或原始 payload。 |
| `GET /api/source-messages` | 查询来源消息安全摘要 | 必须带 Bearer token需要 `SOURCE_MESSAGE_READ`列表不返回邮件正文、HTML、附件 URL 或原始 payload;查询参数以 `hotel_id``external_message_id``external_conversation_id``page_num``page_size` 为准,后端暂兼容早期 camelCase 参数。 |
| `GET /api/source-messages/{id}` | 查询来源消息安全详情 | 必须带 Bearer token需要 `SOURCE_MESSAGE_READ`,后端按消息所属酒店做访问校验;只用于安全摘要详情。 |
| `GET /api/source-messages/{id}/original` | 读取来源消息原文 | 需要受控访问头,返回 HTML 时前端展示前必须 sanitize。 |
| `GET /api/source-messages/{sourceMessageId}/conversation` | 读取邮件会话详情 | 返回同一外部会话全部邮件的完整 text/html、`html_body_sanitized`、附件外链、内联图片和关联订单 / 任务摘要;前端不传原文读取 key展示 HTML 时优先使用 `html_body_sanitized`。 |

View File

@@ -55,20 +55,25 @@ public class SourceMessageController {
*/
@GetMapping
public SourceMessagePageResult<SourceMessageSummaryResponse> list(
@RequestParam(required = false) String hotelId,
@RequestParam(required = false) String externalMessageId,
@RequestParam(required = false) String externalConversationId,
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(name = "hotelId", required = false) String legacyHotelId,
@RequestParam(name = "external_message_id", required = false) String externalMessageId,
@RequestParam(name = "externalMessageId", required = false) String legacyExternalMessageId,
@RequestParam(name = "external_conversation_id", required = false) String externalConversationId,
@RequestParam(name = "externalConversationId", required = false) String legacyExternalConversationId,
@RequestParam(required = false) String captureStatus,
@RequestParam(required = false) Integer pageNum,
@RequestParam(required = false) Integer pageSize) {
@RequestParam(name = "page_num", required = false) Integer pageNum,
@RequestParam(name = "pageNum", required = false) Integer legacyPageNum,
@RequestParam(name = "page_size", required = false) Integer pageSize,
@RequestParam(name = "pageSize", required = false) Integer legacyPageSize) {
authorizationService.requirePermission(PlatformPermissionCode.SOURCE_MESSAGE_READ.name());
return queryService.query(new SourceMessageQueryRequest(
hotelId,
externalMessageId,
externalConversationId,
firstText(hotelId, legacyHotelId),
firstText(externalMessageId, legacyExternalMessageId),
firstText(externalConversationId, legacyExternalConversationId),
captureStatus,
pageNum,
pageSize
firstInteger(pageNum, legacyPageNum),
firstInteger(pageSize, legacyPageSize)
));
}
@@ -119,6 +124,20 @@ public class SourceMessageController {
return value != null && !value.trim().isEmpty();
}
/**
* 优先使用当前规范 snake_case 参数,兼容早期 camelCase 查询参数。
*/
private String firstText(String first, String second) {
return hasText(first) ? first : second;
}
/**
* 优先使用当前规范 snake_case 分页参数,兼容早期 camelCase 查询参数。
*/
private Integer firstInteger(Integer first, Integer second) {
return first == null ? second : first;
}
/**
* 按 SourceMessage 实际归属酒店校验当前用户访问权,避免跨酒店按 ID 读取摘要。
*/

View File

@@ -74,10 +74,10 @@ class SourceMessageControllerTest {
));
performAuthorized(mockMvc, adminToken(), get("/api/source-messages")
.param("hotelId", "HOTEL-TEST")
.param("externalConversationId", "conversation-api-001")
.param("pageNum", "1")
.param("pageSize", "20"))
.param("hotel_id", "HOTEL-TEST")
.param("external_conversation_id", "conversation-api-001")
.param("page_num", "1")
.param("page_size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].id").value(result.inboxId().toString()))
.andExpect(jsonPath("$.items[0].externalMessageId").value("mail-api-001"))

View File

@@ -39,7 +39,9 @@ import org.springframework.test.web.servlet.MockMvc;
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
"auth.bootstrap.default-hotel-name=测试酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"superagent.task-result.hmac-secret=test-superagent-secret"
"superagent.task-result.hmac-secret=test-superagent-secret",
"mcp.enabled=true",
"mcp.auth-token=test-mcp-token"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
@@ -99,6 +101,34 @@ class FrontendReadAuthorizationControllerTest {
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
}
@Test
void shouldRejectSourceMessageReadWhenTokenMissing() throws Exception {
mockMvc.perform(get("/api/source-messages")
.param("hotel_id", HOTEL_ID))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
}
@Test
void shouldRejectSourceMessageReadWhenPermissionMissing() throws Exception {
String token = loginToken(mockMvc, "cp1-no-permission", "NoPerm@123456");
performAuthorized(mockMvc, token, get("/api/source-messages")
.param("hotel_id", HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
}
@Test
void shouldRejectSourceMessageListAcrossHotelsWhenSnakeHotelIdProvided() throws Exception {
String token = loginToken(mockMvc, "cp1-admin", "Admin@123456");
performAuthorized(mockMvc, token, get("/api/source-messages")
.param("hotel_id", OTHER_HOTEL_ID))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
}
@Test
void shouldRejectReservationAndSourceMessageDetailAcrossHotels() throws Exception {
String token = loginToken(mockMvc, "cp1-admin", "Admin@123456");
@@ -133,6 +163,31 @@ class FrontendReadAuthorizationControllerTest {
.andExpect(jsonPath("$.error_code").value("AUTH_HEADER_MISSING"));
}
@Test
void shouldKeepAiQueryEndpointOutsideFrontendLoginInterceptor() throws Exception {
mockMvc.perform(post("/api/ai-query/v1/case-context")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error.code").value("AUTH_HEADER_MISSING"));
}
@Test
void shouldKeepMcpEndpointOutsideFrontendLoginInterceptor() throws Exception {
mockMvc.perform(post("/mcp")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"jsonrpc": "2.0",
"id": "cp1-mcp-auth-001",
"method": "tools/list",
"params": {}
}
"""))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error.data.code").value("MCP_AUTH_INVALID"));
}
private SourceMessageCaptureResult captureOtherHotelSourceMessage() {
return captureService.capture(new CaptureSourceMessageCommand(
OTHER_HOTEL_ID,