实现M002 V4持久化基线

This commit is contained in:
andy
2026-07-19 00:12:40 +07:00
parent 3d9055dd6b
commit e6fbd7a111
29 changed files with 1563 additions and 33 deletions

View File

@@ -0,0 +1,36 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* Reservation V4 订单任务创建草稿。用于按 source_message_id + order_ref 幂等创建订单任务聚合。
*/
public record ReservationV4OrderTaskDraft(
/** 酒店 ID。 */
String hotelId,
/** SourceMessage Inbox 内部 ID。 */
Long sourceMessageId,
/** AI 回调批次 ID。 */
Long aiBatchId,
/** V4 包内订单引用。 */
String orderRef,
/** order_contexts[] 中的一基序号。 */
Integer orderContextIndex,
/** 绑定的本地订单投影 ID归属未解决时可为空。 */
Long orderId,
/** 目标订单业务类型,例如 GROUP、FIT。 */
String targetBookingType,
/** 目标订单定位类型。 */
String targetLocatorType,
/** 目标订单定位值。 */
String targetLocatorValue,
/** 订单归属解析状态。 */
String targetResolutionStatus,
/** 订单任务状态快照。 */
String orderTaskStatus,
/** 来源邮件接收 UTC 时间。 */
LocalDateTime sourceReceivedAt,
/** 当前写入 UTC 时间。 */
LocalDateTime now
) {
}

View File

