收口预约只读接口权限与酒店隔离

This commit is contained in:
andy
2026-07-16 11:27:23 +07:00
parent 9a2812fe74
commit 5c0a5d21d0
22 changed files with 877 additions and 63 deletions

View File

@@ -1,10 +1,13 @@
package cn.nianxx.thhotel.platform.message.control;
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageConversationResult;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageOriginalResponse;
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageOriginalAccessRequest;
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageConversationService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageOriginalService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
@@ -28,6 +31,8 @@ public class SourceMessageController {
private final SourceMessageQueryService queryService;
private final SourceMessageOriginalService originalService;
private final SourceMessageConversationService conversationService;
private final FrontendAuthorizationService authorizationService;
private final HotelContextService hotelContextService;
/**
* 注入 SourceMessage 查询与原文读取服务Controller 不直接访问 Mapper 或 Repository。
@@ -35,10 +40,14 @@ public class SourceMessageController {
public SourceMessageController(
SourceMessageQueryService queryService,
SourceMessageOriginalService originalService,
SourceMessageConversationService conversationService) {
SourceMessageConversationService conversationService,
FrontendAuthorizationService authorizationService,
HotelContextService hotelContextService) {
this.queryService = queryService;
this.originalService = originalService;
this.conversationService = conversationService;
this.authorizationService = authorizationService;
this.hotelContextService = hotelContextService;
}
/**
@@ -52,6 +61,7 @@ public class SourceMessageController {
@RequestParam(required = false) String captureStatus,
@RequestParam(required = false) Integer pageNum,
@RequestParam(required = false) Integer pageSize) {
authorizationService.requirePermission(PlatformPermissionCode.SOURCE_MESSAGE_READ.name());
return queryService.query(new SourceMessageQueryRequest(
hotelId,
externalMessageId,
@@ -67,8 +77,11 @@ public class SourceMessageController {
*/
@GetMapping("/{id}")
public SourceMessageSummaryResponse detail(@PathVariable Long id) {
return queryService.getSummary(id)
authorizationService.requirePermission(PlatformPermissionCode.SOURCE_MESSAGE_READ.name());
SourceMessageSummaryResponse summary = queryService.getSummary(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "source message not found"));
requireSourceMessageHotelAccess(summary);
return summary;
}
/**
@@ -105,4 +118,11 @@ public class SourceMessageController {
private boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
/**
* 按 SourceMessage 实际归属酒店校验当前用户访问权,避免跨酒店按 ID 读取摘要。
*/
private void requireSourceMessageHotelAccess(SourceMessageSummaryResponse summary) {
hotelContextService.requireAccessibleHotel(summary.hotelId());
}
}

View File

@@ -1,6 +1,7 @@
package cn.nianxx.thhotel.platform.message.control;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.security.service.impl.FrontendAuthorizationException;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -22,4 +23,16 @@ public class SourceMessageControllerAdvice {
"error_code", exception.getErrorCode(),
"message", exception.getMessage()));
}
/**
* 处理 SourceMessage 摘要接口登录或权限不足异常,避免泄漏内部鉴权细节。
*/
@ExceptionHandler(FrontendAuthorizationException.class)
public ResponseEntity<Map<String, Object>> handleFrontendAuthorizationException(
FrontendAuthorizationException exception) {
return ResponseEntity.status(exception.getStatus())
.body(Map.of(
"error_code", exception.getErrorCode(),
"message", exception.getMessage()));
}
}

View File

@@ -0,0 +1,19 @@
package cn.nianxx.thhotel.platform.security.service;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
/**
* 前端业务接口强制鉴权服务。用于普通业务页面接口的登录和权限边界,不复用管理后台错误码。
*/
public interface FrontendAuthorizationService {
/**
* 要求当前请求已登录,并返回当前用户上下文。
*/
AuthenticatedUserContext requireLogin();
/**
* 要求当前请求用户拥有指定业务权限码。
*/
AuthenticatedUserContext requirePermission(String permissionCode);
}

View File

@@ -0,0 +1,26 @@
package cn.nianxx.thhotel.platform.security.service.impl;
import org.springframework.http.HttpStatus;
/**
* 前端业务接口鉴权受控异常。ControllerAdvice 负责转换为稳定 HTTP 响应。
*/
public class FrontendAuthorizationException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
public FrontendAuthorizationException(HttpStatus status, String errorCode, String message) {
super(message);
this.status = status;
this.errorCode = errorCode;
}
public HttpStatus getStatus() {
return status;
}
public String getErrorCode() {
return errorCode;
}
}

