diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 0e969bd..984c735 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -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`。 | diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java index a444cd6..8462ad1 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/control/SourceMessageController.java @@ -55,20 +55,25 @@ public class SourceMessageController { */ @GetMapping public SourceMessagePageResult 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 读取摘要。 */ diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java index 0b48e3f..aab3697 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/message/control/SourceMessageControllerTest.java @@ -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")) diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java index 58e48a7..3d30d5c 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/FrontendReadAuthorizationControllerTest.java @@ -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,