@@ -0,0 +1,42 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* Reservation V4 订单任务快照。屏蔽 Entity 细节供 Service 和后续查询接口使用。
*/
public record ReservationV4OrderTaskSnapshot(
/** V4 订单任务 ID。 */
Long id,
/** 酒店 ID。 */
String hotelId,
/** SourceMessage Inbox 内部 ID。 */
Long sourceMessageId,
/** AI 回调批次 ID。 */
Long aiBatchId,
/** V4 包内订单引用。 */
String orderRef,
/** order_contexts[] 中的一基序号。 */
Integer orderContextIndex,
/** 绑定的本地订单投影 ID归属未解决时可为空。 */
Long orderId,
/** 目标订单业务类型。 */
String targetBookingType,
/** 目标订单定位类型。 */
String targetLocatorType,
/** 目标订单定位值。 */
String targetLocatorValue,
/** 订单归属解析状态。 */
String targetResolutionStatus,
/** 订单任务状态快照。 */
String orderTaskStatus,
/** 来源邮件接收 UTC 时间。 */
LocalDateTime sourceReceivedAt,
/** 乐观锁版本。 */
Long version,
/** 记录创建 UTC 时间。 */
LocalDateTime createdAt,
/** 记录更新 UTC 时间。 */
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,28 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* Reservation V4 来源通知创建草稿。第一版用于 S10 纯通知入库。
*/
public record ReservationV4SourceNotificationDraft(
/** 酒店 ID。 */
String hotelId,
/** SourceMessage Inbox 内部 ID。 */
Long sourceMessageId,
/** AI 回调批次 ID。 */
Long aiBatchId,
/** AI transition IDS10 第一版可为空。 */
Long aiTransitionId,
/** 包级路由码,第一版用于 S10。 */
String routeCode,
/** 通知状态。 */
String notificationStatus,
/** 来源通知原始 AI 片段或包级摘要 JSON。 */
String rawPayloadJson,
/** 来源邮件接收 UTC 时间。 */
LocalDateTime sourceReceivedAt,
/** 当前写入 UTC 时间。 */
LocalDateTime now
) {
}

View File

@@ -0,0 +1,38 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* Reservation V4 来源通知快照。用于后续 S10 通知详情和确认接口。
*/
public record ReservationV4SourceNotificationSnapshot(
/** V4 来源通知 ID。 */
Long id,
/** 酒店 ID。 */
String hotelId,
/** SourceMessage Inbox 内部 ID。 */
Long sourceMessageId,
/** AI 回调批次 ID。 */
Long aiBatchId,
/** AI transition IDS10 第一版可为空。 */
Long aiTransitionId,
/** 包级路由码。 */
String routeCode,
/** 通知状态。 */
String notificationStatus,
/** 来源通知原始 AI 片段或包级摘要 JSON。 */
String rawPayloadJson,
/** 确认处理人标识。 */
String ackBy,
/** 确认处理 UTC 时间。 */
LocalDateTime ackAt,
/** 来源邮件接收 UTC 时间。 */
LocalDateTime sourceReceivedAt,
/** 乐观锁版本。 */
Long version,
/** 记录创建 UTC 时间。 */
LocalDateTime createdAt,
/** 记录更新 UTC 时间。 */
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,38 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* Reservation V4 任务卡创建草稿。非 event 卡 source_event_index 允许为空,由 Repository 固定写入 0。
*/
public record ReservationV4TaskCardDraft(
/** 酒店 ID。 */
String hotelId,
/** V4 订单任务 ID。 */
Long v4OrderTaskId,
/** SourceMessage Inbox 内部 ID。 */
Long sourceMessageId,
/** AI transition ID非 event 卡可为空。 */
Long aiTransitionId,
/** 卡片类型。 */
String cardType,
/** 业务事件类型,非 event 卡为空。 */
String eventType,
/** message_events[] 中的一基序号;非 event 卡为空并写入 0。 */
Integer sourceEventIndex,
/** 同一订单任务内卡片排序。 */
Integer cardSortOrder,
/** 卡片状态。 */
String cardStatus,
/** 人工复核状态,非复核卡为空。 */
String reviewStatus,
/** AI 原始卡片 payload JSON 快照。 */
String aiPayloadJson,
/** 前端展示用 payload JSON 快照。 */
String displayPayloadJson,
/** 第一版后端校验错误 JSON。 */
String validationErrorsJson,
/** 当前写入 UTC 时间。 */
LocalDateTime now
) {
}

View File

@@ -0,0 +1,52 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDateTime;
/**
* Reservation V4 任务卡快照。保存 AI 原始卡、展示卡、确认卡和复核结果的当前数据库视图。
*/
public record ReservationV4TaskCardSnapshot(
/** V4 任务卡 ID。 */
Long id,
/** 酒店 ID。 */
String hotelId,
/** V4 订单任务 ID。 */
Long v4OrderTaskId,
/** SourceMessage Inbox 内部 ID。 */
Long sourceMessageId,
/** AI transition ID非 event 卡可为空。 */
Long aiTransitionId,
/** 卡片类型。 */
String cardType,
/** 业务事件类型,非 event 卡为空。 */
String eventType,
/** message_events[] 中的一基序号;非 event 卡固定为 0。 */
Integer sourceEventIndex,
/** 同一订单任务内卡片排序。 */
Integer cardSortOrder,
/** 卡片状态。 */
String cardStatus,
/** 人工复核状态,非复核卡为空。 */
String reviewStatus,
/** AI 原始卡片 payload JSON 快照。 */
String aiPayloadJson,
/** 前端展示用 payload JSON 快照。 */
String displayPayloadJson,
/** 用户最终确认 payload JSON。 */
String confirmedPayloadJson,
/** 人工复核解阻结果 JSON。 */
String reviewResolutionJson,
/** 第一版后端校验错误 JSON。 */
String validationErrorsJson,
/** 最终确认人标识。 */
String confirmedBy,
/** 最终确认 UTC 时间。 */
LocalDateTime confirmedAt,
/** 乐观锁版本。 */
Long version,
/** 记录创建 UTC 时间。 */
LocalDateTime createdAt,
/** 记录更新 UTC 时间。 */
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,15 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* Reservation V4 任务卡状态。
*/
public enum ReservationV4CardStatus {
/** 只读展示卡,不允许用户编辑或确认。 */
READONLY,
/** 待用户确认的业务卡。 */
PENDING_CONFIRM,
/** 待人工复核解阻的业务卡。 */
REVIEW_REQUIRED,
/** 已由用户确认完成的业务卡。 */
CONFIRMED
}

View File

@@ -0,0 +1,19 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* Reservation V4 任务卡类型。
*/
public enum ReservationV4CardType {
/** 来源邮件展示卡。 */
SOURCE_MESSAGE_DISPLAY,
/** 订单级 Basic Information 卡。 */
BASIC_INFORMATION,
/** 房型、房晚和房数等客房信息卡。 */
ROOM_INFORMATION,
/** 预订备注追踪卡。 */
TRACE_RESERVATION_NOTES,
/** Rooming List 业务卡。 */
ROOMING_LIST,
/** Payment 业务卡。 */
PAYMENT
}

View File

@@ -0,0 +1,11 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* Reservation V4 来源通知状态。
*/
public enum ReservationV4NotificationStatus {
/** 需要用户确认已读或已处理。 */
ACK_REQUIRED,
/** 用户已经确认处理。 */
ACKED
}

View File

@@ -0,0 +1,11 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* Reservation V4 订单任务状态快照。
*/
public enum ReservationV4OrderTaskStatus {
/** 订单任务仍有待处理卡片。 */
OPEN,
/** 订单任务下所有需要处理的卡片均已完成。 */
COMPLETED
}

View File

@@ -0,0 +1,13 @@
package cn.nianxx.thhotel.workflows.reservation.common.enums;
/**
* Reservation V4 订单归属解析状态。
*/
public enum ReservationV4TargetResolutionStatus {
/** 已明确匹配到目标订单或可创建的新订单上下文。 */
RESOLVED,
/** 缺少足够信息,等待人工确认订单归属。 */
UNRESOLVED,
/** 匹配到多个候选订单,需要人工处理冲突。 */
CONFLICT
}

View File

@@ -0,0 +1,88 @@
package cn.nianxx.thhotel.workflows.reservation.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
/**
* Reservation V4 订单任务实体。承载同一 SourceMessage + order_ref 下的订单任务聚合。
*/
@TableName("workflow_reservation_v4_order_task")
public class ReservationV4OrderTaskEntity {
/** V4 订单任务 ID。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 酒店 ID。 */
private String hotelId;
/** SourceMessage Inbox 内部 ID。 */
private Long sourceMessageId;
/** AI 回调批次 ID。 */
private Long aiBatchId;
/** V4 包内订单引用。 */
private String orderRef;
/** order_contexts[] 中的一基序号。 */
private Integer orderContextIndex;
/** 绑定的本地订单投影 ID归属未解决时可为空。 */
private Long orderId;
/** 目标订单业务类型,例如 GROUP、FIT。 */
private String targetBookingType;
/** 目标订单定位类型。 */
private String targetLocatorType;
/** 目标订单定位值。 */
private String targetLocatorValue;
/** 订单归属解析状态。 */
private String targetResolutionStatus;
/** 订单任务状态快照。 */
private String orderTaskStatus;
/** 来源邮件接收 UTC 时间。 */
private LocalDateTime sourceReceivedAt;
/** 乐观锁版本。 */
private Long version;
/** 记录创建 UTC 时间。 */
private LocalDateTime createdAt;
/** 记录更新 UTC 时间。 */
private LocalDateTime updatedAt;
/** 逻辑删除 UTC 时间。 */
private LocalDateTime logicDeletedAt;
/** 逻辑删除原因。 */
private String logicDeletedReason;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getHotelId() { return hotelId; }
public void setHotelId(String hotelId) { this.hotelId = hotelId; }
public Long getSourceMessageId() { return sourceMessageId; }
public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; }
public Long getAiBatchId() { return aiBatchId; }
public void setAiBatchId(Long aiBatchId) { this.aiBatchId = aiBatchId; }
public String getOrderRef() { return orderRef; }
public void setOrderRef(String orderRef) { this.orderRef = orderRef; }
public Integer getOrderContextIndex() { return orderContextIndex; }
public void setOrderContextIndex(Integer orderContextIndex) { this.orderContextIndex = orderContextIndex; }
public Long getOrderId() { return orderId; }
public void setOrderId(Long orderId) { this.orderId = orderId; }
public String getTargetBookingType() { return targetBookingType; }
public void setTargetBookingType(String targetBookingType) { this.targetBookingType = targetBookingType; }
public String getTargetLocatorType() { return targetLocatorType; }
public void setTargetLocatorType(String targetLocatorType) { this.targetLocatorType = targetLocatorType; }
public String getTargetLocatorValue() { return targetLocatorValue; }
public void setTargetLocatorValue(String targetLocatorValue) { this.targetLocatorValue = targetLocatorValue; }
public String getTargetResolutionStatus() { return targetResolutionStatus; }
public void setTargetResolutionStatus(String targetResolutionStatus) { this.targetResolutionStatus = targetResolutionStatus; }
public String getOrderTaskStatus() { return orderTaskStatus; }
public void setOrderTaskStatus(String orderTaskStatus) { this.orderTaskStatus = orderTaskStatus; }
public LocalDateTime getSourceReceivedAt() { return sourceReceivedAt; }
public void setSourceReceivedAt(LocalDateTime sourceReceivedAt) { this.sourceReceivedAt = sourceReceivedAt; }
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getLogicDeletedAt() { return logicDeletedAt; }
public void setLogicDeletedAt(LocalDateTime logicDeletedAt) { this.logicDeletedAt = logicDeletedAt; }
public String getLogicDeletedReason() { return logicDeletedReason; }
public void setLogicDeletedReason(String logicDeletedReason) { this.logicDeletedReason = logicDeletedReason; }
}

View File

@@ -0,0 +1,80 @@
package cn.nianxx.thhotel.workflows.reservation.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
/**
* Reservation V4 来源通知实体。第一版用于保存 S10 纯通知,不挂订单任务。
*/
@TableName("workflow_reservation_v4_source_notification")
public class ReservationV4SourceNotificationEntity {
/** V4 来源通知 ID。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 酒店 ID。 */
private String hotelId;
/** SourceMessage Inbox 内部 ID。 */
private Long sourceMessageId;
/** AI 回调批次 ID。 */
private Long aiBatchId;
/** AI transition IDS10 第一版可为空。 */
private Long aiTransitionId;
/** 包级路由码。 */
private String routeCode;
/** 通知状态。 */
private String notificationStatus;
/** 来源通知原始 AI 片段或包级摘要 JSON。 */
private String rawPayloadJson;
/** 确认处理人标识。 */
private String ackBy;
/** 确认处理 UTC 时间。 */
private LocalDateTime ackAt;
/** 来源邮件接收 UTC 时间。 */
private LocalDateTime sourceReceivedAt;
/** 乐观锁版本。 */
private Long version;
/** 记录创建 UTC 时间。 */
private LocalDateTime createdAt;
/** 记录更新 UTC 时间。 */
private LocalDateTime updatedAt;
/** 逻辑删除 UTC 时间。 */
private LocalDateTime logicDeletedAt;
/** 逻辑删除原因。 */
private String logicDeletedReason;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getHotelId() { return hotelId; }
public void setHotelId(String hotelId) { this.hotelId = hotelId; }
public Long getSourceMessageId() { return sourceMessageId; }
public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; }
public Long getAiBatchId() { return aiBatchId; }
public void setAiBatchId(Long aiBatchId) { this.aiBatchId = aiBatchId; }
public Long getAiTransitionId() { return aiTransitionId; }
public void setAiTransitionId(Long aiTransitionId) { this.aiTransitionId = aiTransitionId; }
public String getRouteCode() { return routeCode; }
public void setRouteCode(String routeCode) { this.routeCode = routeCode; }
public String getNotificationStatus() { return notificationStatus; }
public void setNotificationStatus(String notificationStatus) { this.notificationStatus = notificationStatus; }
public String getRawPayloadJson() { return rawPayloadJson; }
public void setRawPayloadJson(String rawPayloadJson) { this.rawPayloadJson = rawPayloadJson; }
public String getAckBy() { return ackBy; }
public void setAckBy(String ackBy) { this.ackBy = ackBy; }
public LocalDateTime getAckAt() { return ackAt; }
public void setAckAt(LocalDateTime ackAt) { this.ackAt = ackAt; }
public LocalDateTime getSourceReceivedAt() { return sourceReceivedAt; }
public void setSourceReceivedAt(LocalDateTime sourceReceivedAt) { this.sourceReceivedAt = sourceReceivedAt; }
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getLogicDeletedAt() { return logicDeletedAt; }
public void setLogicDeletedAt(LocalDateTime logicDeletedAt) { this.logicDeletedAt = logicDeletedAt; }
public String getLogicDeletedReason() { return logicDeletedReason; }
public void setLogicDeletedReason(String logicDeletedReason) { this.logicDeletedReason = logicDeletedReason; }
}

View File

@@ -0,0 +1,108 @@
package cn.nianxx.thhotel.workflows.reservation.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
/**
* Reservation V4 任务卡实体。承载来源邮件展示卡、Basic Information 卡和具体业务卡。
*/
@TableName("workflow_reservation_v4_task_card")
public class ReservationV4TaskCardEntity {
/** V4 任务卡 ID。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 酒店 ID。 */
private String hotelId;
/** V4 订单任务 ID。 */
private Long v4OrderTaskId;
/** SourceMessage Inbox 内部 ID。 */
private Long sourceMessageId;
/** AI transition ID非 event 卡可为空。 */
private Long aiTransitionId;
/** 卡片类型。 */
private String cardType;
/** 业务事件类型,非 event 卡为空。 */
private String eventType;
/** message_events[] 中的一基序号;非 event 卡固定为 0。 */
private Integer sourceEventIndex;
/** 同一订单任务内卡片排序。 */
private Integer cardSortOrder;
/** 卡片状态。 */
private String cardStatus;
/** 人工复核状态,非复核卡为空。 */
private String reviewStatus;
/** AI 原始卡片 payload JSON 快照。 */
private String aiPayloadJson;
/** 前端展示用 payload JSON 快照。 */
private String displayPayloadJson;
/** 用户最终确认 payload JSON。 */
private String confirmedPayloadJson;
/** 人工复核解阻结果 JSON。 */
private String reviewResolutionJson;
/** 第一版后端校验错误 JSON。 */
private String validationErrorsJson;
/** 最终确认人标识。 */
private String confirmedBy;
/** 最终确认 UTC 时间。 */
private LocalDateTime confirmedAt;
/** 乐观锁版本。 */
private Long version;
/** 记录创建 UTC 时间。 */
private LocalDateTime createdAt;
/** 记录更新 UTC 时间。 */
private LocalDateTime updatedAt;
/** 逻辑删除 UTC 时间。 */
private LocalDateTime logicDeletedAt;
/** 逻辑删除原因。 */
private String logicDeletedReason;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getHotelId() { return hotelId; }
public void setHotelId(String hotelId) { this.hotelId = hotelId; }
public Long getV4OrderTaskId() { return v4OrderTaskId; }
public void setV4OrderTaskId(Long v4OrderTaskId) { this.v4OrderTaskId = v4OrderTaskId; }
public Long getSourceMessageId() { return sourceMessageId; }
public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; }
public Long getAiTransitionId() { return aiTransitionId; }
public void setAiTransitionId(Long aiTransitionId) { this.aiTransitionId = aiTransitionId; }
public String getCardType() { return cardType; }
public void setCardType(String cardType) { this.cardType = cardType; }
public String getEventType() { return eventType; }
public void setEventType(String eventType) { this.eventType = eventType; }
public Integer getSourceEventIndex() { return sourceEventIndex; }
public void setSourceEventIndex(Integer sourceEventIndex) { this.sourceEventIndex = sourceEventIndex; }
public Integer getCardSortOrder() { return cardSortOrder; }
public void setCardSortOrder(Integer cardSortOrder) { this.cardSortOrder = cardSortOrder; }
public String getCardStatus() { return cardStatus; }
public void setCardStatus(String cardStatus) { this.cardStatus = cardStatus; }
public String getReviewStatus() { return reviewStatus; }
public void setReviewStatus(String reviewStatus) { this.reviewStatus = reviewStatus; }
public String getAiPayloadJson() { return aiPayloadJson; }
public void setAiPayloadJson(String aiPayloadJson) { this.aiPayloadJson = aiPayloadJson; }
public String getDisplayPayloadJson() { return displayPayloadJson; }
public void setDisplayPayloadJson(String displayPayloadJson) { this.displayPayloadJson = displayPayloadJson; }
public String getConfirmedPayloadJson() { return confirmedPayloadJson; }
public void setConfirmedPayloadJson(String confirmedPayloadJson) { this.confirmedPayloadJson = confirmedPayloadJson; }
public String getReviewResolutionJson() { return reviewResolutionJson; }
public void setReviewResolutionJson(String reviewResolutionJson) { this.reviewResolutionJson = reviewResolutionJson; }
public String getValidationErrorsJson() { return validationErrorsJson; }
public void setValidationErrorsJson(String validationErrorsJson) { this.validationErrorsJson = validationErrorsJson; }
public String getConfirmedBy() { return confirmedBy; }
public void setConfirmedBy(String confirmedBy) { this.confirmedBy = confirmedBy; }
public LocalDateTime getConfirmedAt() { return confirmedAt; }
public void setConfirmedAt(LocalDateTime confirmedAt) { this.confirmedAt = confirmedAt; }
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getLogicDeletedAt() { return logicDeletedAt; }
public void setLogicDeletedAt(LocalDateTime logicDeletedAt) { this.logicDeletedAt = logicDeletedAt; }
public String getLogicDeletedReason() { return logicDeletedReason; }
public void setLogicDeletedReason(String logicDeletedReason) { this.logicDeletedReason = logicDeletedReason; }
}

