再次提交一下代码

This commit is contained in:
andy
2026-07-17 13:13:47 +07:00
parent f4d86248d4
commit 980a3a2535
41 changed files with 1699 additions and 187 deletions

View File

@@ -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();
}
}

View File

@@ -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(

View File

@@ -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);
}

View File

@@ -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 读取原文内容,并记录成功访问审计。
*

View File

@@ -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();
}
}

View File

@@ -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) {

View File

@@ -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 错误响应结构稳定。
*/