实现 V4 卡片确认和来源通知确认

This commit is contained in:
andy
2026-07-19 10:40:23 +07:00
parent 9404a0e5c1
commit fdb16cc4c1
19 changed files with 1190 additions and 55 deletions

View File

@@ -10,5 +10,6 @@ public enum ReservationV4ReadonlyReasonCode {
PRIOR_CARD_NOT_CONFIRMED,
PRIOR_ORDER_TASK_NOT_COMPLETED,
COMMAND_API_PENDING,
ACK_API_PENDING
ACK_API_PENDING,
REVIEW_API_PENDING
}

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
/**
* Reservation V4 卡片确认请求。confirmed_payload 为空时后端使用当前展示 payload 作为确认快照。
*
* @param version 前端读取到的卡片乐观锁版本
* @param confirmedPayload 用户确认后的卡片 payload
*/
public record ReservationV4CardConfirmRequest(
Long version,
@JsonProperty("confirmed_payload")
JsonNode confirmedPayload
) {
}

View File

@@ -0,0 +1,13 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
/**
* Reservation V4 来源通知确认请求。第一版只要求带上前端读取到的 version。
*
* @param version 来源通知乐观锁版本
* @param reason 可选处理说明,当前只写入审计摘要
*/
public record ReservationV4SourceNotificationAckRequest(
Long version,
String reason
) {
}

View File

@@ -3,7 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reservation V4 查询侧动作可用性。CP5 只读查询阶段不开放写接口,因此 confirmable / ackable 默认由后续 CP 打开
* Reservation V4 查询侧动作可用性。用于前端判断卡片确认、来源通知确认和只读原因
*
* @param blocked 是否被前置订单任务或卡片阻塞
* @param readOnly 当前是否只读

View File

@@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
ReservationTaskController.class,
ReservationFrontendQueryController.class,
ReservationV4QueryController.class,
ReservationV4CommandController.class,
ReservationDemoDataController.class,
ReservationInvoiceGenerationController.class,
ReservationRoomingListGenerationController.class

View File

@@ -0,0 +1,68 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CommandService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Reservation V4 前端写操作 Controller。只暴露卡片确认和来源通知确认入口。
*/
@RestController
@RequestMapping("/api/reservation")
public class ReservationV4CommandController {
private final ReservationV4CommandService commandService;
private final FrontendAuthorizationService authorizationService;
/**
* 注入 V4 命令服务和前端鉴权服务Controller 不直接访问 Repository。
*/
public ReservationV4CommandController(
ReservationV4CommandService commandService,
FrontendAuthorizationService authorizationService) {
this.commandService = commandService;
this.authorizationService = authorizationService;
}
/**
* 确认 V4 订单任务下的一张业务卡Basic Information 未确认时业务卡不可确认。
*/
@PostMapping(
value = "/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4OrderTaskDetailResult confirmTaskCard(
@PathVariable Long orderTaskId,
@PathVariable Long cardId,
@RequestBody(required = false) ReservationV4CardConfirmRequest request) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_TASK_CONFIRM.name());
return commandService.confirmTaskCard(orderTaskId, cardId, request, actor);
}
/**
* 确认 S10/S99 来源通知已读或已处理;不创建订单、不参与订单阻塞。
*/
@PostMapping(
value = "/source-notifications/{notificationId}/ack",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4SourceNotificationDetailResult ackSourceNotification(
@PathVariable Long notificationId,
@RequestBody(required = false) ReservationV4SourceNotificationAckRequest request) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_TASK_CONFIRM.name());
return commandService.ackSourceNotification(notificationId, request, actor);
}
}

View File