View File

@@ -0,0 +1,12 @@
package cn.nianxx.thhotel.workflows.reservation.mapper;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* Reservation V4 订单任务 Mapper。自带 CRUD 由 MyBatis-Plus 提供。
*/
@Mapper
public interface ReservationV4OrderTaskMapper extends BaseMapper<ReservationV4OrderTaskEntity> {
}

View File

@@ -0,0 +1,12 @@
package cn.nianxx.thhotel.workflows.reservation.mapper;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4SourceNotificationEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* Reservation V4 来源通知 Mapper。自带 CRUD 由 MyBatis-Plus 提供。
*/
@Mapper
public interface ReservationV4SourceNotificationMapper extends BaseMapper<ReservationV4SourceNotificationEntity> {
}

View File

@@ -0,0 +1,12 @@
package cn.nianxx.thhotel.workflows.reservation.mapper;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* Reservation V4 任务卡 Mapper。自带 CRUD 由 MyBatis-Plus 提供。
*/
@Mapper
public interface ReservationV4TaskCardMapper extends BaseMapper<ReservationV4TaskCardEntity> {
}

View File

@@ -0,0 +1,157 @@
package cn.nianxx.thhotel.workflows.reservation.repository;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4SourceNotificationEntity;
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4SourceNotificationMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Repository;
/**
* Reservation V4 来源通知 MyBatis-Plus 持久化实现。
*/
@Repository
public class MybatisReservationV4SourceNotificationRepository implements ReservationV4SourceNotificationRepository {
private final ReservationV4SourceNotificationMapper sourceNotificationMapper;
/**
* 注入来源通知 MapperRepository 负责幂等和快照转换。
*/
public MybatisReservationV4SourceNotificationRepository(
ReservationV4SourceNotificationMapper sourceNotificationMapper) {
this.sourceNotificationMapper = sourceNotificationMapper;
}
/**
* 按来源通知 ID 查询当前快照。
*/
@Override
public Optional<ReservationV4SourceNotificationSnapshot> findSourceNotificationById(
String hotelId,
Long notificationId) {
ReservationV4SourceNotificationEntity entity = sourceNotificationMapper.selectOne(
Wrappers.<ReservationV4SourceNotificationEntity>lambdaQuery()
.eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId)
.eq(ReservationV4SourceNotificationEntity::getId, notificationId)
.isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toSnapshot);
}
/**
* 按 SourceMessage 和 AI 批次查询来源通知,用于幂等判断。
*/
@Override
public Optional<ReservationV4SourceNotificationSnapshot> findSourceNotificationBySourceMessageAndBatch(
String hotelId,
Long sourceMessageId,
Long aiBatchId) {
ReservationV4SourceNotificationEntity entity = sourceNotificationMapper.selectOne(
Wrappers.<ReservationV4SourceNotificationEntity>lambdaQuery()
.eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId)
.eq(ReservationV4SourceNotificationEntity::getSourceMessageId, sourceMessageId)
.eq(ReservationV4SourceNotificationEntity::getAiBatchId, aiBatchId)
.isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toSnapshot);
}
/**
* 按 SourceMessage 查询来源通知列表,用于后续邮件通知详情和工作台展示。
*/
@Override
public List<ReservationV4SourceNotificationSnapshot> findSourceNotificationsBySourceMessageId(
String hotelId,
Long sourceMessageId) {
return sourceNotificationMapper.selectList(Wrappers.<ReservationV4SourceNotificationEntity>lambdaQuery()
.eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId)
.eq(ReservationV4SourceNotificationEntity::getSourceMessageId, sourceMessageId)
.isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt)
.orderByAsc(ReservationV4SourceNotificationEntity::getSourceReceivedAt)
.orderByAsc(ReservationV4SourceNotificationEntity::getId))
.stream()
.map(this::toSnapshot)
.toList();
}
/**
* 幂等创建 S10 来源通知;并发重复插入时返回已存在记录。
*/
@Override
public ReservationV4SourceNotificationSnapshot findOrCreateSourceNotification(
ReservationV4SourceNotificationDraft draft) {
Optional<ReservationV4SourceNotificationSnapshot> existing =
findSourceNotificationBySourceMessageAndBatch(draft.hotelId(), draft.sourceMessageId(), draft.aiBatchId());
if (existing.isPresent()) {
return existing.get();
}
ReservationV4SourceNotificationEntity entity = new ReservationV4SourceNotificationEntity();
entity.setHotelId(draft.hotelId());
entity.setSourceMessageId(draft.sourceMessageId());
entity.setAiBatchId(draft.aiBatchId());
entity.setAiTransitionId(draft.aiTransitionId());
entity.setRouteCode(draft.routeCode());
entity.setNotificationStatus(draft.notificationStatus());
entity.setRawPayloadJson(draft.rawPayloadJson());
entity.setSourceReceivedAt(draft.sourceReceivedAt());
entity.setVersion(0L);
entity.setCreatedAt(draft.now());
entity.setUpdatedAt(draft.now());
try {
sourceNotificationMapper.insert(entity);
return toSnapshot(entity);
} catch (DuplicateKeyException ex) {
return findSourceNotificationBySourceMessageAndBatch(
draft.hotelId(),
draft.sourceMessageId(),
draft.aiBatchId()).orElseThrow(() -> ex);
}
}
/**
* 按 version 乐观锁更新来源通知状态,常用于 S10 确认已读 / 已处理。
*/
@Override
public boolean updateNotificationStatusWithVersion(
String hotelId,
Long notificationId,
Long expectedVersion,
String notificationStatus,
String ackBy,
LocalDateTime ackAt) {
int updated = sourceNotificationMapper.update(Wrappers.<ReservationV4SourceNotificationEntity>lambdaUpdate()
.set(ReservationV4SourceNotificationEntity::getNotificationStatus, notificationStatus)
.set(ReservationV4SourceNotificationEntity::getAckBy, ackBy)
.set(ReservationV4SourceNotificationEntity::getAckAt, ackAt)
.set(ReservationV4SourceNotificationEntity::getUpdatedAt, ackAt)
.setSql("version = version + 1")
.eq(ReservationV4SourceNotificationEntity::getHotelId, hotelId)
.eq(ReservationV4SourceNotificationEntity::getId, notificationId)
.eq(ReservationV4SourceNotificationEntity::getVersion, expectedVersion)
.isNull(ReservationV4SourceNotificationEntity::getLogicDeletedAt));
return updated == 1;
}
private ReservationV4SourceNotificationSnapshot toSnapshot(ReservationV4SourceNotificationEntity entity) {
return new ReservationV4SourceNotificationSnapshot(
entity.getId(),
entity.getHotelId(),
entity.getSourceMessageId(),
entity.getAiBatchId(),
entity.getAiTransitionId(),
entity.getRouteCode(),
entity.getNotificationStatus(),
entity.getRawPayloadJson(),
entity.getAckBy(),
entity.getAckAt(),
entity.getSourceReceivedAt(),
entity.getVersion(),
entity.getCreatedAt(),
entity.getUpdatedAt());
}
}

