再次提交一下代码
This commit is contained in:
@@ -12,9 +12,9 @@ import cn.nianxx.thhotel.platform.message.service.SourceMessageConversationServi
|
||||
import cn.nianxx.thhotel.platform.message.service.SourceMessageOriginalService;
|
||||
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
|
||||
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -22,12 +22,16 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* SourceMessage Inbox 安全查询接口。该 Controller 不返回正文、HTML、附件 URL 或原始 payload。
|
||||
* SourceMessage Inbox 查询接口。摘要接口只返回安全信息,原文和会话正文接口必须额外校验原文读取权限。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/source-messages")
|
||||
public class SourceMessageController {
|
||||
|
||||
private static final String FRONTEND_ACTOR_PREFIX = "frontend_user:";
|
||||
private static final String ORIGINAL_ACCESS_SCENE = "source-message-original";
|
||||
private static final String CONVERSATION_ACCESS_SCENE = "source-message-conversation";
|
||||
|
||||
private final SourceMessageQueryService queryService;
|
||||
private final SourceMessageOriginalService originalService;
|
||||
private final SourceMessageConversationService conversationService;
|
||||
@@ -90,35 +94,35 @@ public class SourceMessageController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 SourceMessage 原文内容。该接口必须携带受控访问口令、调用方和访问场景,并会写入审计。
|
||||
* 读取 SourceMessage 原文内容。必须登录且拥有原文读取权限,并按消息所属酒店校验访问权。
|
||||
*/
|
||||
@GetMapping("/{id}/original")
|
||||
public SourceMessageOriginalResponse original(
|
||||
@PathVariable Long id,
|
||||
@RequestHeader(name = "X-TH-Hotel-Source-Original-Read-Key", required = false) String accessKey,
|
||||
@RequestHeader(name = "X-TH-Hotel-Actor", required = false) String actorId,
|
||||
@RequestHeader(name = "X-TH-Hotel-Access-Scene", required = false) String accessScene) {
|
||||
if (!originalService.canReadOriginal(accessKey)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "source message original read denied");
|
||||
}
|
||||
if (!hasText(actorId) || !hasText(accessScene)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "actor and access scene are required");
|
||||
}
|
||||
return originalService.readOriginal(id, new SourceMessageOriginalAccessRequest(actorId, accessScene))
|
||||
public SourceMessageOriginalResponse original(@PathVariable Long id) {
|
||||
AuthenticatedUserContext actor = requireSourceMessageOriginalReadPermission();
|
||||
SourceMessageSummaryResponse summary = requireExistingSourceMessage(id);
|
||||
requireSourceMessageHotelAccess(summary);
|
||||
return originalService.readOriginal(
|
||||
id,
|
||||
new SourceMessageOriginalAccessRequest(actorId(actor), ORIGINAL_ACCESS_SCENE))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取指定 SourceMessage 所在邮件会话完整详情。该接口由后端内部记录原文读取审计,前端不传原文 key。
|
||||
* 读取指定 SourceMessage 所在邮件会话完整详情。必须拥有原文读取权限,前端不传原文 key。
|
||||
*/
|
||||
@GetMapping("/{id}/conversation")
|
||||
public SourceMessageConversationResult conversation(@PathVariable Long id) {
|
||||
return conversationService.getConversation(id)
|
||||
AuthenticatedUserContext actor = requireSourceMessageOriginalReadPermission();
|
||||
SourceMessageSummaryResponse summary = requireExistingSourceMessage(id);
|
||||
requireSourceMessageHotelAccess(summary);
|
||||
return conversationService.getConversation(
|
||||
id,
|
||||
new SourceMessageOriginalAccessRequest(actorId(actor), CONVERSATION_ACCESS_SCENE))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求头文本是否有效,避免空白调用方或场景进入审计记录。
|
||||
* 校验查询参数文本是否有效,避免空白参数影响兼容参数选择。
|
||||
*/
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
@@ -144,4 +148,27 @@ public class SourceMessageController {
|
||||
private void requireSourceMessageHotelAccess(SourceMessageSummaryResponse summary) {
|
||||
hotelContextService.requireAccessibleHotel(summary.hotelId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 SourceMessage 安全摘要用于存在性和酒店访问校验,避免原文读取先于权限边界发生。
|
||||
*/
|
||||
private SourceMessageSummaryResponse requireExistingSourceMessage(Long id) {
|
||||
return queryService.getSummary(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验邮件原文读取权限。原文权限必须叠加摘要读取权限,避免自定义角色只拥有原文权限时绕过摘要边界。
|
||||
*/
|
||||
private AuthenticatedUserContext requireSourceMessageOriginalReadPermission() {
|
||||
authorizationService.requirePermission(PlatformPermissionCode.SOURCE_MESSAGE_READ.name());
|
||||
return authorizationService.requirePermission(PlatformPermissionCode.SOURCE_MESSAGE_ORIGINAL_READ.name());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成原文读取审计 actor,使用稳定用户 ID 并加前端用户前缀,便于区分机器和系统内部访问。
|
||||
*/
|
||||
private String actorId(AuthenticatedUserContext actor) {
|
||||
return FRONTEND_ACTOR_PREFIX + actor.userId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* SourceMessage 查询接口异常处理。只返回安全错误码和摘要信息,不暴露内部堆栈。
|
||||
* SourceMessage 前端接口异常处理。只返回安全错误码和摘要信息,不暴露内部堆栈。
|
||||
*/
|
||||
@RestControllerAdvice(assignableTypes = SourceMessageController.class)
|
||||
public class SourceMessageControllerAdvice {
|
||||
@@ -25,7 +25,7 @@ public class SourceMessageControllerAdvice {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 SourceMessage 摘要接口登录或权限不足异常,避免泄漏内部鉴权细节。
|
||||
* 处理 SourceMessage 接口登录或权限不足异常,避免泄漏内部鉴权细节。
|
||||
*/
|
||||
@ExceptionHandler(FrontendAuthorizationException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleFrontendAuthorizationException(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package cn.nianxx.thhotel.platform.message.service;
|
||||
|
||||
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageOriginalAccessRequest;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* SourceMessage 邮件会话详情服务。该服务会返回完整正文、HTML 和媒体外链,并写入内部读取审计。
|
||||
* SourceMessage 邮件会话详情服务。该服务会返回完整正文、HTML 和媒体外链,并写入读取审计。
|
||||
*/
|
||||
public interface SourceMessageConversationService {
|
||||
|
||||
@@ -12,7 +13,10 @@ public interface SourceMessageConversationService {
|
||||
* 读取指定 SourceMessage 所在邮件会话的完整详情。
|
||||
*
|
||||
* @param sourceMessageId 当前定位的 SourceMessage Inbox ID
|
||||
* @param accessRequest 当前登录用户和访问场景,用于原文读取审计
|
||||
* @return 找到时返回同一会话全部邮件详情;不存在时为空
|
||||
*/
|
||||
Optional<SourceMessageConversationResult> getConversation(Long sourceMessageId);
|
||||
Optional<SourceMessageConversationResult> getConversation(
|
||||
Long sourceMessageId,
|
||||
SourceMessageOriginalAccessRequest accessRequest);
|
||||
}
|
||||
|
||||
@@ -5,18 +5,10 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalRes
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* SourceMessage 原文读取服务。该服务负责权限口令校验、原文读取和访问审计。
|
||||
* SourceMessage 原文读取服务。Controller 负责登录、权限和酒店隔离,本服务负责原文读取和访问审计。
|
||||
*/
|
||||
public interface SourceMessageOriginalService {
|
||||
|
||||
/**
|
||||
* 校验原文读取访问口令,当前用于替代尚未落地的角色权限体系。
|
||||
*
|
||||
* @param submittedAccessKey 调用方提交的原文读取口令
|
||||
* @return 口令可用时返回 true
|
||||
*/
|
||||
boolean canReadOriginal(String submittedAccessKey);
|
||||
|
||||
/**
|
||||
* 按内部 SourceMessage ID 读取原文内容,并记录成功访问审计。
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@ import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccess
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalMediaItem;
|
||||
import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageOriginalAccessResult;
|
||||
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageOriginalAccessRequest;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationMessageResult;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationSummaryResult;
|
||||
@@ -23,13 +24,11 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* SourceMessage 邮件会话详情服务实现。该服务负责受控读取原文并写入后端内部审计。
|
||||
* SourceMessage 邮件会话详情服务实现。该服务负责受控读取原文并写入当前用户读取审计。
|
||||
*/
|
||||
@Service
|
||||
public class SourceMessageConversationServiceImpl implements SourceMessageConversationService {
|
||||
|
||||
private static final String INTERNAL_ACTOR_ID = "system:source-message-conversation";
|
||||
private static final String ACCESS_SCENE = "source-message-conversation";
|
||||
private static final String MEDIA_TYPE_INLINE_IMAGE = "INLINE_IMAGE";
|
||||
private static final String MEDIA_TYPE_ATTACHMENT = "ATTACHMENT";
|
||||
|
||||
@@ -50,11 +49,15 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取指定 SourceMessage 所在会话的完整原文链路,并为每封邮件写入内部读取审计。
|
||||
* 读取指定 SourceMessage 所在会话的完整原文链路,并为每封邮件写入当前用户读取审计。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public Optional<SourceMessageConversationResult> getConversation(Long sourceMessageId) {
|
||||
public Optional<SourceMessageConversationResult> getConversation(
|
||||
Long sourceMessageId,
|
||||
SourceMessageOriginalAccessRequest accessRequest) {
|
||||
String actorId = requireText(accessRequest.actorId(), "actorId");
|
||||
String accessScene = requireText(accessRequest.accessScene(), "accessScene");
|
||||
Optional<SourceMessageInboxSnapshot> sourceOptional = inboxRepository.findById(sourceMessageId);
|
||||
if (sourceOptional.isEmpty()) {
|
||||
return Optional.empty();
|
||||
@@ -63,7 +66,7 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
|
||||
List<SourceMessageInboxSnapshot> conversationMessages = findConversationMessages(source);
|
||||
SourceMessageConversationSummaryResult conversation = toConversationSummary(source, conversationMessages);
|
||||
List<SourceMessageConversationMessageResult> messages = conversationMessages.stream()
|
||||
.map(this::toConversationMessage)
|
||||
.map(message -> toConversationMessage(message, actorId, accessScene))
|
||||
.toList();
|
||||
return Optional.of(new SourceMessageConversationResult(conversation, messages));
|
||||
}
|
||||
@@ -102,8 +105,11 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
|
||||
/**
|
||||
* 将 Inbox 快照转换为会话邮件详情,并附带原文、媒体外链和业务关联摘要。
|
||||
*/
|
||||
private SourceMessageConversationMessageResult toConversationMessage(SourceMessageInboxSnapshot message) {
|
||||
SourceMessageOriginalContent originalContent = readOriginalAndAudit(message);
|
||||
private SourceMessageConversationMessageResult toConversationMessage(
|
||||
SourceMessageInboxSnapshot message,
|
||||
String actorId,
|
||||
String accessScene) {
|
||||
SourceMessageOriginalContent originalContent = readOriginalAndAudit(message, actorId, accessScene);
|
||||
SourceMessageRelatedContextResult relatedContext = findRelatedContext(message);
|
||||
String htmlBody = originalContent.htmlBody();
|
||||
return new SourceMessageConversationMessageResult(
|
||||
@@ -126,16 +132,19 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取原文并写入内部审计。会话接口不依赖前端传原文读取 key。
|
||||
* 读取原文并写入当前用户审计。会话接口不依赖前端传原文读取 key。
|
||||
*/
|
||||
private SourceMessageOriginalContent readOriginalAndAudit(SourceMessageInboxSnapshot message) {
|
||||
private SourceMessageOriginalContent readOriginalAndAudit(
|
||||
SourceMessageInboxSnapshot message,
|
||||
String actorId,
|
||||
String accessScene) {
|
||||
SourceMessageOriginalContent content = inboxRepository.findOriginalContent(message.id())
|
||||
.orElse(new SourceMessageOriginalContent(message.id(), null, null, List.of()));
|
||||
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
|
||||
inboxRepository.insertOriginalAccessAudit(new SourceMessageOriginalAccessAuditDraft(
|
||||
message.id(),
|
||||
INTERNAL_ACTOR_ID,
|
||||
ACCESS_SCENE,
|
||||
actorId,
|
||||
accessScene,
|
||||
SourceMessageOriginalAccessResult.GRANTED.code(),
|
||||
now));
|
||||
return content;
|
||||
@@ -181,4 +190,14 @@ public class SourceMessageConversationServiceImpl implements SourceMessageConver
|
||||
item.externalMediaId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验审计字段,避免空 actor 或访问场景写入原文读取审计。
|
||||
*/
|
||||
private String requireText(String value, String fieldName) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(fieldName + " must not be blank");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -14,36 +14,22 @@ import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* SourceMessage 原文读取服务实现。当前用受控访问口令替代尚未落地的角色权限体系。
|
||||
* SourceMessage 原文读取服务实现。权限边界由 Controller 收口,服务层只负责原文读取和审计写入。
|
||||
*/
|
||||
@Service
|
||||
public class SourceMessageOriginalServiceImpl implements SourceMessageOriginalService {
|
||||
|
||||
private final SourceMessageInboxRepository inboxRepository;
|
||||
private final String configuredAccessKey;
|
||||
|
||||
/**
|
||||
* 注入 SourceMessage 持久化边界和原文读取访问口令,服务层负责审计写入。
|
||||
* 注入 SourceMessage 持久化边界,服务层负责原文读取审计写入。
|
||||
*/
|
||||
public SourceMessageOriginalServiceImpl(
|
||||
SourceMessageInboxRepository inboxRepository,
|
||||
@Value("${source-message.original-read.access-key:}") String configuredAccessKey) {
|
||||
public SourceMessageOriginalServiceImpl(SourceMessageInboxRepository inboxRepository) {
|
||||
this.inboxRepository = inboxRepository;
|
||||
this.configuredAccessKey = configuredAccessKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验原文读取口令。配置为空时默认关闭原文读取能力,避免误开放敏感正文和媒体 URL。
|
||||
*/
|
||||
@Override
|
||||
public boolean canReadOriginal(String submittedAccessKey) {
|
||||
String configured = trimToNull(configuredAccessKey);
|
||||
return configured != null && configured.equals(submittedAccessKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,7 +108,7 @@ public class SourceMessageOriginalServiceImpl implements SourceMessageOriginalSe
|
||||
}
|
||||
|
||||
/**
|
||||
* 将空白字符串统一转换为空,避免空白访问口令或审计字段通过校验。
|
||||
* 将空白字符串统一转换为空,避免空白审计字段通过校验。
|
||||
*/
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import cn.nianxx.thhotel.platform.security.service.impl.FrontendAuthorizationException;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationWorkflowErrorResponse;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationInvoiceGenerationException;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationTaskWorkflowException;
|
||||
import java.util.List;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -14,7 +15,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
@RestControllerAdvice(assignableTypes = {
|
||||
ReservationTaskController.class,
|
||||
ReservationFrontendQueryController.class,
|
||||
ReservationDemoDataController.class
|
||||
ReservationDemoDataController.class,
|
||||
ReservationInvoiceGenerationController.class
|
||||
})
|
||||
public class ReservationTaskControllerAdvice {
|
||||
|
||||
@@ -31,6 +33,19 @@ public class ReservationTaskControllerAdvice {
|
||||
exception.getDetails()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Reservation Invoice 生成阶段的受控业务异常。
|
||||
*/
|
||||
@ExceptionHandler(ReservationInvoiceGenerationException.class)
|
||||
public ResponseEntity<ReservationWorkflowErrorResponse> handleInvoiceGenerationException(
|
||||
ReservationInvoiceGenerationException exception) {
|
||||
return ResponseEntity.status(exception.getStatus())
|
||||
.body(new ReservationWorkflowErrorResponse(
|
||||
exception.getErrorCode(),
|
||||
exception.getMessage(),
|
||||
exception.getDetails()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理前端业务接口登录或权限不足异常,保持 Reservation 错误响应结构稳定。
|
||||
*/
|
||||
|
||||
@@ -75,6 +75,7 @@ class AuthControllerTest {
|
||||
"RESERVATION_TASK_CONFIRM",
|
||||
"RESERVATION_OPERA_SIM_EXECUTE",
|
||||
"RESERVATION_AUDIT_READ",
|
||||
"RESERVATION_INVOICE_GENERATE",
|
||||
"HOTEL_SWITCH",
|
||||
"SYSTEM_AUTH_READ",
|
||||
"SYSTEM_USER_MANAGE",
|
||||
|
||||
@@ -28,7 +28,6 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
properties = {
|
||||
"source-message.original-read.access-key=test-original-read-key",
|
||||
"spring.datasource.url=jdbc:h2:mem:source_message_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
|
||||
"auth.bootstrap.admin.username=source-message-admin",
|
||||
"auth.bootstrap.admin.password=Admin@123456",
|
||||
@@ -63,6 +62,18 @@ class SourceMessageControllerTest {
|
||||
return adminToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用户名查询稳定用户 ID,用于校验原文读取审计 actor 不受用户名变更影响。
|
||||
*/
|
||||
private String frontendActorId(String username) {
|
||||
Long userId = jdbcTemplate.queryForObject("""
|
||||
SELECT id
|
||||
FROM platform_user
|
||||
WHERE username = ?
|
||||
""", Long.class, username);
|
||||
return "frontend_user:" + userId;
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldListAndReadSummaryWithoutOriginalContentOrMediaUrls() throws Exception {
|
||||
SourceMessageCaptureResult result = captureService.capture(command(
|
||||
@@ -101,7 +112,7 @@ class SourceMessageControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectOriginalReadWithoutAccessKey() throws Exception {
|
||||
void shouldRejectOriginalReadWithoutLoginToken() throws Exception {
|
||||
SourceMessageCaptureResult result = captureService.capture(command(
|
||||
"mail-original-denied-001",
|
||||
"conversation-original-denied-001",
|
||||
@@ -111,13 +122,13 @@ class SourceMessageControllerTest {
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/source-messages/{id}/original", result.inboxId())
|
||||
.header("X-TH-Hotel-Actor", "operator-001")
|
||||
.header("X-TH-Hotel-Access-Scene", "reservation-detail"))
|
||||
.andExpect(status().isForbidden());
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReadOriginalContentWithAccessKeyAndRecordAudit() throws Exception {
|
||||
void shouldReadOriginalContentWithLoginPermissionAndRecordCurrentUserAudit() throws Exception {
|
||||
SourceMessageCaptureResult result = captureService.capture(command(
|
||||
"mail-original-001",
|
||||
"conversation-original-001",
|
||||
@@ -126,8 +137,7 @@ class SourceMessageControllerTest {
|
||||
"https://media.example.test/original.pdf"
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/source-messages/{id}/original", result.inboxId())
|
||||
.header("X-TH-Hotel-Source-Original-Read-Key", "test-original-read-key")
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/source-messages/{id}/original", result.inboxId())
|
||||
.header("X-TH-Hotel-Actor", "operator-001")
|
||||
.header("X-TH-Hotel-Access-Scene", "reservation-detail"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -143,9 +153,9 @@ class SourceMessageControllerTest {
|
||||
SELECT COUNT(*)
|
||||
FROM platform_source_message_original_access_audit
|
||||
WHERE inbox_id = ?
|
||||
AND actor_id = 'operator-001'
|
||||
AND access_scene = 'reservation-detail'
|
||||
""", Long.class, result.inboxId());
|
||||
AND actor_id = ?
|
||||
AND access_scene = 'source-message-original'
|
||||
""", Long.class, result.inboxId(), frontendActorId("source-message-admin"));
|
||||
assert auditCount != null;
|
||||
org.assertj.core.api.Assertions.assertThat(auditCount).isEqualTo(1L);
|
||||
}
|
||||
@@ -177,7 +187,8 @@ class SourceMessageControllerTest {
|
||||
insertRelatedTransition(transitionId, first.inboxId(), "GRP-CONVERSATION-P0-001");
|
||||
insertRelatedTask(taskId, orderId, first.inboxId(), transitionId);
|
||||
|
||||
mockMvc.perform(get("/api/source-messages/{sourceMessageId}/conversation", first.inboxId()))
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/source-messages/{sourceMessageId}/conversation",
|
||||
first.inboxId()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.conversation.external_conversation_id").value("conversation-p0-001"))
|
||||
.andExpect(jsonPath("$.conversation.message_count").value(2))
|
||||
@@ -215,9 +226,9 @@ class SourceMessageControllerTest {
|
||||
SELECT COUNT(*)
|
||||
FROM platform_source_message_original_access_audit
|
||||
WHERE inbox_id IN (?, ?)
|
||||
AND actor_id = 'system:source-message-conversation'
|
||||
AND actor_id = ?
|
||||
AND access_scene = 'source-message-conversation'
|
||||
""", Long.class, first.inboxId(), second.inboxId());
|
||||
""", Long.class, first.inboxId(), second.inboxId(), frontendActorId("source-message-admin"));
|
||||
org.assertj.core.api.Assertions.assertThat(auditCount).isEqualTo(2L);
|
||||
}
|
||||
|
||||
@@ -236,7 +247,8 @@ class SourceMessageControllerTest {
|
||||
insertRelatedTransition(transitionId, sourceMessage.inboxId(), "TMP-HIDDEN-CONVERSATION-001");
|
||||
insertRelatedTask(taskId, orderId, sourceMessage.inboxId(), transitionId);
|
||||
|
||||
mockMvc.perform(get("/api/source-messages/{sourceMessageId}/conversation", sourceMessage.inboxId()))
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/source-messages/{sourceMessageId}/conversation",
|
||||
sourceMessage.inboxId()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.messages[0].related_orders.length()").value(0))
|
||||
.andExpect(jsonPath("$.messages[0].related_tasks[0].task_id").value(taskId.toString()))
|
||||
|
||||
@@ -8,6 +8,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
|
||||
import cn.nianxx.thhotel.platform.access.common.enums.PlatformRoleStatus;
|
||||
import cn.nianxx.thhotel.platform.access.domain.PlatformPermissionEntity;
|
||||
import cn.nianxx.thhotel.platform.access.domain.PlatformRoleEntity;
|
||||
import cn.nianxx.thhotel.platform.access.repository.PlatformAccessRepository;
|
||||
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
|
||||
import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
|
||||
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
|
||||
@@ -49,6 +54,9 @@ class FrontendReadAuthorizationControllerTest {
|
||||
|
||||
private static final String HOTEL_ID = "HOTEL-TEST";
|
||||
private static final String OTHER_HOTEL_ID = "HOTEL-OTHER";
|
||||
private static final String ORIGINAL_ONLY_USERNAME = "cp2-original-only";
|
||||
private static final String ORIGINAL_ONLY_PASSWORD = "OriginalOnly@123456";
|
||||
private static final String ORIGINAL_ONLY_ROLE_CODE = "CP2_ORIGINAL_ONLY";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@@ -59,6 +67,8 @@ class FrontendReadAuthorizationControllerTest {
|
||||
@Autowired
|
||||
private PlatformIdentityRepository identityRepository;
|
||||
@Autowired
|
||||
private PlatformAccessRepository accessRepository;
|
||||
@Autowired
|
||||
private PlatformHotelRepository hotelRepository;
|
||||
@Autowired
|
||||
private AuthPasswordService passwordService;
|
||||
@@ -81,6 +91,7 @@ class FrontendReadAuthorizationControllerTest {
|
||||
return created;
|
||||
});
|
||||
hotelRepository.ensureUserHotel(user.getId(), HOTEL_ID, true);
|
||||
ensureOriginalOnlyUser();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -119,6 +130,44 @@ class FrontendReadAuthorizationControllerTest {
|
||||
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSourceMessageOriginalAndConversationWhenTokenMissing() throws Exception {
|
||||
mockMvc.perform(get("/api/source-messages/{id}/original", 970000000000000901L))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
|
||||
|
||||
mockMvc.perform(get("/api/source-messages/{id}/conversation", 970000000000000901L))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSourceMessageOriginalAndConversationWhenOriginalPermissionMissing() throws Exception {
|
||||
String token = loginToken(mockMvc, "cp1-no-permission", "NoPerm@123456");
|
||||
|
||||
performAuthorized(mockMvc, token, get("/api/source-messages/{id}/original", 970000000000000902L))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
|
||||
|
||||
performAuthorized(mockMvc, token, get("/api/source-messages/{id}/conversation", 970000000000000902L))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSourceMessageOriginalAndConversationWhenSummaryPermissionMissing() throws Exception {
|
||||
String token = loginToken(mockMvc, ORIGINAL_ONLY_USERNAME, ORIGINAL_ONLY_PASSWORD);
|
||||
SourceMessageCaptureResult source = captureHotelSourceMessage();
|
||||
|
||||
performAuthorized(mockMvc, token, get("/api/source-messages/{id}/original", source.inboxId()))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
|
||||
|
||||
performAuthorized(mockMvc, token, get("/api/source-messages/{id}/conversation", source.inboxId()))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSourceMessageListAcrossHotelsWhenSnakeHotelIdProvided() throws Exception {
|
||||
String token = loginToken(mockMvc, "cp1-admin", "Admin@123456");
|
||||
@@ -154,6 +203,20 @@ class FrontendReadAuthorizationControllerTest {
|
||||
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSourceMessageOriginalAndConversationAcrossHotels() throws Exception {
|
||||
String token = loginToken(mockMvc, "cp1-admin", "Admin@123456");
|
||||
SourceMessageCaptureResult source = captureOtherHotelSourceMessage();
|
||||
|
||||
performAuthorized(mockMvc, token, get("/api/source-messages/{id}/original", source.inboxId()))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
|
||||
|
||||
performAuthorized(mockMvc, token, get("/api/source-messages/{id}/conversation", source.inboxId()))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepSuperAgentTaskResultEndpointOutsideFrontendLoginInterceptor() throws Exception {
|
||||
mockMvc.perform(post("/api/integrations/superagent/task-results")
|
||||
@@ -207,6 +270,62 @@ class FrontendReadAuthorizationControllerTest {
|
||||
List.of()));
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureHotelSourceMessage() {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
HOTEL_ID,
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
"cp2-hotel-mail-001",
|
||||
"cp2-hotel-thread-001",
|
||||
"frame-cp2-hotel",
|
||||
"session-cp2-hotel",
|
||||
Instant.parse("2026-07-13T02:00:00Z"),
|
||||
"guest@example.test",
|
||||
"Current hotel message",
|
||||
"Current hotel body",
|
||||
"<html><body>Current hotel body</body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"cp2-hotel-mail-001\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()));
|
||||
}
|
||||
|
||||
private void ensureOriginalOnlyUser() {
|
||||
PlatformUserEntity user = identityRepository.findUserByUsername(ORIGINAL_ONLY_USERNAME)
|
||||
.orElseGet(() -> {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
PlatformUserEntity created = new PlatformUserEntity();
|
||||
created.setUsername(ORIGINAL_ONLY_USERNAME);
|
||||
created.setPasswordHash(passwordService.hash(ORIGINAL_ONLY_PASSWORD));
|
||||
created.setDisplayName("仅原文权限用户");
|
||||
created.setUserStatus(PlatformUserStatus.ACTIVE.name());
|
||||
created.setSuperAdmin(false);
|
||||
created.setPasswordChangedAt(now);
|
||||
created.setCreatedAt(now);
|
||||
created.setUpdatedAt(now);
|
||||
identityRepository.insertUser(created);
|
||||
return created;
|
||||
});
|
||||
PlatformRoleEntity role = accessRepository.findRoleByCode(ORIGINAL_ONLY_ROLE_CODE)
|
||||
.orElseGet(() -> {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
PlatformRoleEntity created = new PlatformRoleEntity();
|
||||
created.setRoleCode(ORIGINAL_ONLY_ROLE_CODE);
|
||||
created.setRoleName("CP2 仅原文权限测试角色");
|
||||
created.setRoleStatus(PlatformRoleStatus.ACTIVE.name());
|
||||
created.setSystemBuiltin(false);
|
||||
created.setCreatedAt(now);
|
||||
created.setUpdatedAt(now);
|
||||
accessRepository.insertRole(created);
|
||||
return created;
|
||||
});
|
||||
PlatformPermissionEntity originalPermission = accessRepository
|
||||
.findPermissionByCode(PlatformPermissionCode.SOURCE_MESSAGE_ORIGINAL_READ.name())
|
||||
.orElseThrow();
|
||||
accessRepository.replaceRolePermissions(role.getId(), List.of(originalPermission.getId()));
|
||||
accessRepository.ensureUserRole(user.getId(), role.getId());
|
||||
hotelRepository.ensureUserHotel(user.getId(), HOTEL_ID, true);
|
||||
}
|
||||
|
||||
private void insertOtherHotelOrderTask(Long orderId, Long taskId, Long sourceMessageId) {
|
||||
Long transitionId = taskId - 1;
|
||||
jdbcTemplate.update("""
|
||||
|
||||
@@ -133,7 +133,8 @@ class ReservationDemoDataControllerTest {
|
||||
.andExpect(jsonPath("$.opera_operations[0].operation_status").value("FAILED"))
|
||||
.andExpect(jsonPath("$.opera_operations[0].attempt_count").value(1));
|
||||
|
||||
mockMvc.perform(get("/api/source-messages/{sourceMessageId}/conversation", queueSourceMessageId))
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/source-messages/{sourceMessageId}/conversation",
|
||||
queueSourceMessageId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.messages", hasSize(greaterThanOrEqualTo(2))))
|
||||
.andExpect(jsonPath("$.messages[0].html_sanitize_required").value(true))
|
||||
|
||||
Reference in New Issue
Block a user