View File

@@ -0,0 +1,82 @@
package cn.nianxx.thhotel.platform.security.service.impl;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.platform.security.service.CurrentUserContextService;
import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
/**
* 前端业务接口强制鉴权服务实现。复用可选 token 解析结果,并返回业务接口专用错误码。
*/
@Service
public class FrontendAuthorizationServiceImpl implements FrontendAuthorizationService {
private final CurrentUserContextService currentUserContextService;
private final HttpServletRequest request;
/**
* 注入当前用户上下文服务和原始请求,用于区分缺少 token 与 token 失效。
*/
public FrontendAuthorizationServiceImpl(
CurrentUserContextService currentUserContextService,
HttpServletRequest request) {
this.currentUserContextService = currentUserContextService;
this.request = request;
}
/**
* 要求当前请求已登录;没有有效 token 时返回前端业务接口专用 401。
*/
@Override
public AuthenticatedUserContext requireLogin() {
return currentUserContextService.currentUser()
.orElseThrow(this::missingOrInvalidLogin);
}
/**
* 要求当前请求用户拥有指定业务权限码;已登录但无权限时返回 403。
*/
@Override
public AuthenticatedUserContext requirePermission(String permissionCode) {
AuthenticatedUserContext context = requireLogin();
if (permissionCode == null || permissionCode.isBlank()
|| !context.permissionCodes().contains(permissionCode)) {
throw new FrontendAuthorizationException(
HttpStatus.FORBIDDEN,
"FRONTEND_PERMISSION_DENIED",
"当前用户没有访问该业务能力的权限。");
}
return context;
}
/**
* 根据 Authorization 头判断是未登录还是登录态失效,便于前端做统一提示。
*/
private FrontendAuthorizationException missingOrInvalidLogin() {
String authorizationHeader = request.getHeader("Authorization");
if (hasBearerToken(authorizationHeader)) {
return new FrontendAuthorizationException(
HttpStatus.UNAUTHORIZED,
"AUTH_SESSION_INVALID",
"登录已失效,请重新登录。");
}
return new FrontendAuthorizationException(
HttpStatus.UNAUTHORIZED,
"AUTH_TOKEN_REQUIRED",
"请先登录后再访问该业务能力。");
}
/**
* 判断请求是否提供了非空 Bearer token。
*/
private boolean hasBearerToken(String authorizationHeader) {
if (authorizationHeader == null || authorizationHeader.isBlank()) {
return false;
}
String prefix = "Bearer ";
return authorizationHeader.regionMatches(true, 0, prefix, 0, prefix.length())
&& !authorizationHeader.substring(prefix.length()).trim().isBlank();
}
}

View File