View File

@@ -0,0 +1,274 @@
package cn.nianxx.thhotel.workflows.reservation.repository;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity;
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4OrderTaskMapper;
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4TaskCardMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Repository;
/**
* Reservation V4 订单任务和多卡 MyBatis-Plus 持久化实现。
*/
@Repository
public class MybatisReservationV4WorkflowRepository implements ReservationV4WorkflowRepository {
private static final int NON_EVENT_SOURCE_EVENT_INDEX = 0;
private final ReservationV4OrderTaskMapper orderTaskMapper;
private final ReservationV4TaskCardMapper taskCardMapper;
/**
* 注入 V4 MapperRepository 负责幂等、乐观锁和快照转换。
*/
public MybatisReservationV4WorkflowRepository(
ReservationV4OrderTaskMapper orderTaskMapper,
ReservationV4TaskCardMapper taskCardMapper) {
this.orderTaskMapper = orderTaskMapper;
this.taskCardMapper = taskCardMapper;
}
/**
* 按 SourceMessage 和 order_ref 查询 V4 订单任务,用于幂等创建和回调重放判断。
*/
@Override
public Optional<ReservationV4OrderTaskSnapshot> findOrderTaskBySourceMessageAndOrderRef(
String hotelId,
Long sourceMessageId,
String orderRef) {
ReservationV4OrderTaskEntity entity = orderTaskMapper.selectOne(
Wrappers.<ReservationV4OrderTaskEntity>lambdaQuery()
.eq(ReservationV4OrderTaskEntity::getHotelId, hotelId)
.eq(ReservationV4OrderTaskEntity::getSourceMessageId, sourceMessageId)
.eq(ReservationV4OrderTaskEntity::getOrderRef, orderRef)
.isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toOrderTaskSnapshot);
}
/**
* 按订单任务 ID 查询 V4 订单任务。
*/
@Override
public Optional<ReservationV4OrderTaskSnapshot> findOrderTaskById(String hotelId, Long orderTaskId) {
ReservationV4OrderTaskEntity entity = orderTaskMapper.selectOne(
Wrappers.<ReservationV4OrderTaskEntity>lambdaQuery()
.eq(ReservationV4OrderTaskEntity::getHotelId, hotelId)
.eq(ReservationV4OrderTaskEntity::getId, orderTaskId)
.isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toOrderTaskSnapshot);
}
/**
* 按 SourceMessage 查询全部 V4 订单任务,保留同一邮件内多个 order_context 的顺序。
*/
@Override
public List<ReservationV4OrderTaskSnapshot> findOrderTasksBySourceMessageId(String hotelId, Long sourceMessageId) {
return orderTaskMapper.selectList(Wrappers.<ReservationV4OrderTaskEntity>lambdaQuery()
.eq(ReservationV4OrderTaskEntity::getHotelId, hotelId)
.eq(ReservationV4OrderTaskEntity::getSourceMessageId, sourceMessageId)
.isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt)
.orderByAsc(ReservationV4OrderTaskEntity::getOrderContextIndex)
.orderByAsc(ReservationV4OrderTaskEntity::getId))
.stream()
.map(this::toOrderTaskSnapshot)
.toList();
}
/**
* 幂等创建 V4 订单任务;唯一键冲突时返回已存在记录。
*/
@Override
public ReservationV4OrderTaskSnapshot findOrCreateOrderTask(ReservationV4OrderTaskDraft draft) {
Optional<ReservationV4OrderTaskSnapshot> existing = findOrderTaskBySourceMessageAndOrderRef(
draft.hotelId(),
draft.sourceMessageId(),
draft.orderRef());
if (existing.isPresent()) {
return existing.get();
}
ReservationV4OrderTaskEntity entity = new ReservationV4OrderTaskEntity();
entity.setHotelId(draft.hotelId());
entity.setSourceMessageId(draft.sourceMessageId());
entity.setAiBatchId(draft.aiBatchId());
entity.setOrderRef(draft.orderRef());
entity.setOrderContextIndex(requirePositiveOrderContextIndex(draft.orderContextIndex()));
entity.setOrderId(draft.orderId());
entity.setTargetBookingType(draft.targetBookingType());
entity.setTargetLocatorType(draft.targetLocatorType());
entity.setTargetLocatorValue(draft.targetLocatorValue());
entity.setTargetResolutionStatus(draft.targetResolutionStatus());
entity.setOrderTaskStatus(draft.orderTaskStatus());
entity.setSourceReceivedAt(draft.sourceReceivedAt());
entity.setVersion(0L);
entity.setCreatedAt(draft.now());
entity.setUpdatedAt(draft.now());
try {
orderTaskMapper.insert(entity);
return toOrderTaskSnapshot(entity);
} catch (DuplicateKeyException ex) {
return findOrderTaskBySourceMessageAndOrderRef(
draft.hotelId(),
draft.sourceMessageId(),
draft.orderRef()).orElseThrow(() -> ex);
}
}
/**
* 创建 V4 任务卡;非 event 卡 source_event_index 固定写入 0。
*/
@Override
public ReservationV4TaskCardSnapshot insertTaskCard(ReservationV4TaskCardDraft draft) {
ReservationV4TaskCardEntity entity = new ReservationV4TaskCardEntity();
entity.setHotelId(draft.hotelId());
entity.setV4OrderTaskId(draft.v4OrderTaskId());
entity.setSourceMessageId(draft.sourceMessageId());
entity.setAiTransitionId(draft.aiTransitionId());
entity.setCardType(draft.cardType());
entity.setEventType(draft.eventType());
entity.setSourceEventIndex(normalizeSourceEventIndex(draft));
entity.setCardSortOrder(draft.cardSortOrder());
entity.setCardStatus(draft.cardStatus());
entity.setReviewStatus(draft.reviewStatus());
entity.setAiPayloadJson(draft.aiPayloadJson());
entity.setDisplayPayloadJson(draft.displayPayloadJson());
entity.setValidationErrorsJson(draft.validationErrorsJson());
entity.setVersion(0L);
entity.setCreatedAt(draft.now());
entity.setUpdatedAt(draft.now());
taskCardMapper.insert(entity);
return toTaskCardSnapshot(entity);
}
/**
* 按任务卡 ID 查询 V4 任务卡。
*/
@Override
public Optional<ReservationV4TaskCardSnapshot> findTaskCardById(String hotelId, Long taskCardId) {
ReservationV4TaskCardEntity entity = taskCardMapper.selectOne(
Wrappers.<ReservationV4TaskCardEntity>lambdaQuery()
.eq(ReservationV4TaskCardEntity::getHotelId, hotelId)
.eq(ReservationV4TaskCardEntity::getId, taskCardId)
.isNull(ReservationV4TaskCardEntity::getLogicDeletedAt)
.last("LIMIT 1"));
return Optional.ofNullable(entity).map(this::toTaskCardSnapshot);
}
/**
* 查询订单任务下全部 V4 任务卡,并按卡片排序和 event index 返回。
*/
@Override
public List<ReservationV4TaskCardSnapshot> findTaskCardsByOrderTaskId(String hotelId, Long orderTaskId) {
return taskCardMapper.selectList(Wrappers.<ReservationV4TaskCardEntity>lambdaQuery()
.eq(ReservationV4TaskCardEntity::getHotelId, hotelId)
.eq(ReservationV4TaskCardEntity::getV4OrderTaskId, orderTaskId)
.isNull(ReservationV4TaskCardEntity::getLogicDeletedAt)
.orderByAsc(ReservationV4TaskCardEntity::getCardSortOrder)
.orderByAsc(ReservationV4TaskCardEntity::getSourceEventIndex)
.orderByAsc(ReservationV4TaskCardEntity::getId))
.stream()
.map(this::toTaskCardSnapshot)
.toList();
}
/**
* 按 version 乐观锁更新 V4 任务卡状态。
*/
@Override
public boolean updateTaskCardStatusWithVersion(
String hotelId,
Long taskCardId,
Long expectedVersion,
String cardStatus,
LocalDateTime now) {
int updated = taskCardMapper.update(Wrappers.<ReservationV4TaskCardEntity>lambdaUpdate()
.set(ReservationV4TaskCardEntity::getCardStatus, cardStatus)
.set(ReservationV4TaskCardEntity::getUpdatedAt, now)
.setSql("version = version + 1")
.eq(ReservationV4TaskCardEntity::getHotelId, hotelId)
.eq(ReservationV4TaskCardEntity::getId, taskCardId)
.eq(ReservationV4TaskCardEntity::getVersion, expectedVersion)
.isNull(ReservationV4TaskCardEntity::getLogicDeletedAt));
return updated == 1;
}
private ReservationV4OrderTaskSnapshot toOrderTaskSnapshot(ReservationV4OrderTaskEntity entity) {
return new ReservationV4OrderTaskSnapshot(
entity.getId(),
entity.getHotelId(),
entity.getSourceMessageId(),
entity.getAiBatchId(),
entity.getOrderRef(),
entity.getOrderContextIndex(),
entity.getOrderId(),
entity.getTargetBookingType(),
entity.getTargetLocatorType(),
entity.getTargetLocatorValue(),
entity.getTargetResolutionStatus(),
entity.getOrderTaskStatus(),
entity.getSourceReceivedAt(),
entity.getVersion(),
entity.getCreatedAt(),
entity.getUpdatedAt());
}
private Integer requirePositiveOrderContextIndex(Integer orderContextIndex) {
if (orderContextIndex == null || orderContextIndex <= 0) {
throw new IllegalArgumentException("V4 order_context_index 必须是一基正整数。");
}
return orderContextIndex;
}
private Integer normalizeSourceEventIndex(ReservationV4TaskCardDraft draft) {
boolean eventCard = draft.aiTransitionId() != null || hasText(draft.eventType());
Integer sourceEventIndex = draft.sourceEventIndex();
if (eventCard) {
if (sourceEventIndex == null || sourceEventIndex <= 0) {
throw new IllegalArgumentException("V4 event 卡 source_event_index 必须是一基正整数。");
}
return sourceEventIndex;
}
if (sourceEventIndex != null && !sourceEventIndex.equals(NON_EVENT_SOURCE_EVENT_INDEX)) {
throw new IllegalArgumentException("V4 非 event 卡 source_event_index 必须固定为 0。");
}
return NON_EVENT_SOURCE_EVENT_INDEX;
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private ReservationV4TaskCardSnapshot toTaskCardSnapshot(ReservationV4TaskCardEntity entity) {
return new ReservationV4TaskCardSnapshot(
entity.getId(),
entity.getHotelId(),
entity.getV4OrderTaskId(),
entity.getSourceMessageId(),
entity.getAiTransitionId(),
entity.getCardType(),
entity.getEventType(),
entity.getSourceEventIndex(),
entity.getCardSortOrder(),
entity.getCardStatus(),
entity.getReviewStatus(),
entity.getAiPayloadJson(),
entity.getDisplayPayloadJson(),
entity.getConfirmedPayloadJson(),
entity.getReviewResolutionJson(),
entity.getValidationErrorsJson(),
entity.getConfirmedBy(),
entity.getConfirmedAt(),
entity.getVersion(),
entity.getCreatedAt(),
entity.getUpdatedAt());
}
}

