修复来源消息列表酒店参数鉴权
This commit is contained in:
@@ -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`。 |
|
||||
|
||||
@@ -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 读取摘要。
|
||||
*/
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user