@@ -5,6 +5,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTask
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity;
@@ -282,6 +283,51 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work
return updated == 1;
}
/**
* 按 version 乐观锁确认 V4 任务卡,同时写入用户确认 payload、确认人和确认时间。
*/
@Override
public boolean confirmTaskCardWithVersion(
String hotelId,
Long taskCardId,
Long expectedVersion,
String confirmedPayloadJson,
String confirmedBy,
LocalDateTime confirmedAt) {
int updated = taskCardMapper.update(Wrappers.<ReservationV4TaskCardEntity>lambdaUpdate()
.set(ReservationV4TaskCardEntity::getCardStatus, ReservationV4CardStatus.CONFIRMED.name())
.set(ReservationV4TaskCardEntity::getConfirmedPayloadJson, confirmedPayloadJson)
.set(ReservationV4TaskCardEntity::getConfirmedBy, confirmedBy)
.set(ReservationV4TaskCardEntity::getConfirmedAt, confirmedAt)
.set(ReservationV4TaskCardEntity::getUpdatedAt, confirmedAt)
.setSql("version = version + 1")
.eq(ReservationV4TaskCardEntity::getHotelId, hotelId)
.eq(ReservationV4TaskCardEntity::getId, taskCardId)
.eq(ReservationV4TaskCardEntity::getVersion, expectedVersion)
.eq(ReservationV4TaskCardEntity::getCardStatus, ReservationV4CardStatus.PENDING_CONFIRM.name())
.isNull(ReservationV4TaskCardEntity::getLogicDeletedAt));
return updated == 1;
}
/**
* 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。
*/
@Override
public boolean updateOrderTaskStatus(
String hotelId,
Long orderTaskId,
String orderTaskStatus,
LocalDateTime now) {
int updated = orderTaskMapper.update(Wrappers.<ReservationV4OrderTaskEntity>lambdaUpdate()
.set(ReservationV4OrderTaskEntity::getOrderTaskStatus, orderTaskStatus)
.set(ReservationV4OrderTaskEntity::getUpdatedAt, now)
.setSql("version = version + 1")
.eq(ReservationV4OrderTaskEntity::getHotelId, hotelId)
.eq(ReservationV4OrderTaskEntity::getId, orderTaskId)
.isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt));
return updated == 1;
}
private ReservationV4OrderTaskSnapshot toOrderTaskSnapshot(ReservationV4OrderTaskEntity entity) {
return new ReservationV4OrderTaskSnapshot(
entity.getId(),

View File

@@ -86,4 +86,24 @@ public interface ReservationV4WorkflowRepository {
Long expectedVersion,
String cardStatus,
LocalDateTime now);
/**
* 按 version 乐观锁确认 V4 任务卡,同时写入用户确认 payload、确认人和确认时间。
*/
boolean confirmTaskCardWithVersion(
String hotelId,
Long taskCardId,
Long expectedVersion,
String confirmedPayloadJson,
String confirmedBy,
LocalDateTime confirmedAt);
/**
* 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。
*/
boolean updateOrderTaskStatus(
String hotelId,
Long orderTaskId,
String orderTaskStatus,
LocalDateTime now);
}

View File

@@ -0,0 +1,30 @@
package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
/**
* Reservation V4 命令服务。承接卡片确认和 S10/S99 来源通知确认写操作。
*/
public interface ReservationV4CommandService {
/**
* 确认指定 V4 任务卡,并返回刷新后的订单任务详情。
*/
ReservationV4OrderTaskDetailResult confirmTaskCard(
Long orderTaskId,
Long cardId,
ReservationV4CardConfirmRequest request,
AuthenticatedUserContext actor);
/**
* 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。
*/
ReservationV4SourceNotificationDetailResult ackSourceNotification(
Long notificationId,
ReservationV4SourceNotificationAckRequest request,
AuthenticatedUserContext actor);
}

View File

@@ -0,0 +1,408 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CommandService;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Reservation V4 命令服务实现。负责卡片确认、来源通知确认、状态刷新和业务审计。
*/
@Service
public class ReservationV4CommandServiceImpl implements ReservationV4CommandService {
private static final String ACTOR_TYPE_USER = "USER";
private static final String ACTION_V4_CARD_CONFIRM = "V4_CARD_CONFIRM";
private static final String ACTION_V4_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK";
private final ReservationV4WorkflowRepository workflowRepository;
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
private final ReservationAiWorkflowRepository auditRepository;
private final ReservationV4QueryService queryService;
private final HotelContextService hotelContextService;
private final ObjectMapper objectMapper;
/**
* 注入 V4 Repository、查询服务、酒店上下文和审计边界命令服务不直接依赖 Mapper。
*/
public ReservationV4CommandServiceImpl(
ReservationV4WorkflowRepository workflowRepository,
ReservationV4SourceNotificationRepository sourceNotificationRepository,
ReservationAiWorkflowRepository auditRepository,
ReservationV4QueryService queryService,
HotelContextService hotelContextService,
ObjectMapper objectMapper) {
this.workflowRepository = workflowRepository;
this.sourceNotificationRepository = sourceNotificationRepository;
this.auditRepository = auditRepository;
this.queryService = queryService;
this.hotelContextService = hotelContextService;
this.objectMapper = objectMapper;
}
/**
* 确认指定 V4 任务卡,并返回刷新后的订单任务详情。
*/
@Override
@Transactional
public ReservationV4OrderTaskDetailResult confirmTaskCard(
Long orderTaskId,
Long cardId,
ReservationV4CardConfirmRequest request,
AuthenticatedUserContext actor) {
Long expectedVersion = requireVersion(request == null ? null : request.version());
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
ReservationV4OrderTaskSnapshot orderTask = requireOrderTask(orderTaskId);
requireHotelAccess(orderTask.hotelId());
ReservationV4TaskCardSnapshot card = requireTaskCard(orderTask.hotelId(), cardId, orderTask.id());
validateCardConfirmable(orderTask, card);
ensureNoPriorOrderTaskBlocking(orderTask);
ensureBasicInformationConfirmed(orderTask, card);
String confirmedPayloadJson = confirmedPayloadJson(card, request == null ? null : request.confirmedPayload());
String actorId = actorIdentifier(actor);
boolean updated = workflowRepository.confirmTaskCardWithVersion(
orderTask.hotelId(),
card.id(),
expectedVersion,
confirmedPayloadJson,
actorId,
now);
if (!updated) {
handleCardConfirmRace(orderTask.hotelId(), card.id());
}
refreshOrderTaskStatus(orderTask, now);
writeCardConfirmAudit(orderTask, card, actorId, confirmedPayloadJson, now);
return queryService.getOrderTaskDetail(orderTask.hotelId(), orderTask.id());
}
/**
* 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。
*/
@Override
@Transactional
public ReservationV4SourceNotificationDetailResult ackSourceNotification(
Long notificationId,
ReservationV4SourceNotificationAckRequest request,
AuthenticatedUserContext actor) {
Long expectedVersion = requireVersion(request == null ? null : request.version());
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
ReservationV4SourceNotificationSnapshot notification = requireSourceNotification(notificationId);
requireHotelAccess(notification.hotelId());
if (ReservationV4NotificationStatus.ACKED.name().equals(notification.notificationStatus())) {
return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id());
}
if (!ReservationV4NotificationStatus.ACK_REQUIRED.name().equals(notification.notificationStatus())) {
throw error(HttpStatus.CONFLICT, "V4_SOURCE_NOTIFICATION_NOT_ACKABLE", "当前来源通知状态不允许确认。");
}
String actorId = actorIdentifier(actor);
boolean updated = sourceNotificationRepository.updateNotificationStatusWithVersion(
notification.hotelId(),
notification.id(),
expectedVersion,
ReservationV4NotificationStatus.ACKED.name(),
actorId,
now);
if (!updated) {
ReservationV4SourceNotificationSnapshot latest = requireSourceNotification(notificationId);
if (ReservationV4NotificationStatus.ACKED.name().equals(latest.notificationStatus())) {
return queryService.getSourceNotificationDetail(latest.hotelId(), latest.id());
}
throw error(HttpStatus.CONFLICT, "V4_SOURCE_NOTIFICATION_VERSION_CONFLICT", "来源通知版本已变化,请刷新后重试。");
}
writeSourceNotificationAckAudit(notification, actorId, request == null ? null : request.reason(), now);
return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id());
}
private void validateCardConfirmable(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card) {
if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) {
throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_CONFIRMABLE", "来源邮件展示卡不允许确认。");
}
if (ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())) {
throw error(HttpStatus.CONFLICT, "V4_CARD_ALREADY_CONFIRMED", "该卡片已经确认,不能重复确认。");
}
if (ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) {
throw error(HttpStatus.CONFLICT, "V4_CARD_REVIEW_REQUIRED", "该卡片需要先完成人工复核。");
}
if (!ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) {
throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_CONFIRMABLE", "当前卡片状态不允许确认。");
}
if (ReservationV4OrderTaskStatus.COMPLETED.name().equals(orderTask.orderTaskStatus())) {
throw error(HttpStatus.CONFLICT, "V4_ORDER_TASK_ALREADY_COMPLETED", "该 V4 订单任务已经完成。");
}
}
private void ensureBasicInformationConfirmed(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card) {
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
return;
}
ReservationV4TaskCardSnapshot basicCard = workflowRepository.findTaskCardsByOrderTaskId(
orderTask.hotelId(),
orderTask.id()).stream()
.filter(candidate -> ReservationV4CardType.BASIC_INFORMATION.name().equals(candidate.cardType()))
.findFirst()
.orElseThrow(() -> error(
HttpStatus.CONFLICT,
"V4_BASIC_INFORMATION_MISSING",
"V4 订单任务缺少 Basic Information 卡。"));
if (!ReservationV4CardStatus.CONFIRMED.name().equals(basicCard.cardStatus())) {
throw error(
HttpStatus.CONFLICT,
"V4_BASIC_INFORMATION_NOT_CONFIRMED",
"Basic Information 卡未确认前,业务卡不可确认。");
}
}
private void ensureNoPriorOrderTaskBlocking(ReservationV4OrderTaskSnapshot orderTask) {
if (orderTask.orderId() == null) {
return;
}
List<ReservationV4OrderTaskSnapshot> sameOrderTasks = workflowRepository.findOrderTasksByOrderIds(
orderTask.hotelId(),
List.of(orderTask.orderId()));
for (ReservationV4OrderTaskSnapshot candidate : sameOrderTasks) {
if (Objects.equals(candidate.id(), orderTask.id())) {
return;
}
if (!ReservationV4OrderTaskStatus.COMPLETED.name().equals(candidate.orderTaskStatus())) {
throw error(
HttpStatus.CONFLICT,
"V4_PRIOR_ORDER_TASK_NOT_COMPLETED",
"同一本地订单下存在更早未完成的 V4 订单任务。");
}
}
}
private void refreshOrderTaskStatus(ReservationV4OrderTaskSnapshot orderTask, LocalDateTime now) {
List<ReservationV4TaskCardSnapshot> cards = workflowRepository.findTaskCardsByOrderTaskId(
orderTask.hotelId(),
orderTask.id());
boolean hasUnfinishedCard = cards.stream()
.filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType()))
.anyMatch(card -> !ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus()));
String nextStatus = hasUnfinishedCard
? ReservationV4OrderTaskStatus.OPEN.name()
: ReservationV4OrderTaskStatus.COMPLETED.name();
if (!Objects.equals(orderTask.orderTaskStatus(), nextStatus)) {
boolean updated = workflowRepository.updateOrderTaskStatus(orderTask.hotelId(), orderTask.id(), nextStatus, now);
if (!updated) {
throw error(
HttpStatus.CONFLICT,
"V4_ORDER_TASK_STATUS_REFRESH_FAILED",
"V4 订单任务状态刷新失败,请刷新后重试。");
}
}
}
private void handleCardConfirmRace(String hotelId, Long cardId) {
ReservationV4TaskCardSnapshot latest = workflowRepository.findTaskCardById(hotelId, cardId)
.orElseThrow(() -> notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。"));
if (ReservationV4CardStatus.CONFIRMED.name().equals(latest.cardStatus())) {
throw error(HttpStatus.CONFLICT, "V4_CARD_ALREADY_CONFIRMED", "该卡片已经确认,不能重复确认。");
}
throw error(HttpStatus.CONFLICT, "V4_CARD_VERSION_CONFLICT", "任务卡版本已变化,请刷新后重试。");
}
private String confirmedPayloadJson(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) {
if (confirmedPayload != null && !confirmedPayload.isNull() && !confirmedPayload.isMissingNode()) {
return toJson(confirmedPayload);
}
if (hasText(card.displayPayloadJson())) {
return card.displayPayloadJson();
}
return "{}";
}
private ReservationV4OrderTaskSnapshot requireOrderTask(Long orderTaskId) {
return workflowRepository.findOrderTaskById(orderTaskId)
.orElseThrow(() -> notFound("V4_ORDER_TASK_NOT_FOUND", "V4 订单任务不存在。"));
}
private ReservationV4TaskCardSnapshot requireTaskCard(String hotelId, Long cardId, Long orderTaskId) {
ReservationV4TaskCardSnapshot card = workflowRepository.findTaskCardById(hotelId, cardId)
.orElseThrow(() -> notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。"));
if (!Objects.equals(orderTaskId, card.v4OrderTaskId())) {
throw notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。");
}
return card;
}
private ReservationV4SourceNotificationSnapshot requireSourceNotification(Long notificationId) {
return sourceNotificationRepository.findSourceNotificationById(notificationId)
.orElseThrow(() -> notFound("V4_SOURCE_NOTIFICATION_NOT_FOUND", "V4 来源通知不存在。"));
}
private void requireHotelAccess(String hotelId) {
try {
hotelContextService.requireAccessibleHotel(hotelId);
} catch (HotelContextException exception) {
throw new ReservationTaskWorkflowException(
exception.getStatus(),
exception.getErrorCode(),
exception.getMessage());
}
}
private Long requireVersion(Long version) {
if (version == null) {
throw error(HttpStatus.BAD_REQUEST, "V4_VERSION_REQUIRED", "请求必须携带当前 version。");
}
if (version < 0) {
throw error(HttpStatus.BAD_REQUEST, "V4_VERSION_INVALID", "version 不能小于 0。");
}
return version;
}
private String actorIdentifier(AuthenticatedUserContext actor) {
if (actor == null) {
return "unknown-user";
}
if (hasText(actor.username())) {
return actor.username();
}
return actor.userId() == null ? "unknown-user" : actor.userId().toString();
}
private void writeCardConfirmAudit(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
String actorId,
String confirmedPayloadJson,
LocalDateTime now) {
Map<String, Object> beforeSnapshot = new LinkedHashMap<>();
beforeSnapshot.put("v4_order_task_id", orderTask.id().toString());
beforeSnapshot.put("v4_task_card_id", card.id().toString());
beforeSnapshot.put("card_type", card.cardType());
beforeSnapshot.put("card_status", card.cardStatus());
beforeSnapshot.put("version", card.version());
beforeSnapshot.put("has_confirmed_payload", hasText(card.confirmedPayloadJson()));
Map<String, Object> afterSnapshot = new LinkedHashMap<>();
afterSnapshot.put("v4_order_task_id", orderTask.id().toString());
afterSnapshot.put("v4_task_card_id", card.id().toString());
afterSnapshot.put("card_type", card.cardType());
afterSnapshot.put("card_status", ReservationV4CardStatus.CONFIRMED.name());
afterSnapshot.put("confirmed_by", actorId);
afterSnapshot.put("confirmed_payload_keys", payloadFieldNames(confirmedPayloadJson));
auditRepository.insertAuditLog(new ReservationAuditLogDraft(
orderTask.hotelId(),
orderTask.orderId(),
null,
null,
ACTOR_TYPE_USER,
actorId,
ACTION_V4_CARD_CONFIRM,
null,
toJson(beforeSnapshot),
toJson(afterSnapshot),
now));
}
private void writeSourceNotificationAckAudit(
ReservationV4SourceNotificationSnapshot notification,
String actorId,
String reason,
LocalDateTime now) {
Map<String, Object> beforeSnapshot = new LinkedHashMap<>();
beforeSnapshot.put("v4_source_notification_id", notification.id().toString());
beforeSnapshot.put("route_code", notification.routeCode());
beforeSnapshot.put("notification_status", notification.notificationStatus());
beforeSnapshot.put("version", notification.version());
Map<String, Object> afterSnapshot = new LinkedHashMap<>();
afterSnapshot.put("v4_source_notification_id", notification.id().toString());
afterSnapshot.put("route_code", notification.routeCode());
afterSnapshot.put("notification_status", ReservationV4NotificationStatus.ACKED.name());
afterSnapshot.put("ack_by", actorId);
auditRepository.insertAuditLog(new ReservationAuditLogDraft(
notification.hotelId(),
null,
null,
null,
ACTOR_TYPE_USER,
actorId,
ACTION_V4_SOURCE_NOTIFICATION_ACK,
trimToNull(reason),
toJson(beforeSnapshot),
toJson(afterSnapshot),
now));
}
private List<String> payloadFieldNames(String json) {
if (!hasText(json)) {
return List.of();
}
try {
JsonNode node = objectMapper.readTree(json);
if (node == null || !node.isObject()) {
return List.of();
}
List<String> fieldNames = new ArrayList<>();
Iterator<String> iterator = node.fieldNames();
while (iterator.hasNext()) {
fieldNames.add(iterator.next());
}
return fieldNames;
} catch (Exception exception) {
return List.of();
}
}
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception exception) {
return "{}";
}
}
private ReservationTaskWorkflowException notFound(String errorCode, String message) {
return new ReservationTaskWorkflowException(HttpStatus.NOT_FOUND, errorCode, message);
}
private ReservationTaskWorkflowException error(HttpStatus status, String errorCode, String message) {
return new ReservationTaskWorkflowException(status, errorCode, message);
}
private boolean hasText(String value) {
return trimToNull(value) != null;
}
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}