View File

@@ -0,0 +1,51 @@
package cn.nianxx.thhotel.workflows.reservation.repository;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* Reservation V4 来源通知持久化边界。第一版用于 S10 纯通知的幂等创建、查询和确认。
*/
public interface ReservationV4SourceNotificationRepository {
/**
* 按来源通知 ID 查询当前快照。
*/
Optional<ReservationV4SourceNotificationSnapshot> findSourceNotificationById(
String hotelId,
Long notificationId);
/**
* 按 SourceMessage 和 AI 批次查询来源通知,用于幂等判断。
*/
Optional<ReservationV4SourceNotificationSnapshot> findSourceNotificationBySourceMessageAndBatch(
String hotelId,
Long sourceMessageId,
Long aiBatchId);
/**
* 按 SourceMessage 查询来源通知列表,用于后续邮件通知详情和工作台展示。
*/
List<ReservationV4SourceNotificationSnapshot> findSourceNotificationsBySourceMessageId(
String hotelId,
Long sourceMessageId);
/**
* 幂等创建 S10 来源通知;并发重复插入时返回已存在记录。
*/
ReservationV4SourceNotificationSnapshot findOrCreateSourceNotification(ReservationV4SourceNotificationDraft draft);
/**
* 按 version 乐观锁更新来源通知状态,常用于 S10 确认已读 / 已处理。
*/
boolean updateNotificationStatusWithVersion(
String hotelId,
Long notificationId,
Long expectedVersion,
String notificationStatus,
String ackBy,
LocalDateTime ackAt);
}