@@ -1,5 +1,7 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOrderListQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult;
@@ -21,12 +23,16 @@ import org.springframework.web.bind.annotation.RestController;
public class ReservationFrontendQueryController {
private final ReservationFrontendQueryService frontendQueryService;
private final FrontendAuthorizationService authorizationService;
/**
* 注入前端查询服务Controller 只负责 HTTP 参数到查询对象的转换。
*/
public ReservationFrontendQueryController(ReservationFrontendQueryService frontendQueryService) {
public ReservationFrontendQueryController(
ReservationFrontendQueryService frontendQueryService,
FrontendAuthorizationService authorizationService) {
this.frontendQueryService = frontendQueryService;
this.authorizationService = authorizationService;
}
/**
@@ -44,6 +50,7 @@ public class ReservationFrontendQueryController {
@RequestParam(required = false) String keyword,
@RequestParam(name = "page_num", required = false) Integer pageNum,
@RequestParam(name = "page_size", required = false) Integer pageSize) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name());
return frontendQueryService.queryTaskWorkbench(new ReservationTaskWorkbenchQueryRequest(
hotelId,
orderId,
@@ -69,6 +76,7 @@ public class ReservationFrontendQueryController {
@RequestParam(required = false) String keyword,
@RequestParam(name = "page_num", required = false) Integer pageNum,
@RequestParam(name = "page_size", required = false) Integer pageSize) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_ORDER_READ.name());
return frontendQueryService.queryOrders(new ReservationOrderListQueryRequest(
hotelId,
orderStatus,
@@ -88,6 +96,7 @@ public class ReservationFrontendQueryController {
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(name = "include_tasks", required = false) Boolean includeTasks,
@RequestParam(name = "include_source_summary", required = false) Boolean includeSourceSummary) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_ORDER_READ.name());
return frontendQueryService.getOrderDetail(hotelId, orderId, includeTasks, includeSourceSummary);
}
}

View File

@@ -1,5 +1,7 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService;
import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewResolutionRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest;
@@ -28,12 +30,16 @@ import org.springframework.web.bind.annotation.RestController;
public class ReservationTaskController {
private final ReservationTaskWorkflowService taskWorkflowService;
private final FrontendAuthorizationService authorizationService;
/**
* 注入任务工作流服务Controller 不直接访问 Mapper 或 Repository。
*/
public ReservationTaskController(ReservationTaskWorkflowService taskWorkflowService) {
public ReservationTaskController(
ReservationTaskWorkflowService taskWorkflowService,
FrontendAuthorizationService authorizationService) {
this.taskWorkflowService = taskWorkflowService;
this.authorizationService = authorizationService;
}
/**
@@ -41,6 +47,7 @@ public class ReservationTaskController {
*/
@GetMapping(value = "/{taskId}", produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationTaskDetailResult detail(@PathVariable Long taskId) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name());
return taskWorkflowService.getTaskDetail(taskId);
}
@@ -129,6 +136,7 @@ public class ReservationTaskController {
*/
@GetMapping(value = "/{taskId}/audits", produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationTaskAuditListResult listAudits(@PathVariable Long taskId) {
authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_AUDIT_READ.name());
return taskWorkflowService.listTaskAudits(taskId);
}
}

View File

@@ -1,7 +1,9 @@
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.ReservationTaskWorkflowException;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -28,4 +30,17 @@ public class ReservationTaskControllerAdvice {
exception.getMessage(),
exception.getDetails()));
}
/**
* 处理前端业务接口登录或权限不足异常,保持 Reservation 错误响应结构稳定。
*/
@ExceptionHandler(FrontendAuthorizationException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleFrontendAuthorizationException(
FrontendAuthorizationException exception) {
return ResponseEntity.status(exception.getStatus())
.body(new ReservationWorkflowErrorResponse(
exception.getErrorCode(),
exception.getMessage(),
List.of()));
}
}

View File

@@ -400,6 +400,17 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
return Optional.ofNullable(entity).map(this::toAiQueryOrderSnapshot);
}
/**
* 按订单 ID 查询前端详情快照。该方法不带酒店条件,仅供读取后再做酒店访问权校验。
*/
@Override
public Optional<ReservationAiQueryOrderSnapshot> findAiQueryOrderById(Long orderId) {
if (orderId == null) {
return Optional.empty();
}
return Optional.ofNullable(orderMapper.selectById(orderId)).map(this::toAiQueryOrderSnapshot);
}
/**
* 按订单 ID 批量查询订单快照,供前端任务列表补充订单展示键。
*/

View File