View File

@@ -423,14 +423,14 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
}
return new ReservationV4ActionAvailabilityResult(
false,
false,
true,
true,
false,
false,
false,
ReservationV4ReadonlyReasonCode.COMMAND_API_PENDING.name(),
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
null,
null,
"V4 卡片确认和复核写接口将在后续 checkpoint 开放。");
null);
}
private ReservationV4ActionAvailabilityResult cardAvailability(
@@ -467,18 +467,29 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
if (ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())) {
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
}
if (ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())
|| ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) {
if (ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) {
return new ReservationV4ActionAvailabilityResult(
false,
false,
true,
true,
false,
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
null,
null,
null);
}
if (ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) {
return new ReservationV4ActionAvailabilityResult(
false,
true,
false,
false,
false,
ReservationV4ReadonlyReasonCode.COMMAND_API_PENDING.name(),
ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(),
null,
null,
"V4 卡片确认和复核写接口将在后续 checkpoint 开放。");
"V4 人工复核写接口将在后续 checkpoint 开放。");
}
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
}
@@ -489,15 +500,15 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
}
return new ReservationV4ActionAvailabilityResult(
false,
false,
false,
false,
true,
false,
false,
false,
ReservationV4ReadonlyReasonCode.ACK_API_PENDING.name(),
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
null,
null,
"V4 来源通知确认写接口将在后续 checkpoint 开放。");
null);
}
private ReservationV4ActionAvailabilityResult readOnlyAvailability(