View File

@@ -0,0 +1,63 @@
package cn.nianxx.thhotel.workflows.reservation.repository;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* Reservation V4 订单任务和多卡持久化边界。Service 不直接依赖 Mapper 或 Entity。
*/
public interface ReservationV4WorkflowRepository {
/**
* 按 SourceMessage 和 order_ref 查询 V4 订单任务,用于幂等创建和回调重放判断。
*/
Optional<ReservationV4OrderTaskSnapshot> findOrderTaskBySourceMessageAndOrderRef(
String hotelId,
Long sourceMessageId,
String orderRef);
/**
* 按订单任务 ID 查询 V4 订单任务。
*/
Optional<ReservationV4OrderTaskSnapshot> findOrderTaskById(String hotelId, Long orderTaskId);
/**
* 按 SourceMessage 查询全部 V4 订单任务,保留同一邮件内多个 order_context 的顺序。
*/
List<ReservationV4OrderTaskSnapshot> findOrderTasksBySourceMessageId(String hotelId, Long sourceMessageId);
/**
* 幂等创建 V4 订单任务;唯一键冲突时返回已存在记录。
*/
ReservationV4OrderTaskSnapshot findOrCreateOrderTask(ReservationV4OrderTaskDraft draft);
/**
* 创建 V4 任务卡;非 event 卡 source_event_index 固定写入 0。
*/
ReservationV4TaskCardSnapshot insertTaskCard(ReservationV4TaskCardDraft draft);
/**
* 按任务卡 ID 查询 V4 任务卡。
*/
Optional<ReservationV4TaskCardSnapshot> findTaskCardById(String hotelId, Long taskCardId);
/**
* 查询订单任务下全部 V4 任务卡,并按卡片排序和 event index 返回。
*/
List<ReservationV4TaskCardSnapshot> findTaskCardsByOrderTaskId(String hotelId, Long orderTaskId);
/**
* 按 version 乐观锁更新 V4 任务卡状态。
*/
boolean updateTaskCardStatusWithVersion(
String hotelId,
Long taskCardId,
Long expectedVersion,
String cardStatus,
LocalDateTime now);
}

View File