@@ -116,6 +116,11 @@ public interface ReservationAiWorkflowRepository {
*/
Optional<ReservationAiQueryOrderSnapshot> findAiQueryOrderById(String hotelId, Long orderId);
/**
* 按订单 ID 查询前端详情使用的订单快照,调用方必须在读取后按订单实际酒店做访问权校验。
*/
Optional<ReservationAiQueryOrderSnapshot> findAiQueryOrderById(Long orderId);
/**
* 按订单 ID 批量查询订单快照,供前端列表补充展示字段。
*/

View File

@@ -144,9 +144,8 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
Long orderId,
Boolean includeTasks,
Boolean includeSourceSummary) {
String normalizedHotelId = normalizeHotelId(hotelId);
ReservationAiQueryOrderSnapshot order = workflowRepository
.findAiQueryOrderById(normalizedHotelId, orderId)
.findAiQueryOrderById(orderId)
.orElseThrow(() -> new ReservationTaskWorkflowException(
HttpStatus.NOT_FOUND,
"ORDER_NOT_FOUND",
@@ -157,14 +156,15 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
"ORDER_NOT_FOUND",
"订单不存在。");
}
String orderHotelId = requireOrderHotelAccess(order, hotelId);
List<ReservationAiQueryTaskSnapshot> taskSnapshots = Boolean.FALSE.equals(includeTasks)
? List.of()
: workflowRepository.findAiQueryTasksByOrderIds(normalizedHotelId, List.of(order.id()));
: workflowRepository.findAiQueryTasksByOrderIds(orderHotelId, List.of(order.id()));
Map<Long, ReservationTaskAvailabilityResult> availabilityByTaskId = calculateAvailabilityByTaskId(
taskSnapshots,
taskSnapshots);
Map<Long, SourceMessageDisplayContext> sourceContextsById = findSourceContextsById(
normalizedHotelId,
orderHotelId,
taskSnapshots);
List<ReservationOrderTaskTimelineItemResult> tasks = taskSnapshots.stream()
.map(task -> toTimelineItem(
@@ -567,6 +567,29 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
return order != null && ReservationOrderVisibility.HIDDEN_SYSTEM.name().equals(order.orderVisibility());
}
/**
* 按订单实际归属酒店校验当前用户访问权。详情接口不能只按请求 hotel_id 过滤,否则跨酒店 ID 会被误判为不存在。
*/
private String requireOrderHotelAccess(ReservationAiQueryOrderSnapshot order, String requestedHotelId) {
try {
String normalizedRequestedHotelId = trimToNull(requestedHotelId);
String hotelIdToCheck = normalizedRequestedHotelId == null ? order.hotelId() : normalizedRequestedHotelId;
String resolvedHotelId = hotelContextService.requireAccessibleHotel(hotelIdToCheck);
if (!Objects.equals(order.hotelId(), resolvedHotelId)) {
throw new ReservationTaskWorkflowException(
HttpStatus.FORBIDDEN,
"HOTEL_ACCESS_DENIED",
"当前用户无权访问该酒店数据。");
}
return order.hotelId();
} catch (HotelContextException exception) {
throw new ReservationTaskWorkflowException(
exception.getStatus(),
exception.getErrorCode(),
exception.getMessage());
}
}
/**
* 标准化酒店 ID。前端可不传酒店后端按当前用户上下文或单酒店系统上下文解析。
*/

View File

@@ -1,6 +1,8 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft;
@@ -95,6 +97,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
private final ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider;
private final ReservationTaskAvailabilityResolver availabilityResolver;
private final SourceMessageQueryService sourceMessageQueryService;
private final HotelContextService hotelContextService;
private final ObjectMapper objectMapper;
/**
@@ -105,11 +108,13 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider,
ReservationTaskAvailabilityResolver availabilityResolver,
SourceMessageQueryService sourceMessageQueryService,
HotelContextService hotelContextService,
ObjectMapper objectMapper) {
this.workflowRepository = workflowRepository;
this.fieldDefinitionProvider = fieldDefinitionProvider;
this.availabilityResolver = availabilityResolver;
this.sourceMessageQueryService = sourceMessageQueryService;
this.hotelContextService = hotelContextService;
this.objectMapper = objectMapper;
}
@@ -120,6 +125,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
@Transactional(readOnly = true)
public ReservationTaskDetailResult getTaskDetail(Long taskId) {
ReservationTaskSnapshot task = findTaskOrThrow(taskId);
requireTaskHotelAccess(task);
ReservationTaskCardSnapshot taskCard = workflowRepository
.findTaskCardByTaskId(task.hotelId(), task.id())
.orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TASK_CARD_NOT_FOUND", "任务卡不存在。"));
@@ -403,6 +409,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
@Transactional(readOnly = true)
public ReservationTaskAuditListResult listTaskAudits(Long taskId) {
ReservationTaskSnapshot task = findTaskOrThrow(taskId);
requireTaskHotelAccess(task);
List<ReservationTaskAuditLogResult> items = workflowRepository
.findAuditLogsByTaskId(task.hotelId(), task.id())
.stream()
@@ -753,6 +760,20 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
.orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TASK_NOT_FOUND", "任务不存在。"));
}
/**
* 按任务实际归属酒店校验当前用户访问权,避免跨酒店按任务 ID 读取详情或审计。
*/
private void requireTaskHotelAccess(ReservationTaskSnapshot task) {
try {
hotelContextService.requireAccessibleHotel(task.hotelId());
} catch (HotelContextException exception) {
throw new ReservationTaskWorkflowException(
exception.getStatus(),
exception.getErrorCode(),
exception.getMessage());
}
}
/**
* 查询任务卡,不存在时抛出受控 404。
*/