@@ -0,0 +1,83 @@
CREATE TABLE workflow_reservation_v4_order_task (
id BIGINT NOT NULL COMMENT 'V4 订单任务 ID',
hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID',
source_message_id BIGINT NOT NULL COMMENT 'SourceMessage Inbox 内部 ID',
ai_batch_id BIGINT NOT NULL COMMENT 'AI 回调批次 ID',
order_ref VARCHAR(128) NOT NULL COMMENT 'V4 包内订单引用,同一 source_message_id 下用于聚合同一订单任务',
order_context_index INT NOT NULL COMMENT 'order_contexts[] 中的一基序号,用于同一 SourceMessage 下稳定排序',
order_id BIGINT NULL COMMENT '绑定的本地订单投影 ID归属未解决时可为空',
target_booking_type VARCHAR(32) NULL COMMENT '目标订单业务类型,例如 GROUP、FIT',
target_locator_type VARCHAR(64) NULL COMMENT '目标订单定位类型,例如 GROUP_CODE、BOOKING_CODE、CONFIRMATION_NUMBER',
target_locator_value VARCHAR(128) NULL COMMENT '目标订单定位值,未解决时为空',
target_resolution_status VARCHAR(32) NOT NULL COMMENT '订单归属解析状态RESOLVED、UNRESOLVED、CONFLICT',
order_task_status VARCHAR(32) NOT NULL COMMENT '订单任务状态快照OPEN、COMPLETED阻塞状态由查询实时派生',
source_received_at DATETIME(6) NOT NULL COMMENT '来源邮件接收 UTC 时间,用于排序',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间',
updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间',
logic_deleted_at DATETIME(6) NULL COMMENT '逻辑删除 UTC 时间,第一版不主动使用',
logic_deleted_reason VARCHAR(512) NULL COMMENT '逻辑删除原因,第一版不主动使用',
PRIMARY KEY (id),
UNIQUE KEY uk_reservation_v4_order_task_source_ref (hotel_id, source_message_id, order_ref),
UNIQUE KEY uk_reservation_v4_order_task_source_index (hotel_id, source_message_id, order_context_index),
KEY idx_reservation_v4_order_task_order (hotel_id, order_id, source_received_at),
KEY idx_reservation_v4_order_task_status (hotel_id, order_task_status, source_received_at),
KEY idx_reservation_v4_order_task_source (hotel_id, source_message_id),
KEY idx_reservation_v4_order_task_locator (hotel_id, target_locator_type, target_locator_value)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation V4 订单任务聚合表';
CREATE TABLE workflow_reservation_v4_task_card (
id BIGINT NOT NULL COMMENT 'V4 任务卡 ID',
hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID',
v4_order_task_id BIGINT NOT NULL COMMENT 'V4 订单任务 ID',
source_message_id BIGINT NOT NULL COMMENT 'SourceMessage Inbox 内部 ID',
ai_transition_id BIGINT NULL COMMENT 'AI transition ID非 event 卡可为空',
card_type VARCHAR(64) NOT NULL COMMENT '卡片类型,例如 SOURCE_MESSAGE_DISPLAY、BASIC_INFORMATION、ROOM_INFORMATION',
event_type VARCHAR(64) NULL COMMENT '业务事件类型,非 event 卡为空',
source_event_index INT NOT NULL DEFAULT 0 COMMENT 'message_events[] 中的一基序号;非 event 卡固定为 0',
card_sort_order INT NOT NULL COMMENT '同一订单任务内卡片展示和处理排序',
card_status VARCHAR(32) NOT NULL COMMENT '卡片状态READONLY、PENDING_CONFIRM、REVIEW_REQUIRED、CONFIRMED',
review_status VARCHAR(32) NULL COMMENT '人工复核状态,非复核卡为空',
ai_payload_json LONGTEXT NOT NULL COMMENT 'AI 原始卡片 payload JSON 快照',
display_payload_json LONGTEXT NULL COMMENT '前端展示用 payload JSON 快照',
confirmed_payload_json LONGTEXT NULL COMMENT '用户最终确认 payload JSON',
review_resolution_json LONGTEXT NULL COMMENT '人工复核解阻结果 JSON',
validation_errors_json LONGTEXT NULL COMMENT '第一版后端校验错误 JSON',
confirmed_by VARCHAR(128) NULL COMMENT '最终确认人标识',
confirmed_at DATETIME(6) NULL COMMENT '最终确认 UTC 时间',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间',
updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间',
logic_deleted_at DATETIME(6) NULL COMMENT '逻辑删除 UTC 时间,第一版不主动使用',
logic_deleted_reason VARCHAR(512) NULL COMMENT '逻辑删除原因,第一版不主动使用',
PRIMARY KEY (id),
UNIQUE KEY uk_reservation_v4_task_card_slot (hotel_id, v4_order_task_id, card_sort_order, source_event_index),
KEY idx_reservation_v4_task_card_task (hotel_id, v4_order_task_id, card_sort_order),
KEY idx_reservation_v4_task_card_source (hotel_id, source_message_id),
KEY idx_reservation_v4_task_card_transition (hotel_id, ai_transition_id),
KEY idx_reservation_v4_task_card_status (hotel_id, card_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation V4 多卡任务表';
CREATE TABLE workflow_reservation_v4_source_notification (
id BIGINT NOT NULL COMMENT 'V4 来源通知 ID',
hotel_id VARCHAR(64) NOT NULL COMMENT '酒店 ID',
source_message_id BIGINT NOT NULL COMMENT 'SourceMessage Inbox 内部 ID',
ai_batch_id BIGINT NOT NULL COMMENT 'AI 回调批次 ID',
ai_transition_id BIGINT NULL COMMENT 'AI transition IDS10 第一版可为空',
route_code VARCHAR(32) NOT NULL COMMENT '包级路由码,第一版用于 S10 来源通知',
notification_status VARCHAR(32) NOT NULL COMMENT '通知状态ACK_REQUIRED、ACKED',
raw_payload_json LONGTEXT NOT NULL COMMENT '来源通知原始 AI 片段或包级摘要 JSON',
ack_by VARCHAR(128) NULL COMMENT '确认处理人标识',
ack_at DATETIME(6) NULL COMMENT '确认处理 UTC 时间',
source_received_at DATETIME(6) NOT NULL COMMENT '来源邮件接收 UTC 时间,用于排序',
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间',
updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间',
logic_deleted_at DATETIME(6) NULL COMMENT '逻辑删除 UTC 时间,第一版不主动使用',
logic_deleted_reason VARCHAR(512) NULL COMMENT '逻辑删除原因,第一版不主动使用',
PRIMARY KEY (id),
UNIQUE KEY uk_reservation_v4_notification_message_batch (hotel_id, source_message_id, ai_batch_id),
KEY idx_reservation_v4_notification_status (hotel_id, notification_status, source_received_at),
KEY idx_reservation_v4_notification_source (hotel_id, source_message_id),
KEY idx_reservation_v4_notification_transition (hotel_id, ai_transition_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='Reservation V4 S10 来源通知表';

View File

@@ -0,0 +1,274 @@
package cn.nianxx.thhotel.workflows.reservation.repository;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
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.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.enums.ReservationV4TargetResolutionStatus;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:m002_v4_repository;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"auth.bootstrap.admin.username=",
"auth.bootstrap.admin.password=",
"auth.bootstrap.default-hotel-id="
})
@ActiveProfiles("test")
class MybatisReservationV4WorkflowRepositoryTest {
private static final String HOTEL_ID = "HOTEL-TEST";
@Autowired
private ReservationV4WorkflowRepository workflowRepository;
@Autowired
private ReservationV4SourceNotificationRepository sourceNotificationRepository;
@Test
void shouldCreateOrderTaskIdempotentlyBySourceMessageAndOrderRef() {
ReservationV4OrderTaskDraft draft = orderTaskDraft(1001L, "order-1", 501L, now());
ReservationV4OrderTaskSnapshot first = workflowRepository.findOrCreateOrderTask(draft);
ReservationV4OrderTaskSnapshot second = workflowRepository.findOrCreateOrderTask(draft);
assertThat(second.id()).isEqualTo(first.id());
assertThat(workflowRepository.findOrderTaskBySourceMessageAndOrderRef(HOTEL_ID, 1001L, "order-1"))
.get()
.satisfies(snapshot -> {
assertThat(snapshot.hotelId()).isEqualTo(HOTEL_ID);
assertThat(snapshot.orderRef()).isEqualTo("order-1");
assertThat(snapshot.orderContextIndex()).isEqualTo(1);
assertThat(snapshot.orderTaskStatus()).isEqualTo(ReservationV4OrderTaskStatus.OPEN.name());
assertThat(snapshot.targetResolutionStatus())
.isEqualTo(ReservationV4TargetResolutionStatus.RESOLVED.name());
});
}
@Test
void shouldFindOrderTasksBySourceMessageInOrderContextIndexOrder() {
workflowRepository.findOrCreateOrderTask(orderTaskDraft(1010L, "order-later", 510L, 2, now()));
workflowRepository.findOrCreateOrderTask(orderTaskDraft(1010L, "order-earlier", 511L, 1, now()));
assertThat(workflowRepository.findOrderTasksBySourceMessageId(HOTEL_ID, 1010L))
.extracting(ReservationV4OrderTaskSnapshot::orderRef)
.containsExactly("order-earlier", "order-later");
}
@Test
void shouldStoreNonEventCardWithZeroSourceEventIndexAndRejectDuplicateSlot() {
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(
orderTaskDraft(1002L, "order-2", 502L, now()));
ReservationV4TaskCardDraft draft = sourceMessageDisplayCardDraft(orderTask.id(), 1002L, now());
ReservationV4TaskCardSnapshot card = workflowRepository.insertTaskCard(draft);
assertThat(card.sourceEventIndex()).isZero();
assertThat(card.version()).isZero();
assertThatThrownBy(() -> workflowRepository.insertTaskCard(draft))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void shouldRejectEventCardWithoutPositiveSourceEventIndex() {
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(
orderTaskDraft(1011L, "order-11", 511L, now()));
assertThatThrownBy(() -> workflowRepository.insertTaskCard(
eventCardDraftWithoutSourceEventIndex(orderTask.id(), 1011L, 2011L, now())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("source_event_index");
}
@Test
void shouldUpdateTaskCardStatusWithExpectedVersion() {
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(
orderTaskDraft(1003L, "order-3", 503L, now()));
ReservationV4TaskCardSnapshot card = workflowRepository.insertTaskCard(
businessCardDraft(orderTask.id(), 1003L, 2003L, now()));
LocalDateTime updatedAt = now().plusMinutes(1);
boolean updated = workflowRepository.updateTaskCardStatusWithVersion(
HOTEL_ID,
card.id(),
0L,
ReservationV4CardStatus.CONFIRMED.name(),
updatedAt);
assertThat(updated).isTrue();
ReservationV4TaskCardSnapshot afterUpdate = workflowRepository.findTaskCardById(HOTEL_ID, card.id()).orElseThrow();
assertThat(afterUpdate.cardStatus()).isEqualTo(ReservationV4CardStatus.CONFIRMED.name());
assertThat(afterUpdate.version()).isEqualTo(1L);
assertThat(workflowRepository.updateTaskCardStatusWithVersion(
HOTEL_ID,
card.id(),
0L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
updatedAt.plusMinutes(1))).isFalse();
}
@Test
void shouldCreateSourceNotificationIdempotentlyAndAckWithVersion() {
ReservationV4SourceNotificationDraft draft = sourceNotificationDraft(1004L, 704L, now());
ReservationV4SourceNotificationSnapshot first = sourceNotificationRepository.findOrCreateSourceNotification(draft);
ReservationV4SourceNotificationSnapshot second = sourceNotificationRepository.findOrCreateSourceNotification(draft);
LocalDateTime ackAt = now().plusMinutes(2);
assertThat(second.id()).isEqualTo(first.id());
assertThat(first.notificationStatus()).isEqualTo(ReservationV4NotificationStatus.ACK_REQUIRED.name());
assertThat(first.version()).isZero();
assertThat(sourceNotificationRepository.updateNotificationStatusWithVersion(
HOTEL_ID,
first.id(),
0L,
ReservationV4NotificationStatus.ACKED.name(),
"user-1",
ackAt)).isTrue();
assertThat(sourceNotificationRepository.updateNotificationStatusWithVersion(
HOTEL_ID,
first.id(),
0L,
ReservationV4NotificationStatus.ACK_REQUIRED.name(),
"user-2",
ackAt.plusMinutes(1))).isFalse();
ReservationV4SourceNotificationSnapshot afterAck =
sourceNotificationRepository.findSourceNotificationById(HOTEL_ID, first.id()).orElseThrow();
assertThat(afterAck.notificationStatus()).isEqualTo(ReservationV4NotificationStatus.ACKED.name());
assertThat(afterAck.ackBy()).isEqualTo("user-1");
assertThat(afterAck.ackAt()).isEqualTo(ackAt);
assertThat(afterAck.version()).isEqualTo(1L);
}
private ReservationV4OrderTaskDraft orderTaskDraft(
Long sourceMessageId,
String orderRef,
Long orderId,
LocalDateTime now) {
return orderTaskDraft(sourceMessageId, orderRef, orderId, 1, now);
}
private ReservationV4OrderTaskDraft orderTaskDraft(
Long sourceMessageId,
String orderRef,
Long orderId,
Integer orderContextIndex,
LocalDateTime now) {
return new ReservationV4OrderTaskDraft(
HOTEL_ID,
sourceMessageId,
9001L,
orderRef,
orderContextIndex,
orderId,
"GROUP",
"GROUP_CODE",
"GRP-" + orderRef,
ReservationV4TargetResolutionStatus.RESOLVED.name(),
ReservationV4OrderTaskStatus.OPEN.name(),
now.minusMinutes(5),
now);
}
private ReservationV4TaskCardDraft sourceMessageDisplayCardDraft(
Long orderTaskId,
Long sourceMessageId,
LocalDateTime now) {
return new ReservationV4TaskCardDraft(
HOTEL_ID,
orderTaskId,
sourceMessageId,
null,
ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(),
null,
null,
10,
ReservationV4CardStatus.READONLY.name(),
null,
"{\"source\":\"ai\"}",
"{\"display\":\"mail\"}",
null,
now);
}
private ReservationV4TaskCardDraft eventCardDraftWithoutSourceEventIndex(
Long orderTaskId,
Long sourceMessageId,
Long transitionId,
LocalDateTime now) {
return new ReservationV4TaskCardDraft(
HOTEL_ID,
orderTaskId,
sourceMessageId,
transitionId,
ReservationV4CardType.ROOM_INFORMATION.name(),
"NEW_BOOKING",
null,
30,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
null,
"{\"event\":\"new_booking\"}",
"{\"display\":\"room\"}",
null,
now);
}
private ReservationV4TaskCardDraft businessCardDraft(
Long orderTaskId,
Long sourceMessageId,
Long transitionId,
LocalDateTime now) {
return new ReservationV4TaskCardDraft(
HOTEL_ID,
orderTaskId,
sourceMessageId,
transitionId,
ReservationV4CardType.ROOM_INFORMATION.name(),
"NEW_BOOKING",
1,
30,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
null,
"{\"event\":\"new_booking\"}",
"{\"display\":\"room\"}",
null,
now);
}
private ReservationV4SourceNotificationDraft sourceNotificationDraft(
Long sourceMessageId,
Long transitionId,
LocalDateTime now) {
return new ReservationV4SourceNotificationDraft(
HOTEL_ID,
sourceMessageId,
9004L,
transitionId,
"S10",
ReservationV4NotificationStatus.ACK_REQUIRED.name(),
"{\"route_code\":\"S10\"}",
now.minusMinutes(3),
now);
}
private LocalDateTime now() {
return LocalDateTime.of(2026, 7, 18, 8, 30, 0);
}
}