diff --git a/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/api/hotel/HotelZaxOrderApi.java b/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/api/hotel/HotelZaxOrderApi.java new file mode 100644 index 0000000..33f946d --- /dev/null +++ b/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/api/hotel/HotelZaxOrderApi.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.product.api.hotel; + +/** + * 酒店订单支付回调桥接(与商城共用 mall 支付应用)。 + * 商户单号可能与 trade_order.id 撞号,必须以支付单反查酒店单。 + */ +public interface HotelZaxOrderApi { + + /** + * 支付成功通知:优先按 payOrderId 匹配酒店单。 + * + * @return true=已按酒店单处理;false=不是酒店单,应继续走商城订单 + */ + boolean handlePayNotify(String merchantOrderId, Long payOrderId); + + /** + * @return true=已按酒店单处理;false=不是酒店单 + */ + boolean updatePaidIfMatch(Long merchantOrderId, Long payOrderId); +} diff --git a/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java b/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java index 5da4261..22dcd17 100644 --- a/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java +++ b/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java @@ -75,4 +75,12 @@ public interface ErrorCodeConstants { // ========== 贩卖机测试日志 1-008-013-000 ========== ErrorCode DEVICE_LOG_NOT_EXISTS = new ErrorCode(1_008_013_000, "贩卖机测试日志不存在"); + // ========== 资安心酒店本地库 1-008-020-000 ========== + ErrorCode HOTEL_ZAX_NOT_EXISTS = new ErrorCode(1_008_020_000, "本地酒店不存在"); + ErrorCode HOTEL_ZAX_ROOM_NOT_EXISTS = new ErrorCode(1_008_020_001, "本地酒店房型不存在"); + ErrorCode HOTEL_ZAX_PRODUCT_NOT_EXISTS = new ErrorCode(1_008_020_002, "本地酒店产品不存在"); + ErrorCode HOTEL_ZAX_DAILY_NOT_EXISTS = new ErrorCode(1_008_020_003, "本地酒店日历不存在"); + ErrorCode HOTEL_ZAX_ORDER_NOT_EXISTS = new ErrorCode(1_008_020_004, "本地酒店订单不存在"); + ErrorCode HOTEL_ZAX_ORDER_CANCEL_FAIL = new ErrorCode(1_008_020_005, "酒店订单取消失败:{}"); + } diff --git a/yudao-module-mall/yudao-module-product-biz/pom.xml b/yudao-module-mall/yudao-module-product-biz/pom.xml index e69e05d..ee6b9de 100644 --- a/yudao-module-mall/yudao-module-product-biz/pom.xml +++ b/yudao-module-mall/yudao-module-product-biz/pom.xml @@ -28,6 +28,11 @@ yudao-module-member-api ${revision} + + cn.iocoder.boot + yudao-module-pay-api + ${revision} + diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/api/hotel/HotelZaxOrderApiImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/api/hotel/HotelZaxOrderApiImpl.java new file mode 100644 index 0000000..c106275 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/api/hotel/HotelZaxOrderApiImpl.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.product.api.hotel; + +import cn.iocoder.yudao.module.product.service.hotel.HotelZaxTradeService; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; + +@Service +@Validated +public class HotelZaxOrderApiImpl implements HotelZaxOrderApi { + + @Resource + private HotelZaxTradeService hotelZaxTradeService; + + @Override + public boolean handlePayNotify(String merchantOrderId, Long payOrderId) { + return hotelZaxTradeService.handlePayNotify(merchantOrderId, payOrderId); + } + + @Override + public boolean updatePaidIfMatch(Long merchantOrderId, Long payOrderId) { + return hotelZaxTradeService.updatePaidIfMatch(merchantOrderId, payOrderId); + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/HotelZaxController.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/HotelZaxController.java new file mode 100644 index 0000000..696c84b --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/HotelZaxController.java @@ -0,0 +1,234 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailyBatchReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailyPageReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailyRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailySaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxOrderPageReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxOrderRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxPageReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxProductRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxProductSaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxRoomRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxRoomSaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxSaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxStatusReqVO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxSyncCursorDO; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxSyncCursorMapper; +import cn.iocoder.yudao.module.product.service.hotel.HotelZaxAdminService; +import cn.iocoder.yudao.module.product.service.hotel.HotelZaxSyncService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.validation.Valid; +import java.util.LinkedHashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 资安心酒店本地库") +@RestController +@RequestMapping("/product/hotel-zax") +@Validated +public class HotelZaxController { + + @Resource + private HotelZaxMapper hotelZaxMapper; + + @Resource + private HotelZaxSyncCursorMapper cursorMapper; + + @Resource + private HotelZaxSyncService hotelZaxSyncService; + + @Resource + private HotelZaxAdminService hotelZaxAdminService; + + @GetMapping("/page") + @Operation(summary = "本地酒店分页") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:query')") + public CommonResult> page(@Valid HotelZaxPageReqVO req) { + return success(hotelZaxAdminService.page(req)); + } + + @GetMapping("/get") + @Operation(summary = "本地酒店详情(含房型/产品)") + @Parameter(name = "id", required = true) + @PreAuthorize("@ss.hasPermission('product:hotel-zax:query')") + public CommonResult get(@RequestParam("id") Long id) { + return success(hotelZaxAdminService.getDetail(id)); + } + + @PutMapping("/update") + @Operation(summary = "编辑本地酒店基础信息") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:update')") + public CommonResult update(@Valid @RequestBody HotelZaxSaveReqVO req) { + hotelZaxAdminService.update(req); + return success(true); + } + + @PutMapping("/update-status") + @Operation(summary = "本地上下架") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:update')") + public CommonResult updateStatus(@Valid @RequestBody HotelZaxStatusReqVO req) { + hotelZaxAdminService.updateStatus(req); + return success(true); + } + + @PostMapping("/room/save") + @Operation(summary = "保存房型") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:update')") + public CommonResult saveRoom(@Valid @RequestBody HotelZaxRoomSaveReqVO req) { + return success(hotelZaxAdminService.saveRoom(req)); + } + + @DeleteMapping("/room/delete") + @Operation(summary = "删除房型") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:update')") + public CommonResult removeRoom(@RequestParam("hotelId") Long hotelId, + @RequestParam("roomId") Long roomId) { + hotelZaxAdminService.removeRoom(hotelId, roomId); + return success(true); + } + + @PostMapping("/product/save") + @Operation(summary = "保存产品") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:update')") + public CommonResult saveProduct(@Valid @RequestBody HotelZaxProductSaveReqVO req) { + return success(hotelZaxAdminService.saveProduct(req)); + } + + @DeleteMapping("/product/delete") + @Operation(summary = "删除产品") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:update')") + public CommonResult removeProduct(@RequestParam("hotelId") Long hotelId, + @RequestParam("productId") Long productId) { + hotelZaxAdminService.removeProduct(hotelId, productId); + return success(true); + } + + @GetMapping("/daily/page") + @Operation(summary = "本地酒店日历分页") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:query')") + public CommonResult> pageDaily(@Valid HotelZaxDailyPageReqVO req) { + return success(hotelZaxAdminService.pageDaily(req)); + } + + @PostMapping("/daily/save") + @Operation(summary = "保存日历") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:update')") + public CommonResult saveDaily(@Valid @RequestBody HotelZaxDailySaveReqVO req) { + return success(hotelZaxAdminService.saveDaily(req)); + } + + @PostMapping("/daily/batch") + @Operation(summary = "批量生成/调价日历 FILL/ADJUST") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:update')") + public CommonResult> batchDaily(@Valid @RequestBody HotelZaxDailyBatchReqVO req) { + return success(hotelZaxAdminService.batchDaily(req)); + } + + @DeleteMapping("/daily/delete") + @Operation(summary = "删除日历") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:update')") + public CommonResult removeDaily(@RequestParam("hotelId") Long hotelId, + @RequestParam("dailyId") Long dailyId) { + hotelZaxAdminService.removeDaily(hotelId, dailyId); + return success(true); + } + + @PostMapping("/daily/sync") + @Operation(summary = "从资安心拉取该酒店日历并落本地") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:sync')") + public CommonResult> syncDaily(@RequestParam("hotelId") Long hotelId) { + return success(hotelZaxSyncService.syncDailyByHotelId(hotelId)); + } + + @GetMapping("/sync-cursor") + @Operation(summary = "同步水位") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:query')") + public CommonResult> syncCursor() { + HotelZaxSyncCursorDO c = cursorMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxSyncCursorDO::getCursorKey, "default") + .last("limit 1")); + Map m = new LinkedHashMap<>(); + if (c != null) { + m.put("updatedSince", c.getUpdatedSince()); + m.put("lastSyncAt", c.getLastSyncAt()); + m.put("lastMsg", c.getLastMsg()); + } + long total = hotelZaxMapper.selectCount(); + long active = hotelZaxMapper.selectCount(new LambdaQueryWrapperX() + .eq(HotelZaxDO::getStatus, "ACTIVE")); + m.put("total", total); + m.put("active", active); + return success(m); + } + + @GetMapping("/order/page") + @Operation(summary = "本地酒店订单分页") + @PreAuthorize("@ss.hasPermission('product:hotel-zax-order:query')") + public CommonResult> pageOrders(@Valid HotelZaxOrderPageReqVO req) { + return success(hotelZaxAdminService.pageOrders(req)); + } + + @GetMapping("/order/get") + @Operation(summary = "本地酒店订单详情") + @Parameter(name = "id", required = true) + @PreAuthorize("@ss.hasPermission('product:hotel-zax-order:query')") + public CommonResult getOrder(@RequestParam("id") Long id) { + return success(hotelZaxAdminService.getOrder(id)); + } + + @PostMapping("/order/cancel") + @Operation(summary = "后台取消酒店订单(已支付则退款,并通知资安心)") + @Parameter(name = "id", required = true) + @PreAuthorize("@ss.hasPermission('product:hotel-zax-order:query')") + public CommonResult cancelOrder(@RequestParam("id") Long id, + @RequestParam(value = "reason", required = false) String reason) { + hotelZaxAdminService.cancelOrder(id, reason); + return success(true); + } + + @PostMapping("/sync") + @Operation(summary = "从资安心开放平台同步到本地(默认全量,适配授权范围变更)") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:sync')") + public CommonResult> sync( + @RequestParam(value = "full", required = false, defaultValue = "true") Boolean full) { + return success(hotelZaxSyncService.syncOnce(Boolean.TRUE.equals(full))); + } + + @PostMapping("/reset-cursor") + @Operation(summary = "重置同步水位(下次全量增量起点)") + @PreAuthorize("@ss.hasPermission('product:hotel-zax:sync')") + public CommonResult resetCursor() { + HotelZaxSyncCursorDO c = cursorMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxSyncCursorDO::getCursorKey, "default") + .last("limit 1")); + if (c != null) { + c.setUpdatedSince(null); + c.setLastMsg(StrUtil.blankToDefault(c.getLastMsg(), "") + " | reset"); + cursorMapper.updateById(c); + } + return success(true); + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailyBatchReqVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailyBatchReqVO.java new file mode 100644 index 0000000..405f2e8 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailyBatchReqVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "管理后台 - 本地酒店日历批量生成/调价") +@Data +public class HotelZaxDailyBatchReqVO { + + @NotNull(message = "酒店 id 不能为空") + private Long hotelId; + + @NotBlank(message = "mode 不能为空") + @Schema(description = "FILL 补齐缺失 / ADJUST 改已有") + private String mode; + + @NotNull(message = "开始日期不能为空") + private LocalDate startDate; + + @NotNull(message = "结束日期不能为空") + private LocalDate endDate; + + @Schema(description = "空=全部产品") + private List productCodes; + + private BigDecimal costPrice; + + private BigDecimal sellPrice; + + private Integer stock; + + private Integer dailyStatus; + + @Schema(description = "ADJUST 时是否同时补齐缺失日期") + private Boolean fillMissing; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailyPageReqVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailyPageReqVO.java new file mode 100644 index 0000000..de069ae --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailyPageReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import javax.validation.constraints.NotNull; + +@Schema(description = "管理后台 - 本地酒店日历分页") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class HotelZaxDailyPageReqVO extends PageParam { + + @NotNull(message = "酒店 id 不能为空") + private Long hotelId; + + private String productCode; + + @Schema(description = "入住日起 yyyy-MM-dd") + private String stayFrom; + + @Schema(description = "入住日止 yyyy-MM-dd") + private String stayTo; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailyRespVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailyRespVO.java new file mode 100644 index 0000000..0f955b1 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailyRespVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Schema(description = "管理后台 - 本地酒店日历") +@Data +public class HotelZaxDailyRespVO { + + private Long id; + + private Long hotelId; + + private String hotelCode; + + private Long productId; + + private String productCode; + + private String roomTypeCode; + + private LocalDate stayDate; + + private BigDecimal costPrice; + + private BigDecimal sellPrice; + + private Integer stock; + + private Integer sold; + + private Integer remainStock; + + /** 0关 1开 */ + private Integer dailyStatus; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailySaveReqVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailySaveReqVO.java new file mode 100644 index 0000000..ab892c2 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxDailySaveReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.LocalDate; + +@Schema(description = "管理后台 - 本地酒店日历保存") +@Data +public class HotelZaxDailySaveReqVO { + + private Long id; + + @NotNull(message = "酒店 id 不能为空") + private Long hotelId; + + @NotBlank(message = "产品编码不能为空") + private String productCode; + + @NotNull(message = "入住日期不能为空") + private LocalDate stayDate; + + private BigDecimal costPrice; + + @NotNull(message = "卖价不能为空") + private BigDecimal sellPrice; + + @NotNull(message = "当日配额不能为空") + private Integer stock; + + @NotNull(message = "房态不能为空") + private Integer dailyStatus; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxOrderPageReqVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxOrderPageReqVO.java new file mode 100644 index 0000000..203f830 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxOrderPageReqVO.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - 本地酒店订单分页") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class HotelZaxOrderPageReqVO extends PageParam { + + @Schema(description = "酒店名称") + private String hotelName; + + @Schema(description = "酒店编码") + private String hotelCode; + + @Schema(description = "渠道单号") + private String channelNo; + + @Schema(description = "供应方单号") + private String orderNo; + + @Schema(description = "本地状态 UNPAID/PAID/CREATED/BOOK_FAIL") + private String orderStatus; + + @Schema(description = "是否已支付") + private Boolean payStatus; + + @Schema(description = "联系人手机") + private String contactPhone; + + @Schema(description = "下单用户") + private Long userId; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxOrderRespVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxOrderRespVO.java new file mode 100644 index 0000000..207f3ec --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxOrderRespVO.java @@ -0,0 +1,63 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 本地酒店订单") +@Data +public class HotelZaxOrderRespVO { + + private Long id; + + private Long userId; + + private String channelNo; + + private String orderNo; + + private String platformNo; + + private String orderStatus; + + private String bookStatus; + + private String hotelCode; + + private String hotelName; + + private String productCode; + + private String productName; + + private String roomTypeCode; + + private LocalDate checkInDate; + + private LocalDate checkOutDate; + + private Integer nights; + + private Integer roomCount; + + private String contactName; + + private String contactPhone; + + private String guestJson; + + private Long payOrderId; + + private Boolean payStatus; + + private LocalDateTime payTime; + + private BigDecimal sellAmount; + + private String failMsg; + + private LocalDateTime createTime; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxPageReqVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxPageReqVO.java new file mode 100644 index 0000000..235ce2c --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxPageReqVO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - 本地酒店分页") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class HotelZaxPageReqVO extends PageParam { + + @Schema(description = "酒店名称") + private String hotelName; + + @Schema(description = "酒店编码") + private String hotelCode; + + @Schema(description = "状态 ACTIVE/OFFLINE") + private String status; + + @Schema(description = "区域 adcode") + private Long districtAdcode; + + @Schema(description = "区域名称") + private String district; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxProductRespVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxProductRespVO.java new file mode 100644 index 0000000..3d0980d --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxProductRespVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +@Schema(description = "管理后台 - 本地酒店产品") +@Data +public class HotelZaxProductRespVO { + + private Long id; + + private Long hotelId; + + private String hotelCode; + + private String groupCode; + + private String roomTypeCode; + + private String productCode; + + private String productName; + + private String priceCode; + + private String cancelCode; + + private Integer mealType; + + private Integer mealCount; + + private BigDecimal sellPrice; + + private Integer stock; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxProductSaveReqVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxProductSaveReqVO.java new file mode 100644 index 0000000..be1fb21 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxProductSaveReqVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; + +@Schema(description = "管理后台 - 本地酒店产品保存") +@Data +public class HotelZaxProductSaveReqVO { + + private Long id; + + @NotNull(message = "酒店 id 不能为空") + private Long hotelId; + + @NotBlank(message = "产品编码不能为空") + private String productCode; + + @NotBlank(message = "产品名称不能为空") + private String productName; + + @NotBlank(message = "所属房型不能为空") + private String roomTypeCode; + + private String priceCode; + + private String cancelCode; + + private Integer mealType; + + private Integer mealCount; + + private BigDecimal sellPrice; + + private Integer stock; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxRespVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxRespVO.java new file mode 100644 index 0000000..62d648e --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxRespVO.java @@ -0,0 +1,71 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - 本地酒店") +@Data +public class HotelZaxRespVO { + + private Long id; + + private String hotelCode; + + private String hotelName; + + private String groupCode; + + private String coverImage; + + private String carouselImages; + + private String address; + + private String district; + + private Long districtAdcode; + + private String bizArea; + + private Integer starLevel; + + private BigDecimal score; + + private String facilityTags; + + private BigDecimal longitude; + + private BigDecimal latitude; + + private String phone; + + private BigDecimal minSellPrice; + + private String status; + + private String intro; + + private LocalDateTime supplierUpdateTime; + + private LocalDateTime syncedAt; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; + + @Schema(description = "房型数") + private Integer roomCount; + + @Schema(description = "产品数") + private Integer productCount; + + @Schema(description = "房型列表(详情)") + private List rooms; + + @Schema(description = "产品列表(详情)") + private List products; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxRoomRespVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxRoomRespVO.java new file mode 100644 index 0000000..2905c7c --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxRoomRespVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - 本地酒店房型") +@Data +public class HotelZaxRoomRespVO { + + private Long id; + + private Long hotelId; + + private String hotelCode; + + private String groupCode; + + private String roomTypeCode; + + private String roomTypeName; + + private String bedType; + + private Integer capacity; + + private Integer roomNumber; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxRoomSaveReqVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxRoomSaveReqVO.java new file mode 100644 index 0000000..902d94e --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxRoomSaveReqVO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Schema(description = "管理后台 - 本地酒店房型保存") +@Data +public class HotelZaxRoomSaveReqVO { + + private Long id; + + @NotNull(message = "酒店 id 不能为空") + private Long hotelId; + + @NotBlank(message = "房型编码不能为空") + private String roomTypeCode; + + @NotBlank(message = "房型名称不能为空") + private String roomTypeName; + + private String bedType; + + private Integer capacity; + + private Integer roomNumber; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxSaveReqVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxSaveReqVO.java new file mode 100644 index 0000000..667cda3 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxSaveReqVO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; + +@Schema(description = "管理后台 - 本地酒店保存") +@Data +public class HotelZaxSaveReqVO { + + @NotNull(message = "id 不能为空") + private Long id; + + @NotBlank(message = "酒店名称不能为空") + private String hotelName; + + private String coverImage; + + private String carouselImages; + + private String address; + + private String district; + + private Long districtAdcode; + + private String bizArea; + + private Integer starLevel; + + private BigDecimal score; + + private String facilityTags; + + private BigDecimal longitude; + + private BigDecimal latitude; + + private String phone; + + private BigDecimal minSellPrice; + + /** ACTIVE / OFFLINE */ + private String status; + + private String intro; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxStatusReqVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxStatusReqVO.java new file mode 100644 index 0000000..ebee849 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxStatusReqVO.java @@ -0,0 +1,18 @@ +package cn.iocoder.yudao.module.product.controller.admin.hotel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Schema(description = "管理后台 - 本地酒店上下架") +@Data +public class HotelZaxStatusReqVO { + + @NotNull(message = "id 不能为空") + private Long id; + + @NotBlank(message = "状态不能为空") + private String status; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/AppHotelController.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/AppHotelController.java new file mode 100644 index 0000000..a460b73 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/AppHotelController.java @@ -0,0 +1,192 @@ +package cn.iocoder.yudao.module.product.controller.app.hotel; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.product.framework.hotel.ZaxHotelOpenClient; +import cn.iocoder.yudao.module.product.service.hotel.HotelZaxLocalService; +import cn.iocoder.yudao.module.product.service.hotel.HotelZaxSyncService; +import cn.iocoder.yudao.module.product.service.hotel.HotelZaxTradeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.validation.annotation.Validated; +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; + +import javax.annotation.Resource; +import javax.annotation.security.PermitAll; +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 小如酒店:列表/详情本地;交易走供应方;支付在小如微信。 + */ +@Tag(name = "用户 APP - 酒店预订(资安心供应)") +@RestController +@RequestMapping("/hotel") +@Validated +public class AppHotelController { + + @Resource + private ZaxHotelOpenClient zaxHotelOpenClient; + + @Resource + private HotelZaxLocalService hotelZaxLocalService; + + @Resource + private HotelZaxSyncService hotelZaxSyncService; + + @Resource + private HotelZaxTradeService hotelZaxTradeService; + + @PermitAll + @PostMapping("/list") + @Operation(summary = "酒店列表(优先本地)") + public CommonResult list(@RequestBody(required = false) Map body) { + if (hotelZaxLocalService.hasLocalData()) { + return success(hotelZaxLocalService.pageList(body)); + } + return success(dataOf(zaxHotelOpenClient.post("hotel/list", body))); + } + + @PermitAll + @PostMapping("/filter-meta") + @Operation(summary = "筛选项(区域等,优先本地)") + public CommonResult filterMeta(@RequestBody(required = false) Map body) { + if (hotelZaxLocalService.hasLocalData()) { + return success(hotelZaxLocalService.filterMeta()); + } + return success(dataOf(zaxHotelOpenClient.post("hotel/filter-meta", + body == null ? new HashMap<>() : body))); + } + + @PermitAll + @PostMapping("/detail") + @Operation(summary = "酒店详情(优先本地)") + public CommonResult detail(@RequestBody Map body) { + String hotelCode = body == null ? null : str(body.get("hotelCode")); + Map local = hotelZaxLocalService.detail(hotelCode); + if (local != null) { + return success(local); + } + return success(dataOf(zaxHotelOpenClient.post("hotel/detail", body))); + } + + @PermitAll + @PostMapping("/daily") + @Operation(summary = "价态日历(有本地日历用本地,否则供应方)") + public CommonResult daily(@RequestBody Map body) { + Map local = hotelZaxLocalService.daily(body); + if (local != null) { + return success(local); + } + return success(dataOf(zaxHotelOpenClient.post("hotel/daily", body))); + } + + @PostMapping("/order/check") + @Operation(summary = "可订校验") + public CommonResult orderCheck(@RequestBody Map body) { + return success(dataOf(zaxHotelOpenClient.post("order/check", body))); + } + + @PostMapping("/order/create") + @Operation(summary = "创建订单并发起微信支付") + public CommonResult orderCreate(@RequestBody Map body) { + return success(hotelZaxTradeService.createOrderAndPay(getLoginUserId(), body)); + } + + @PostMapping("/order/cancel") + @Operation(summary = "取消订单(已支付则退款,并通知资安心)") + public CommonResult orderCancel(@RequestBody Map body) { + String channelNo = body == null ? null : str(body.get("channelNo")); + hotelZaxTradeService.cancelByChannelNo(getLoginUserId(), channelNo, "用户取消"); + Map local = hotelZaxTradeService.getByChannelNo(getLoginUserId(), channelNo); + return success(local); + } + + @PostMapping("/order/page") + @Operation(summary = "我的酒店订单分页") + public CommonResult>> orderPage( + @RequestBody(required = false) Map body) { + Integer pageNo = body == null || body.get("pageNo") == null ? 1 + : Integer.valueOf(String.valueOf(body.get("pageNo"))); + Integer pageSize = body == null || body.get("pageSize") == null ? 10 + : Integer.valueOf(String.valueOf(body.get("pageSize"))); + String orderStatus = body == null || body.get("orderStatus") == null ? null + : String.valueOf(body.get("orderStatus")).trim(); + Boolean payStatus = null; + if (body != null && body.get("payStatus") != null && !"".equals(String.valueOf(body.get("payStatus")))) { + payStatus = Boolean.valueOf(String.valueOf(body.get("payStatus"))); + } + return success(hotelZaxTradeService.pageMyOrders(getLoginUserId(), pageNo, pageSize, + StrUtil.blankToDefault(orderStatus, null), payStatus)); + } + + @PostMapping("/order/query") + @Operation(summary = "查询订单(优先本地,再查供应方)") + public CommonResult orderQuery(@RequestBody Map body) { + Long uid = getLoginUserId(); + String channelNo = body == null ? null : str(body.get("channelNo")); + Long payOrderId = body == null || body.get("payOrderId") == null ? null + : Long.valueOf(String.valueOf(body.get("payOrderId"))); + Map local = null; + if (payOrderId != null) { + local = hotelZaxTradeService.getByPayOrderId(uid, payOrderId); + } + if (local == null && StrUtil.isNotBlank(channelNo)) { + local = hotelZaxTradeService.getByChannelNo(uid, channelNo); + } + if (local != null) { + boolean paid = Boolean.TRUE.equals(local.get("payStatus")); + boolean booked = local.get("orderNo") != null && StrUtil.isNotBlank(String.valueOf(local.get("orderNo"))); + if (paid && booked) { + try { + Map q = new HashMap<>(2); + q.put("channelNo", local.get("channelNo")); + Object remote = dataOf(zaxHotelOpenClient.post("order/query", q)); + if (remote instanceof Map) { + Map r = (Map) remote; + if (r.get("orderStatus") != null) { + local.put("orderStatus", r.get("orderStatus")); + local.put("status", r.get("orderStatus")); + } + if (r.get("bookStatus") != null) { + local.put("bookStatus", r.get("bookStatus")); + } + if (r.get("platformNo") != null) { + local.put("platformNo", r.get("platformNo")); + } + } + } + catch (Exception ignored) { + // 本地单仍可展示 + } + } + return success(local); + } + return success(dataOf(zaxHotelOpenClient.post("order/query", body == null ? new HashMap<>() : body))); + } + + @PostMapping("/sync") + @Operation(summary = "手动触发本地同步(管理/联调)") + public CommonResult sync() { + return success(hotelZaxSyncService.syncOnce()); + } + + private static Object dataOf(JSONObject r) { + if (r == null) { + return null; + } + return r.get("data"); + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/HotelZaxOpenCallbackController.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/HotelZaxOpenCallbackController.java new file mode 100644 index 0000000..1229852 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/HotelZaxOpenCallbackController.java @@ -0,0 +1,109 @@ +package cn.iocoder.yudao.module.product.controller.app.hotel; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.product.framework.hotel.ZaxHotelOpenCallbackAuth; +import cn.iocoder.yudao.module.product.framework.hotel.ZaxHotelOpenProperties; +import cn.iocoder.yudao.module.product.service.hotel.HotelZaxOrderCallbackService; +import cn.iocoder.yudao.module.product.service.hotel.HotelZaxSyncService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.annotation.security.PermitAll; +import javax.servlet.http.HttpServletRequest; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 资安心 → 小如:开放平台回调接收(与第三方下游约定路径一致,便于把小如当标准样板)。 + *

+ * 资安心开放应用配置示例: + *

    + *
  • callbackBaseUrl = http://{小如}:{port}/app-api
  • + *
  • callbackResourcePath = /hotel/resource/notify
  • + *
  • callbackOrderPath = /order/status/notify
  • + *
+ */ +@Slf4j +@Tag(name = "用户 APP - 资安心酒店开放回调") +@RestController +@Validated +public class HotelZaxOpenCallbackController { + + private static final ExecutorService ASYNC = Executors.newFixedThreadPool(2, r -> { + Thread t = new Thread(r, "hotel-zax-callback"); + t.setDaemon(true); + return t; + }); + + @Resource + private ZaxHotelOpenCallbackAuth callbackAuth; + + @Resource + private ZaxHotelOpenProperties openProperties; + + @Resource + private HotelZaxSyncService hotelZaxSyncService; + + @Resource + private HotelZaxOrderCallbackService orderCallbackService; + + @PermitAll + @PostMapping("/hotel/resource/notify") + @Operation(summary = "资源变更回调(验签后异步拉 detail)") + public CommonResult> resourceNotify(HttpServletRequest request, + @RequestBody String rawBody) { + callbackAuth.assertValid(request, rawBody); + JSONObject body = JSONUtil.parseObj(StrUtil.blankToDefault(rawBody, "{}")); + String hotelCode = StrUtil.trim(body.getStr("hotelCode")); + String changeType = StrUtil.blankToDefault(body.getStr("changeType"), "HOTEL_UPSERT"); + if (StrUtil.isBlank(hotelCode)) { + throw new IllegalArgumentException("hotelCode 不能为空"); + } + Long tenantId = openProperties.getCallbackTenantId() == null ? 1L : openProperties.getCallbackTenantId(); + // 先快速 ACK,再异步同步,避免供应方回调超时 + ASYNC.execute(() -> TenantUtils.execute(tenantId, () -> { + try { + if ("HOTEL_DELETE".equalsIgnoreCase(changeType) || "HOTEL_OFFLINE".equalsIgnoreCase(changeType) + || "OFFLINE".equalsIgnoreCase(changeType) || "DELETE".equalsIgnoreCase(changeType)) { + hotelZaxSyncService.markOffline(hotelCode); + } + else { + hotelZaxSyncService.syncHotelByCode(hotelCode); + } + } + catch (Exception e) { + log.warn("|hotel-zax-callback|资源同步失败|code={}|type={}|{}", hotelCode, changeType, e.getMessage()); + } + })); + Map ack = new LinkedHashMap<>(4); + ack.put("hotelCode", hotelCode); + ack.put("changeType", changeType); + ack.put("accepted", true); + return success(ack); + } + + @PermitAll + @PostMapping({"/order/status/notify", "/hotel/order/status/notify"}) + @Operation(summary = "订单状态回调") + public CommonResult> orderStatusNotify(HttpServletRequest request, + @RequestBody String rawBody) { + callbackAuth.assertValid(request, rawBody); + Long tenantId = openProperties.getCallbackTenantId() == null ? 1L : openProperties.getCallbackTenantId(); + Map result = TenantUtils.execute(tenantId, () -> orderCallbackService.upsertFromNotify(rawBody)); + return success(result); + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxDO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxDO.java new file mode 100644 index 0000000..6c80b71 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxDO.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.product.dal.dataobject.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 资安心酒店本地镜像(开放平台同步落库,C 端读本地)。 + */ +@TableName("hotel_zax") +@KeySequence("hotel_zax_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HotelZaxDO extends BaseDO { + + @TableId + private Long id; + + private String hotelCode; + + private String hotelName; + + private String groupCode; + + private String coverImage; + + private String carouselImages; + + private String address; + + private String district; + + private Long districtAdcode; + + private String bizArea; + + private Integer starLevel; + + private BigDecimal score; + + private String facilityTags; + + private BigDecimal longitude; + + private BigDecimal latitude; + + private String phone; + + private BigDecimal minSellPrice; + + /** ACTIVE / OFFLINE */ + private String status; + + private String intro; + + private String roomsJson; + + private String productsJson; + + private LocalDateTime supplierUpdateTime; + + private LocalDateTime syncedAt; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxDailyDO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxDailyDO.java new file mode 100644 index 0000000..e220eab --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxDailyDO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.product.dal.dataobject.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 对齐供应侧 canteen_hotel_daily。 + */ +@TableName("hotel_zax_daily") +@KeySequence("hotel_zax_daily_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HotelZaxDailyDO extends BaseDO { + + @TableId + private Long id; + + private Long hotelId; + + private String hotelCode; + + private Long productId; + + private String productCode; + + private String roomTypeCode; + + private LocalDate stayDate; + + private BigDecimal costPrice; + + private BigDecimal sellPrice; + + private Integer stock; + + private Integer sold; + + /** 0关 1开 */ + private Integer dailyStatus; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxOrderDO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxOrderDO.java new file mode 100644 index 0000000..7f52ab9 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxOrderDO.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.product.dal.dataobject.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 小如酒店订单(供应侧下单 + 本地微信支付)。 + */ +@TableName("hotel_zax_order") +@KeySequence("hotel_zax_order_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HotelZaxOrderDO extends BaseDO { + + @TableId + private Long id; + + private Long userId; + + private String channelNo; + + private String orderNo; + + private String platformNo; + + private String orderStatus; + + private String bookStatus; + + private String hotelCode; + + private String hotelName; + + private String productCode; + + private String productName; + + private String roomTypeCode; + + private LocalDate checkInDate; + + private LocalDate checkOutDate; + + private Integer nights; + + private Integer roomCount; + + private String contactName; + + private String contactPhone; + + private String guestJson; + + private Long payOrderId; + + private Boolean payStatus; + + private LocalDateTime payTime; + + private BigDecimal sellAmount; + + private String failMsg; + + private String payloadJson; + + private LocalDateTime lastNotifyAt; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxProductDO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxProductDO.java new file mode 100644 index 0000000..15d9ca2 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxProductDO.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.product.dal.dataobject.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.math.BigDecimal; + +/** + * 对齐供应侧 canteen_hotel_product。 + */ +@TableName("hotel_zax_product") +@KeySequence("hotel_zax_product_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HotelZaxProductDO extends BaseDO { + + @TableId + private Long id; + + private Long hotelId; + + private String hotelCode; + + private String groupCode; + + private String roomTypeCode; + + private String productCode; + + private String productName; + + private String priceCode; + + private String cancelCode; + + private Integer mealType; + + private Integer mealCount; + + private BigDecimal sellPrice; + + private Integer stock; + + private String rawJson; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxRoomDO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxRoomDO.java new file mode 100644 index 0000000..e006d15 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxRoomDO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.product.dal.dataobject.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + * 对齐供应侧 canteen_hotel_room。 + */ +@TableName("hotel_zax_room") +@KeySequence("hotel_zax_room_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HotelZaxRoomDO extends BaseDO { + + @TableId + private Long id; + + private Long hotelId; + + private String hotelCode; + + private String groupCode; + + private String roomTypeCode; + + private String roomTypeName; + + private String bedType; + + private Integer capacity; + + private Integer roomNumber; + + private String rawJson; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxSyncCursorDO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxSyncCursorDO.java new file mode 100644 index 0000000..81e002b --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/dataobject/hotel/HotelZaxSyncCursorDO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.product.dal.dataobject.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.time.LocalDateTime; + +@TableName("hotel_zax_sync_cursor") +@KeySequence("hotel_zax_sync_cursor_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HotelZaxSyncCursorDO extends BaseDO { + + @TableId + private Long id; + + private String cursorKey; + + private String updatedSince; + + private LocalDateTime lastSyncAt; + + private String lastMsg; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxDailyMapper.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxDailyMapper.java new file mode 100644 index 0000000..0ad1cdf --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxDailyMapper.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.product.dal.mysql.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxDailyDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HotelZaxDailyMapper extends BaseMapperX { +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxMapper.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxMapper.java new file mode 100644 index 0000000..236cf94 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxMapper.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.product.dal.mysql.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HotelZaxMapper extends BaseMapperX { +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxOrderMapper.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxOrderMapper.java new file mode 100644 index 0000000..ca93ffe --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxOrderMapper.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.product.dal.mysql.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxOrderDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HotelZaxOrderMapper extends BaseMapperX { +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxProductMapper.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxProductMapper.java new file mode 100644 index 0000000..7315205 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxProductMapper.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.product.dal.mysql.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxProductDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HotelZaxProductMapper extends BaseMapperX { +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxRoomMapper.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxRoomMapper.java new file mode 100644 index 0000000..ac1a473 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxRoomMapper.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.product.dal.mysql.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxRoomDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HotelZaxRoomMapper extends BaseMapperX { +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxSyncCursorMapper.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxSyncCursorMapper.java new file mode 100644 index 0000000..79a6637 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/dal/mysql/hotel/HotelZaxSyncCursorMapper.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.product.dal.mysql.hotel; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxSyncCursorDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface HotelZaxSyncCursorMapper extends BaseMapperX { +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenCallbackAuth.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenCallbackAuth.java new file mode 100644 index 0000000..8cfa046 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenCallbackAuth.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.product.framework.hotel; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * 校验资安心开放平台回调签名(与开放 API 同一套 MD5 规则)。 + */ +@Component +@RequiredArgsConstructor +public class ZaxHotelOpenCallbackAuth { + + private static final long SKEW_MS = 5L * 60L * 1000L; + + private final ZaxHotelOpenProperties properties; + + public void assertValid(HttpServletRequest request, String rawBody) { + if (!properties.isEnabled()) { + throw new IllegalStateException("酒店中转未启用"); + } + String appKey = StrUtil.trim(request.getHeader("appKey")); + String timestamp = StrUtil.trim(request.getHeader("timestamp")); + String nonce = StrUtil.trim(request.getHeader("nonce")); + String sign = StrUtil.trim(request.getHeader("sign")); + if (StrUtil.hasBlank(appKey, timestamp, nonce, sign)) { + throw new IllegalArgumentException("缺少鉴权头 appKey/timestamp/nonce/sign"); + } + if (!appKey.equals(properties.getAppKey())) { + throw new IllegalArgumentException("appKey 不匹配"); + } + long ts; + try { + ts = Long.parseLong(timestamp); + } + catch (Exception e) { + throw new IllegalArgumentException("timestamp 无效"); + } + if (Math.abs(System.currentTimeMillis() - ts) > SKEW_MS) { + throw new IllegalArgumentException("timestamp 超出允许时差"); + } + String body = StrUtil.isBlank(rawBody) ? "{}" : rawBody; + String expect = sign(properties.getAppKey(), properties.getAppSecret(), timestamp, nonce, body); + if (!expect.equalsIgnoreCase(sign)) { + throw new IllegalArgumentException("签名校验失败"); + } + } + + private static String sign(String appKey, String appSecret, String timestamp, String nonce, String bodyJson) { + Map params = new TreeMap<>(); + params.put("appKey", StrUtil.nullToEmpty(appKey)); + params.put("body", StrUtil.nullToEmpty(bodyJson)); + params.put("nonce", StrUtil.nullToEmpty(nonce)); + params.put("timestamp", StrUtil.nullToEmpty(timestamp)); + String stringA = params.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining("&")); + return DigestUtil.md5Hex(stringA + "&appSecret=" + StrUtil.nullToEmpty(appSecret)).toUpperCase(); + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenClient.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenClient.java new file mode 100644 index 0000000..cb6808a --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenClient.java @@ -0,0 +1,86 @@ +package cn.iocoder.yudao.module.product.framework.hotel; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * 调用资安心酒店下游开放 API(服务端代签)。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ZaxHotelOpenClient { + + private final ZaxHotelOpenProperties properties; + + public JSONObject post(String apiPath, Map body) { + if (!properties.isEnabled()) { + throw new IllegalStateException("酒店中转未启用,请配置 xrhome.hotel.zax.enabled=true"); + } + if (StrUtil.hasBlank(properties.getBaseUrl(), properties.getAppKey(), properties.getAppSecret())) { + throw new IllegalStateException("酒店中转缺少 baseUrl/appKey/appSecret 配置"); + } + String bodyJson = body == null || body.isEmpty() ? "{}" : JSONUtil.toJsonStr(body); + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = IdUtil.fastSimpleUUID(); + String sign = sign(properties.getAppKey(), properties.getAppSecret(), timestamp, nonce, bodyJson); + String url = trimSlash(properties.getBaseUrl()) + "/" + StrUtil.removePrefix(apiPath, "/"); + try (HttpResponse resp = HttpRequest.post(url) + .header("appKey", properties.getAppKey()) + .header("timestamp", timestamp) + .header("nonce", nonce) + .header("sign", sign) + .header("Content-Type", "application/json;charset=UTF-8") + .body(bodyJson) + .setConnectionTimeout(properties.getConnectTimeoutMs()) + .setReadTimeout(properties.getReadTimeoutMs()) + .execute()) { + String raw = resp.body(); + if (!resp.isOk()) { + log.warn("|zax-hotel|HTTP失败|url={}|status={}|body={}|", url, resp.getStatus(), + StrUtil.maxLength(raw, 300)); + throw new IllegalStateException("酒店供应方接口异常 HTTP " + resp.getStatus()); + } + JSONObject json = JSONUtil.parseObj(raw); + // 资安心 R:code=0 成功 + Integer code = json.getInt("code"); + if (code != null && code != 0) { + String msg = StrUtil.blankToDefault(json.getStr("msg"), "酒店供应方业务失败"); + log.warn("|zax-hotel|业务失败|url={}|code={}|msg={}|", url, code, StrUtil.maxLength(msg, 200)); + throw new IllegalStateException("供应方下单失败:" + msg); + } + return json; + } + } + + private static String sign(String appKey, String appSecret, String timestamp, String nonce, String bodyJson) { + Map params = new TreeMap<>(); + params.put("appKey", StrUtil.nullToEmpty(appKey)); + params.put("body", StrUtil.nullToEmpty(bodyJson)); + params.put("nonce", StrUtil.nullToEmpty(nonce)); + params.put("timestamp", StrUtil.nullToEmpty(timestamp)); + String stringA = params.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining("&")); + return DigestUtil.md5Hex(stringA + "&appSecret=" + StrUtil.nullToEmpty(appSecret)).toUpperCase(); + } + + private static String trimSlash(String url) { + String u = StrUtil.trim(url); + while (u.endsWith("/")) { + u = u.substring(0, u.length() - 1); + } + return u; + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenProperties.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenProperties.java new file mode 100644 index 0000000..4128e92 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenProperties.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.product.framework.hotel; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 对接资安心酒店开放平台(下游应用凭证放服务端,勿下发小程序)。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "xrhome.hotel.zax") +public class ZaxHotelOpenProperties { + + /** 是否启用中转 */ + private boolean enabled = false; + + /** + * 开放平台根地址,必须含到 /third/party/open(不要只配到 /api/supermarket)。 + * 例:http://localhost:9999/api/supermarket/third/party/open + * 拼上 hotel/list 后才是完整路径。 + */ + private String baseUrl = ""; + + private String appKey = ""; + + private String appSecret = ""; + + /** 连接超时毫秒 */ + private int connectTimeoutMs = 5000; + + /** 读超时毫秒 */ + private int readTimeoutMs = 20000; + + /** + * 供应方回调落库使用的租户(回调无租户头时用此值)。 + * 与小如运营租户一致,默认 1。 + */ + private Long callbackTenantId = 1L; + + /** + * 微信支付应用标识(支付管理→应用信息)。 + * 默认复用商城 mall;支付回调若命中「交易订单不存在」会再尝试酒店单。 + */ + private String payAppKey = "mall"; + + /** + * 同步详情时顺带拉取日历的天数(含今天)。0=不同步日历;最大 90(开放平台窗口上限)。 + */ + private int dailySyncDays = 90; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxDailySyncJob.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxDailySyncJob.java new file mode 100644 index 0000000..47088fe --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxDailySyncJob.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.product.job.hotel; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.product.framework.hotel.ZaxHotelOpenProperties; +import cn.iocoder.yudao.module.product.service.hotel.HotelZaxSyncService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.Map; + +/** + * 每天从资安心拉取酒店主数据、房型、产品与价态日历到本地。 + */ +@Slf4j +@Component +public class HotelZaxDailySyncJob implements JobHandler { + + @Resource + private HotelZaxSyncService hotelZaxSyncService; + + @Resource + private ZaxHotelOpenProperties openProperties; + + @Override + @TenantJob + public String execute(String param) { + boolean full = !StrUtil.equalsIgnoreCase(StrUtil.trim(param), "incr"); + Map r = hotelZaxSyncService.syncOnce(full); + return formatResult(full, r); + } + + /** 每天 03:20 全量同步(适配授权范围变更;增量水位拉不到新授权酒店) */ + @Scheduled(cron = "0 20 3 * * ?") + public void scheduleDaily() { + if (!openProperties.isEnabled()) { + return; + } + Long tenantId = openProperties.getCallbackTenantId() == null ? 1L : openProperties.getCallbackTenantId(); + TenantUtils.execute(tenantId, () -> { + try { + Map r = hotelZaxSyncService.syncOnce(true); + log.info("|hotel-zax-sync|每日定时全量|{}", formatResult(true, r)); + } + catch (Exception e) { + log.warn("|hotel-zax-sync|每日定时失败|{}", e.getMessage()); + } + }); + } + + private static String formatResult(boolean full, Map r) { + if (r == null) { + return full ? "全量同步完成" : "增量同步完成"; + } + return String.format("%s 写入%s 详情%s 日历%s条 日历失败%s 下架授权外%s", + full ? "全量" : "增量", + r.get("upsert"), + r.get("detailOk"), + r.get("dailyRows"), + r.get("dailyFail"), + r.get("offlineOutOfScope")); + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxUnpaidCancelJob.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxUnpaidCancelJob.java new file mode 100644 index 0000000..d3ec83b --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxUnpaidCancelJob.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.product.job.hotel; + +import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; +import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.product.service.hotel.HotelZaxTradeService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; + +/** + * 酒店待支付超时(10 分钟)自动取消。 + */ +@Slf4j +@Component +public class HotelZaxUnpaidCancelJob implements JobHandler { + + @Resource + private HotelZaxTradeService hotelZaxTradeService; + + @Override + @TenantJob + public String execute(String param) { + int count = hotelZaxTradeService.cancelUnpaidTimeout(); + return String.format("酒店超时未支付取消 %s 个", count); + } + + @Scheduled(cron = "20 * * * * ?") + public void schedule() { + TenantUtils.executeIgnore(() -> { + try { + int n = hotelZaxTradeService.cancelUnpaidTimeout(); + if (n > 0) { + log.info("|hotel-pay|定时取消超时未支付|count={}|", n); + } + } + catch (Exception e) { + log.warn("|hotel-pay|定时取消失败|{}", e.getMessage()); + } + }); + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminService.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminService.java new file mode 100644 index 0000000..edf3f17 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminService.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.product.service.hotel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailyBatchReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailyPageReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailyRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailySaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxOrderPageReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxOrderRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxPageReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxProductRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxProductSaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxRoomRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxRoomSaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxSaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxStatusReqVO; + +import java.util.Map; + +/** + * 小如后台:本地酒店维护(展示/编辑对齐供应侧后台能力)。 + */ +public interface HotelZaxAdminService { + + PageResult page(HotelZaxPageReqVO req); + + HotelZaxRespVO getDetail(Long id); + + void update(HotelZaxSaveReqVO req); + + void updateStatus(HotelZaxStatusReqVO req); + + HotelZaxRoomRespVO saveRoom(HotelZaxRoomSaveReqVO req); + + void removeRoom(Long hotelId, Long roomId); + + HotelZaxProductRespVO saveProduct(HotelZaxProductSaveReqVO req); + + void removeProduct(Long hotelId, Long productId); + + PageResult pageDaily(HotelZaxDailyPageReqVO req); + + HotelZaxDailyRespVO saveDaily(HotelZaxDailySaveReqVO req); + + void removeDaily(Long hotelId, Long dailyId); + + Map batchDaily(HotelZaxDailyBatchReqVO req); + + PageResult pageOrders(HotelZaxOrderPageReqVO req); + + HotelZaxOrderRespVO getOrder(Long id); + + /** 后台取消:已支付原路退款,并通知资安心取消预订 */ + void cancelOrder(Long id, String reason); +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminServiceImpl.java new file mode 100644 index 0000000..17c3e73 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminServiceImpl.java @@ -0,0 +1,657 @@ +package cn.iocoder.yudao.module.product.service.hotel; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailyBatchReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailyPageReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailyRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxDailySaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxOrderPageReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxOrderRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxPageReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxProductRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxProductSaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxRoomRespVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxRoomSaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxSaveReqVO; +import cn.iocoder.yudao.module.product.controller.admin.hotel.vo.HotelZaxStatusReqVO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxDailyDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxOrderDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxProductDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxRoomDO; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxDailyMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxOrderMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxProductMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxRoomMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.HOTEL_ZAX_DAILY_NOT_EXISTS; +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.HOTEL_ZAX_NOT_EXISTS; +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.HOTEL_ZAX_ORDER_NOT_EXISTS; +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.HOTEL_ZAX_PRODUCT_NOT_EXISTS; +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.HOTEL_ZAX_ROOM_NOT_EXISTS; + +@Service +@Validated +@RequiredArgsConstructor +public class HotelZaxAdminServiceImpl implements HotelZaxAdminService { + + private final HotelZaxMapper hotelZaxMapper; + + private final HotelZaxRoomMapper roomMapper; + + private final HotelZaxProductMapper productMapper; + + private final HotelZaxDailyMapper dailyMapper; + + private final HotelZaxOrderMapper orderMapper; + + private final HotelZaxTradeService hotelZaxTradeService; + + private static final int LOCAL_DAILY_BATCH_MAX_DAYS = 366; + + @Override + public PageResult page(HotelZaxPageReqVO req) { + PageResult page = hotelZaxMapper.selectPage(req, new LambdaQueryWrapperX() + .likeIfPresent(HotelZaxDO::getHotelName, req.getHotelName()) + .eqIfPresent(HotelZaxDO::getHotelCode, req.getHotelCode()) + .eqIfPresent(HotelZaxDO::getStatus, req.getStatus()) + .eqIfPresent(HotelZaxDO::getDistrictAdcode, req.getDistrictAdcode()) + .likeIfPresent(HotelZaxDO::getDistrict, req.getDistrict()) + .orderByDesc(HotelZaxDO::getId)); + PageResult result = BeanUtils.toBean(page, HotelZaxRespVO.class); + fillCounts(result.getList()); + return result; + } + + @Override + public HotelZaxRespVO getDetail(Long id) { + HotelZaxDO hotel = requireHotel(id); + HotelZaxRespVO vo = BeanUtils.toBean(hotel, HotelZaxRespVO.class); + List rooms = roomMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxRoomDO::getHotelId, id) + .orderByAsc(HotelZaxRoomDO::getRoomTypeCode)); + List products = productMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxProductDO::getHotelId, id) + .orderByAsc(HotelZaxProductDO::getProductCode)); + vo.setRooms(BeanUtils.toBean(rooms, HotelZaxRoomRespVO.class)); + vo.setProducts(BeanUtils.toBean(products, HotelZaxProductRespVO.class)); + vo.setRoomCount(rooms.size()); + vo.setProductCount(products.size()); + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void update(HotelZaxSaveReqVO req) { + HotelZaxDO hotel = requireHotel(req.getId()); + hotel.setHotelName(StrUtil.trim(req.getHotelName())); + hotel.setCoverImage(StrUtil.trim(req.getCoverImage())); + hotel.setCarouselImages(normalizeCsv(req.getCarouselImages())); + hotel.setAddress(StrUtil.trim(req.getAddress())); + hotel.setDistrict(StrUtil.trim(req.getDistrict())); + hotel.setDistrictAdcode(req.getDistrictAdcode()); + hotel.setBizArea(cleanJunk(req.getBizArea())); + hotel.setStarLevel(req.getStarLevel()); + hotel.setScore(req.getScore()); + hotel.setFacilityTags(normalizeCsv(req.getFacilityTags())); + hotel.setLongitude(req.getLongitude()); + hotel.setLatitude(req.getLatitude()); + hotel.setPhone(StrUtil.trim(req.getPhone())); + if (req.getMinSellPrice() != null) { + hotel.setMinSellPrice(req.getMinSellPrice()); + } + if (StrUtil.isNotBlank(req.getStatus())) { + hotel.setStatus(normalizeStatus(req.getStatus())); + } + hotel.setIntro(req.getIntro()); + hotelZaxMapper.updateById(hotel); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateStatus(HotelZaxStatusReqVO req) { + HotelZaxDO hotel = requireHotel(req.getId()); + hotel.setStatus(normalizeStatus(req.getStatus())); + hotelZaxMapper.updateById(hotel); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public HotelZaxRoomRespVO saveRoom(HotelZaxRoomSaveReqVO req) { + HotelZaxDO hotel = requireHotel(req.getHotelId()); + String code = StrUtil.trim(req.getRoomTypeCode()); + HotelZaxRoomDO row; + if (req.getId() == null) { + Long exists = roomMapper.selectCount(new LambdaQueryWrapperX() + .eq(HotelZaxRoomDO::getHotelId, hotel.getId()) + .eq(HotelZaxRoomDO::getRoomTypeCode, code)); + if (exists != null && exists > 0) { + throw invalidParamException("房型编码已存在:{}", code); + } + row = new HotelZaxRoomDO(); + } + else { + row = roomMapper.selectById(req.getId()); + if (row == null || !Objects.equals(row.getHotelId(), hotel.getId())) { + throw exception(HOTEL_ZAX_ROOM_NOT_EXISTS); + } + } + row.setHotelId(hotel.getId()); + row.setHotelCode(hotel.getHotelCode()); + row.setGroupCode(hotel.getGroupCode()); + row.setRoomTypeCode(code); + row.setRoomTypeName(StrUtil.trim(req.getRoomTypeName())); + row.setBedType(StrUtil.trim(req.getBedType())); + row.setCapacity(req.getCapacity()); + row.setRoomNumber(req.getRoomNumber()); + if (req.getId() == null) { + roomMapper.insert(row); + } + else { + roomMapper.updateById(row); + } + refreshHotelRoomsJson(hotel.getId()); + return BeanUtils.toBean(row, HotelZaxRoomRespVO.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeRoom(Long hotelId, Long roomId) { + requireHotel(hotelId); + HotelZaxRoomDO old = roomMapper.selectById(roomId); + if (old == null || !Objects.equals(old.getHotelId(), hotelId)) { + throw exception(HOTEL_ZAX_ROOM_NOT_EXISTS); + } + Long productCnt = productMapper.selectCount(new LambdaQueryWrapperX() + .eq(HotelZaxProductDO::getHotelId, hotelId) + .eq(HotelZaxProductDO::getRoomTypeCode, old.getRoomTypeCode())); + if (productCnt != null && productCnt > 0) { + throw invalidParamException("请先删除该房型下的产品"); + } + roomMapper.deleteById(roomId); + refreshHotelRoomsJson(hotelId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public HotelZaxProductRespVO saveProduct(HotelZaxProductSaveReqVO req) { + HotelZaxDO hotel = requireHotel(req.getHotelId()); + String roomTypeCode = StrUtil.trim(req.getRoomTypeCode()); + Long roomCnt = roomMapper.selectCount(new LambdaQueryWrapperX() + .eq(HotelZaxRoomDO::getHotelId, hotel.getId()) + .eq(HotelZaxRoomDO::getRoomTypeCode, roomTypeCode)); + if (roomCnt == null || roomCnt <= 0) { + throw invalidParamException("所属房型不存在,请先维护房型"); + } + String productCode = StrUtil.trim(req.getProductCode()); + HotelZaxProductDO row; + if (req.getId() == null) { + Long exists = productMapper.selectCount(new LambdaQueryWrapperX() + .eq(HotelZaxProductDO::getHotelId, hotel.getId()) + .eq(HotelZaxProductDO::getProductCode, productCode)); + if (exists != null && exists > 0) { + throw invalidParamException("产品编码已存在:{}", productCode); + } + row = new HotelZaxProductDO(); + } + else { + row = productMapper.selectById(req.getId()); + if (row == null || !Objects.equals(row.getHotelId(), hotel.getId())) { + throw exception(HOTEL_ZAX_PRODUCT_NOT_EXISTS); + } + } + row.setHotelId(hotel.getId()); + row.setHotelCode(hotel.getHotelCode()); + row.setGroupCode(hotel.getGroupCode()); + row.setRoomTypeCode(roomTypeCode); + row.setProductCode(productCode); + row.setProductName(StrUtil.trim(req.getProductName())); + row.setPriceCode(StrUtil.trim(req.getPriceCode())); + row.setCancelCode(StrUtil.trim(req.getCancelCode())); + row.setMealType(req.getMealType()); + row.setMealCount(req.getMealCount()); + row.setSellPrice(req.getSellPrice()); + row.setStock(req.getStock()); + if (req.getId() == null) { + productMapper.insert(row); + } + else { + productMapper.updateById(row); + } + refreshHotelProductsJson(hotel.getId()); + return BeanUtils.toBean(row, HotelZaxProductRespVO.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeProduct(Long hotelId, Long productId) { + requireHotel(hotelId); + HotelZaxProductDO old = productMapper.selectById(productId); + if (old == null || !Objects.equals(old.getHotelId(), hotelId)) { + throw exception(HOTEL_ZAX_PRODUCT_NOT_EXISTS); + } + dailyMapper.delete(new LambdaQueryWrapperX() + .eq(HotelZaxDailyDO::getHotelId, hotelId) + .eq(HotelZaxDailyDO::getProductCode, old.getProductCode())); + productMapper.deleteById(productId); + refreshHotelProductsJson(hotelId); + } + + @Override + public PageResult pageDaily(HotelZaxDailyPageReqVO req) { + requireHotel(req.getHotelId()); + LocalDate from = parseDay(req.getStayFrom()); + LocalDate to = parseDay(req.getStayTo()); + PageResult page = dailyMapper.selectPage(req, new LambdaQueryWrapperX() + .eq(HotelZaxDailyDO::getHotelId, req.getHotelId()) + .eq(StrUtil.isNotBlank(req.getProductCode()), HotelZaxDailyDO::getProductCode, + StrUtil.trim(req.getProductCode())) + .ge(from != null, HotelZaxDailyDO::getStayDate, from) + .le(to != null, HotelZaxDailyDO::getStayDate, to) + .orderByAsc(HotelZaxDailyDO::getStayDate) + .orderByAsc(HotelZaxDailyDO::getProductCode)); + PageResult result = BeanUtils.toBean(page, HotelZaxDailyRespVO.class); + if (CollUtil.isNotEmpty(result.getList())) { + for (HotelZaxDailyRespVO vo : result.getList()) { + fillRemain(vo); + } + } + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public HotelZaxDailyRespVO saveDaily(HotelZaxDailySaveReqVO req) { + HotelZaxDO hotel = requireHotel(req.getHotelId()); + HotelZaxProductDO product = requireProduct(hotel.getId(), req.getProductCode()); + int stock = req.getStock() == null ? 0 : req.getStock(); + int status = req.getDailyStatus() == null ? 1 : req.getDailyStatus(); + HotelZaxDailyDO row; + if (req.getId() == null) { + HotelZaxDailyDO exist = dailyMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxDailyDO::getHotelId, hotel.getId()) + .eq(HotelZaxDailyDO::getProductCode, product.getProductCode()) + .eq(HotelZaxDailyDO::getStayDate, req.getStayDate()) + .last("limit 1")); + if (exist != null) { + row = exist; + } + else { + row = new HotelZaxDailyDO(); + row.setSold(0); + } + } + else { + row = dailyMapper.selectById(req.getId()); + if (row == null || !Objects.equals(row.getHotelId(), hotel.getId())) { + throw exception(HOTEL_ZAX_DAILY_NOT_EXISTS); + } + } + int sold = row.getSold() == null ? 0 : row.getSold(); + if (stock < sold) { + throw invalidParamException("库存不能小于已售 {}", sold); + } + row.setHotelId(hotel.getId()); + row.setHotelCode(hotel.getHotelCode()); + row.setProductId(product.getId()); + row.setProductCode(product.getProductCode()); + row.setRoomTypeCode(product.getRoomTypeCode()); + row.setStayDate(req.getStayDate()); + row.setCostPrice(req.getCostPrice() == null ? BigDecimal.ZERO : req.getCostPrice()); + row.setSellPrice(req.getSellPrice()); + row.setStock(stock); + row.setSold(sold); + row.setDailyStatus(status); + if (row.getId() == null) { + dailyMapper.insert(row); + } + else { + dailyMapper.updateById(row); + } + return toDailyVo(row); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeDaily(Long hotelId, Long dailyId) { + requireHotel(hotelId); + HotelZaxDailyDO old = dailyMapper.selectById(dailyId); + if (old == null || !Objects.equals(old.getHotelId(), hotelId)) { + throw exception(HOTEL_ZAX_DAILY_NOT_EXISTS); + } + dailyMapper.deleteById(dailyId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map batchDaily(HotelZaxDailyBatchReqVO req) { + HotelZaxDO hotel = requireHotel(req.getHotelId()); + if (req.getEndDate().isBefore(req.getStartDate())) { + throw invalidParamException("结束日期不能早于开始日期"); + } + long dayCount = req.getEndDate().toEpochDay() - req.getStartDate().toEpochDay() + 1; + if (dayCount > LOCAL_DAILY_BATCH_MAX_DAYS) { + throw invalidParamException("单次最多生成/调整 {} 天", LOCAL_DAILY_BATCH_MAX_DAYS); + } + String mode = StrUtil.blankToDefault(req.getMode(), "").trim().toUpperCase(Locale.ROOT); + boolean fillMode = "FILL".equals(mode); + boolean adjustMode = "ADJUST".equals(mode); + if (!fillMode && !adjustMode) { + throw invalidParamException("mode 仅支持 FILL(补齐缺失)或 ADJUST(调整已有)"); + } + boolean fillMissing = Boolean.TRUE.equals(req.getFillMissing()); + if (adjustMode && req.getCostPrice() == null && req.getSellPrice() == null && req.getStock() == null + && req.getDailyStatus() == null && !fillMissing) { + throw invalidParamException("调整模式下请至少填写一项:成本价/卖价/配额/房态,或勾选同时补齐缺失"); + } + List products = loadProductsForBatch(hotel.getId(), req.getProductCodes()); + if (products.isEmpty()) { + throw invalidParamException("没有可操作的产品,请先维护产品"); + } + for (HotelZaxProductDO p : products) { + if (p.getSellPrice() == null && req.getSellPrice() == null && (fillMode || fillMissing)) { + throw invalidParamException("产品 {} 未设置卖价,请先维护产品或在本次填写覆盖卖价", p.getProductCode()); + } + } + List productCodes = products.stream().map(HotelZaxProductDO::getProductCode).collect(Collectors.toList()); + List existRows = dailyMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxDailyDO::getHotelId, hotel.getId()) + .in(HotelZaxDailyDO::getProductCode, productCodes) + .ge(HotelZaxDailyDO::getStayDate, req.getStartDate()) + .le(HotelZaxDailyDO::getStayDate, req.getEndDate())); + Map existMap = new HashMap<>(Math.max(16, existRows.size() * 2)); + for (HotelZaxDailyDO d : existRows) { + existMap.put(d.getProductCode() + "|" + d.getStayDate(), d); + } + int inserted = 0; + int updated = 0; + int skippedExist = 0; + int skippedStock = 0; + for (HotelZaxProductDO product : products) { + for (LocalDate day = req.getStartDate(); !day.isAfter(req.getEndDate()); day = day.plusDays(1)) { + String key = product.getProductCode() + "|" + day; + HotelZaxDailyDO exist = existMap.get(key); + if (exist == null) { + if (fillMode || (adjustMode && fillMissing)) { + HotelZaxDailyDO row = buildDailyFromProduct(hotel, product, day, req); + dailyMapper.insert(row); + inserted++; + } + continue; + } + if (fillMode) { + skippedExist++; + continue; + } + int sold = exist.getSold() == null ? 0 : exist.getSold(); + Integer newStock = req.getStock() != null ? req.getStock() : exist.getStock(); + if (newStock == null) { + newStock = 0; + } + if (newStock < sold) { + skippedStock++; + continue; + } + if (req.getCostPrice() != null) { + exist.setCostPrice(req.getCostPrice()); + } + if (req.getSellPrice() != null) { + exist.setSellPrice(req.getSellPrice()); + } + if (req.getStock() != null) { + exist.setStock(req.getStock()); + } + if (req.getDailyStatus() != null) { + exist.setDailyStatus(req.getDailyStatus()); + } + exist.setSold(sold); + dailyMapper.updateById(exist); + updated++; + } + } + Map out = new LinkedHashMap<>(8); + out.put("inserted", inserted); + out.put("updated", updated); + out.put("skippedExist", skippedExist); + out.put("skippedStock", skippedStock); + return out; + } + + private List loadProductsForBatch(Long hotelId, List productCodes) { + LambdaQueryWrapperX w = new LambdaQueryWrapperX() + .eq(HotelZaxProductDO::getHotelId, hotelId); + if (CollUtil.isNotEmpty(productCodes)) { + List codes = productCodes.stream().filter(StrUtil::isNotBlank).map(String::trim).distinct() + .collect(Collectors.toList()); + if (codes.isEmpty()) { + return Collections.emptyList(); + } + w.in(HotelZaxProductDO::getProductCode, codes); + } + return productMapper.selectList(w); + } + + private HotelZaxDailyDO buildDailyFromProduct(HotelZaxDO hotel, HotelZaxProductDO product, LocalDate day, + HotelZaxDailyBatchReqVO req) { + HotelZaxDailyDO row = new HotelZaxDailyDO(); + row.setHotelId(hotel.getId()); + row.setHotelCode(hotel.getHotelCode()); + row.setProductId(product.getId()); + row.setProductCode(product.getProductCode()); + row.setRoomTypeCode(product.getRoomTypeCode()); + row.setStayDate(day); + row.setCostPrice(req.getCostPrice() != null ? req.getCostPrice() : BigDecimal.ZERO); + row.setSellPrice(req.getSellPrice() != null ? req.getSellPrice() : product.getSellPrice()); + row.setStock(req.getStock() != null ? req.getStock() : (product.getStock() == null ? 0 : product.getStock())); + row.setSold(0); + row.setDailyStatus(req.getDailyStatus() != null ? req.getDailyStatus() : 1); + return row; + } + + private HotelZaxProductDO requireProduct(Long hotelId, String productCode) { + HotelZaxProductDO product = productMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxProductDO::getHotelId, hotelId) + .eq(HotelZaxProductDO::getProductCode, StrUtil.trim(productCode)) + .last("limit 1")); + if (product == null) { + throw invalidParamException("产品不存在,请先维护产品"); + } + return product; + } + + private static HotelZaxDailyRespVO toDailyVo(HotelZaxDailyDO row) { + HotelZaxDailyRespVO vo = BeanUtils.toBean(row, HotelZaxDailyRespVO.class); + fillRemain(vo); + return vo; + } + + private static void fillRemain(HotelZaxDailyRespVO vo) { + if (vo == null) { + return; + } + int stock = vo.getStock() == null ? 0 : vo.getStock(); + int sold = vo.getSold() == null ? 0 : vo.getSold(); + vo.setRemainStock(Math.max(0, stock - sold)); + } + + private static LocalDate parseDay(String raw) { + if (StrUtil.isBlank(raw)) { + return null; + } + try { + String s = raw.trim().replace('/', '-'); + if (s.length() >= 10) { + s = s.substring(0, 10); + } + return LocalDate.parse(s); + } + catch (Exception e) { + return null; + } + } + + private void fillCounts(List list) { + if (CollUtil.isEmpty(list)) { + return; + } + List ids = list.stream().map(HotelZaxRespVO::getId).filter(Objects::nonNull).collect(Collectors.toList()); + if (ids.isEmpty()) { + return; + } + List rooms = roomMapper.selectList(new LambdaQueryWrapperX() + .in(HotelZaxRoomDO::getHotelId, ids) + .select(HotelZaxRoomDO::getHotelId)); + List products = productMapper.selectList(new LambdaQueryWrapperX() + .in(HotelZaxProductDO::getHotelId, ids) + .select(HotelZaxProductDO::getHotelId)); + Map roomMap = new HashMap<>(); + Map productMap = new HashMap<>(); + for (HotelZaxRoomDO r : rooms) { + if (r != null && r.getHotelId() != null) { + roomMap.merge(r.getHotelId(), 1, Integer::sum); + } + } + for (HotelZaxProductDO p : products) { + if (p != null && p.getHotelId() != null) { + productMap.merge(p.getHotelId(), 1, Integer::sum); + } + } + for (HotelZaxRespVO vo : list) { + vo.setRoomCount(roomMap.getOrDefault(vo.getId(), 0)); + vo.setProductCount(productMap.getOrDefault(vo.getId(), 0)); + } + } + + private void refreshHotelRoomsJson(Long hotelId) { + HotelZaxDO hotel = hotelZaxMapper.selectById(hotelId); + if (hotel == null) { + return; + } + List rooms = roomMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxRoomDO::getHotelId, hotelId) + .orderByAsc(HotelZaxRoomDO::getRoomTypeCode)); + List> arr = rooms.stream().map(r -> { + Map m = new HashMap<>(6); + m.put("roomTypeCode", r.getRoomTypeCode()); + m.put("roomTypeName", r.getRoomTypeName()); + m.put("bedType", r.getBedType()); + m.put("capacity", r.getCapacity()); + return m; + }).collect(Collectors.toList()); + hotel.setRoomsJson(JSONUtil.toJsonStr(arr)); + hotelZaxMapper.updateById(hotel); + } + + private void refreshHotelProductsJson(Long hotelId) { + HotelZaxDO hotel = hotelZaxMapper.selectById(hotelId); + if (hotel == null) { + return; + } + List products = productMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxProductDO::getHotelId, hotelId) + .orderByAsc(HotelZaxProductDO::getProductCode)); + List> arr = products.stream().map(p -> { + Map m = new HashMap<>(8); + m.put("productCode", p.getProductCode()); + m.put("productName", p.getProductName()); + m.put("roomTypeCode", p.getRoomTypeCode()); + m.put("priceCode", p.getPriceCode()); + m.put("mealType", p.getMealType()); + m.put("mealCount", p.getMealCount()); + m.put("sellPrice", p.getSellPrice()); + return m; + }).collect(Collectors.toList()); + hotel.setProductsJson(JSONUtil.toJsonStr(arr)); + hotelZaxMapper.updateById(hotel); + } + + private HotelZaxDO requireHotel(Long id) { + HotelZaxDO hotel = hotelZaxMapper.selectById(id); + if (hotel == null) { + throw exception(HOTEL_ZAX_NOT_EXISTS); + } + return hotel; + } + + private static String normalizeStatus(String status) { + String s = StrUtil.trim(status); + if ("OFFLINE".equalsIgnoreCase(s)) { + return "OFFLINE"; + } + return "ACTIVE"; + } + + @Override + public PageResult pageOrders(HotelZaxOrderPageReqVO req) { + PageResult page = orderMapper.selectPage(req, new LambdaQueryWrapperX() + .likeIfPresent(HotelZaxOrderDO::getHotelName, req.getHotelName()) + .eqIfPresent(HotelZaxOrderDO::getHotelCode, req.getHotelCode()) + .eqIfPresent(HotelZaxOrderDO::getChannelNo, req.getChannelNo()) + .eqIfPresent(HotelZaxOrderDO::getOrderNo, req.getOrderNo()) + .eqIfPresent(HotelZaxOrderDO::getOrderStatus, req.getOrderStatus()) + .eqIfPresent(HotelZaxOrderDO::getPayStatus, req.getPayStatus()) + .eqIfPresent(HotelZaxOrderDO::getContactPhone, req.getContactPhone()) + .eqIfPresent(HotelZaxOrderDO::getUserId, req.getUserId()) + .orderByDesc(HotelZaxOrderDO::getId)); + return BeanUtils.toBean(page, HotelZaxOrderRespVO.class); + } + + @Override + public HotelZaxOrderRespVO getOrder(Long id) { + if (id == null) { + throw invalidParamException("订单 id 不能为空"); + } + HotelZaxOrderDO order = orderMapper.selectById(id); + if (order == null) { + throw exception(HOTEL_ZAX_ORDER_NOT_EXISTS); + } + return BeanUtils.toBean(order, HotelZaxOrderRespVO.class); + } + + @Override + public void cancelOrder(Long id, String reason) { + hotelZaxTradeService.cancelOrder(id, StrUtil.blankToDefault(reason, "后台取消"), true); + } + + private static String normalizeCsv(String v) { + if (StrUtil.isBlank(v)) { + return null; + } + return StrUtil.splitTrim(v, ',').stream().filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + private static String cleanJunk(String v) { + if (StrUtil.isBlank(v)) { + return null; + } + String s = v.trim(); + if ("[]".equals(s) || "{}".equals(s) || "()".equals(s)) { + return null; + } + return s; + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalService.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalService.java new file mode 100644 index 0000000..fa16ec0 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalService.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.product.service.hotel; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** + * 小如本地酒店查询(C 端读库)。 + */ +public interface HotelZaxLocalService { + + Map pageList(Map body); + + Map detail(String hotelCode); + + Map filterMeta(); + + /** 本地是否已有上架数据 */ + boolean hasLocalData(); + + /** + * 本地日历;无本地日历时返回 null,由调用方回退供应方。 + */ + Map daily(Map body); + + /** + * 按日历逐夜累计房价(入住日含、离店日不含)× 间数。 + * 任一夜无开房日历或库存不足则返回 null。 + */ + BigDecimal quoteStay(String hotelCode, String productCode, LocalDate checkIn, LocalDate checkOut, int rooms); + + /** 用已查到的日历行报价,规则同 quoteStay。 */ + BigDecimal quoteFromDailyRows(List> rows, String productCode, LocalDate checkIn, + LocalDate checkOut, int rooms); +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalServiceImpl.java new file mode 100644 index 0000000..e6fc112 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxLocalServiceImpl.java @@ -0,0 +1,444 @@ +package cn.iocoder.yudao.module.product.service.hotel; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxDailyDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxProductDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxRoomDO; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxDailyMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxProductMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxRoomMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class HotelZaxLocalServiceImpl implements HotelZaxLocalService { + + private final HotelZaxMapper hotelZaxMapper; + + private final HotelZaxRoomMapper roomMapper; + + private final HotelZaxProductMapper productMapper; + + private final HotelZaxDailyMapper dailyMapper; + + @Override + public boolean hasLocalData() { + return hotelZaxMapper.selectCount(new LambdaQueryWrapperX() + .eq(HotelZaxDO::getStatus, "ACTIVE")) > 0; + } + + @Override + public Map pageList(Map body) { + long current = toLong(body == null ? null : body.get("current"), 1L); + long size = Math.min(50L, toLong(body == null ? null : body.get("size"), 10L)); + String keyword = trim(body == null ? null : body.get("keyword")); + String district = trim(body == null ? null : body.get("district")); + Long districtAdcode = toLongObj(body == null ? null : body.get("districtAdcode")); + String bizArea = trim(body == null ? null : body.get("bizArea")); + + LambdaQueryWrapperX qw = new LambdaQueryWrapperX() + .eq(HotelZaxDO::getStatus, "ACTIVE") + .likeIfPresent(HotelZaxDO::getHotelName, keyword) + .likeIfPresent(HotelZaxDO::getBizArea, bizArea) + .orderByDesc(HotelZaxDO::getUpdateTime); + if (districtAdcode != null) { + // 简化:等值或前缀(省/市码用范围可后续增强) + qw.and(w -> w.eq(HotelZaxDO::getDistrictAdcode, districtAdcode) + .or() + .apply("district_adcode is not null and cast(district_adcode as char) like {0}", + String.valueOf(districtAdcode).replaceAll("0+$", "") + "%")); + } + else if (StrUtil.isNotBlank(district)) { + qw.and(w -> w.like(HotelZaxDO::getDistrict, district).or().like(HotelZaxDO::getAddress, district)); + } + + Page page = hotelZaxMapper.selectPage(new Page<>(current, size), qw); + List> records = page.getRecords().stream().map(this::toBrief).collect(Collectors.toList()); + Map data = new LinkedHashMap<>(8); + data.put("records", records); + data.put("total", page.getTotal()); + data.put("current", page.getCurrent()); + data.put("size", page.getSize()); + return data; + } + + @Override + public Map detail(String hotelCode) { + if (StrUtil.isBlank(hotelCode)) { + return null; + } + HotelZaxDO h = hotelZaxMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxDO::getHotelCode, hotelCode.trim()) + .last("limit 1")); + if (h == null) { + return null; + } + Map data = toBrief(h); + data.put("carouselImages", h.getCarouselImages()); + data.put("intro", h.getIntro()); + data.put("phone", h.getPhone()); + List> rooms = loadRooms(h.getHotelCode()); + List> products = loadProducts(h.getHotelCode()); + // 明细表优先;未拆表前兼容 JSON 冗余 + data.put("rooms", rooms.isEmpty() ? parseArray(h.getRoomsJson()) : rooms); + data.put("products", products.isEmpty() ? parseArray(h.getProductsJson()) : products); + return data; + } + + @Override + public Map daily(Map body) { + String hotelCode = trim(body == null ? null : body.get("hotelCode")); + if (StrUtil.isBlank(hotelCode)) { + return null; + } + HotelZaxDO h = hotelZaxMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxDO::getHotelCode, hotelCode) + .last("limit 1")); + if (h == null) { + return null; + } + String productCode = trim(body == null ? null : body.get("productCode")); + LocalDate start = parseDay(body == null ? null : body.get("startDate")); + LocalDate end = parseDay(body == null ? null : body.get("endDate")); + if (start == null) { + start = LocalDate.now(); + } + if (end == null || end.isBefore(start)) { + end = start; + } + if (start.plusDays(90).isBefore(end)) { + end = start.plusDays(90); + } + List dailies = dailyMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxDailyDO::getHotelId, h.getId()) + .eq(StrUtil.isNotBlank(productCode), HotelZaxDailyDO::getProductCode, productCode) + .ge(HotelZaxDailyDO::getStayDate, start) + .le(HotelZaxDailyDO::getStayDate, end) + .orderByAsc(HotelZaxDailyDO::getProductCode) + .orderByAsc(HotelZaxDailyDO::getStayDate) + .last("limit 5000")); + if (dailies == null || dailies.isEmpty()) { + return null; + } + List> list = new ArrayList<>(); + for (HotelZaxDailyDO d : dailies) { + if (d == null) { + continue; + } + int stock = d.getStock() == null ? 0 : d.getStock(); + int sold = d.getSold() == null ? 0 : d.getSold(); + Map row = new LinkedHashMap<>(10); + row.put("productCode", d.getProductCode()); + row.put("roomTypeCode", d.getRoomTypeCode()); + row.put("stayDate", d.getStayDate() == null ? null : d.getStayDate().toString()); + row.put("sellPrice", d.getSellPrice()); + row.put("stock", stock); + row.put("sold", sold); + row.put("remain", Math.max(0, stock - sold)); + row.put("dailyStatus", d.getDailyStatus()); + list.add(row); + } + Map data = new LinkedHashMap<>(4); + data.put("hotelCode", hotelCode); + data.put("list", list); + return data; + } + + @Override + public BigDecimal quoteStay(String hotelCode, String productCode, LocalDate checkIn, LocalDate checkOut, int rooms) { + if (StrUtil.hasBlank(hotelCode, productCode) || checkIn == null || checkOut == null + || !checkOut.isAfter(checkIn)) { + return null; + } + Map body = new LinkedHashMap(8); + body.put("hotelCode", hotelCode); + body.put("productCode", productCode); + body.put("startDate", checkIn.toString()); + body.put("endDate", checkOut.minusDays(1).toString()); + Map data = daily(body); + if (data == null || data.get("list") == null) { + return null; + } + @SuppressWarnings("unchecked") + List> rows = (List>) data.get("list"); + return quoteFromDailyRows(rows, productCode, checkIn, checkOut, rooms); + } + + @Override + public BigDecimal quoteFromDailyRows(List> rows, String productCode, LocalDate checkIn, + LocalDate checkOut, int rooms) { + if (rows == null || StrUtil.isBlank(productCode) || checkIn == null || checkOut == null + || !checkOut.isAfter(checkIn)) { + return null; + } + int need = rooms < 1 ? 1 : rooms; + Map> byDay = new LinkedHashMap>(); + for (Map row : rows) { + if (row == null) { + continue; + } + if (StrUtil.isNotBlank(productCode) && !productCode.equals(trim(row.get("productCode")))) { + continue; + } + String ymd = stayYmd(row.get("stayDate")); + if (StrUtil.isBlank(ymd)) { + continue; + } + byDay.put(ymd, row); + } + BigDecimal nightSum = BigDecimal.ZERO; + for (LocalDate day = checkIn; day.isBefore(checkOut); day = day.plusDays(1)) { + Map row = byDay.get(day.toString()); + if (row == null) { + return null; + } + Integer status = toIntObj(row.get("dailyStatus")); + if (status == null || status.intValue() != 1) { + return null; + } + int remain = remainOf(row); + if (remain < need) { + return null; + } + BigDecimal price = toMoney(row.get("sellPrice")); + if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + nightSum = nightSum.add(price); + } + return nightSum.multiply(new BigDecimal(need)).setScale(2, java.math.RoundingMode.HALF_UP); + } + + private static String stayYmd(Object raw) { + if (raw == null) { + return null; + } + String s = String.valueOf(raw).trim().replace('/', '-'); + if (s.length() >= 10) { + s = s.substring(0, 10); + } + return s; + } + + private static int remainOf(Map row) { + Object remain = row.get("remain"); + if (remain == null) { + remain = row.get("remainStock"); + } + if (remain != null) { + Integer n = toIntObj(remain); + return n == null ? 0 : Math.max(0, n.intValue()); + } + int stock = toIntObj(row.get("stock")) == null ? 0 : toIntObj(row.get("stock")).intValue(); + int sold = toIntObj(row.get("sold")) == null ? 0 : toIntObj(row.get("sold")).intValue(); + return Math.max(0, stock - sold); + } + + private static Integer toIntObj(Object o) { + if (o == null || StrUtil.isBlank(String.valueOf(o))) { + return null; + } + try { + return Integer.valueOf(new BigDecimal(String.valueOf(o).trim()).intValue()); + } + catch (Exception e) { + return null; + } + } + + private static BigDecimal toMoney(Object o) { + if (o == null || StrUtil.isBlank(String.valueOf(o))) { + return null; + } + try { + return new BigDecimal(String.valueOf(o).trim()); + } + catch (Exception e) { + return null; + } + } + + private static LocalDate parseDay(Object raw) { + if (raw == null) { + return null; + } + try { + String s = String.valueOf(raw).trim().replace('/', '-'); + if (s.length() >= 10) { + s = s.substring(0, 10); + } + return LocalDate.parse(s); + } + catch (Exception e) { + return null; + } + } + + private List> loadRooms(String hotelCode) { + List list = roomMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxRoomDO::getHotelCode, hotelCode) + .orderByAsc(HotelZaxRoomDO::getRoomTypeCode)); + List> out = new ArrayList<>(); + for (HotelZaxRoomDO r : list) { + if (r == null) { + continue; + } + Map row = new LinkedHashMap<>(6); + row.put("roomTypeCode", r.getRoomTypeCode()); + row.put("roomTypeName", r.getRoomTypeName()); + row.put("bedType", r.getBedType()); + row.put("capacity", r.getCapacity()); + out.add(row); + } + return out; + } + + private List> loadProducts(String hotelCode) { + List list = productMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxProductDO::getHotelCode, hotelCode) + .orderByAsc(HotelZaxProductDO::getProductCode)); + List> out = new ArrayList<>(); + for (HotelZaxProductDO p : list) { + if (p == null) { + continue; + } + Map row = new LinkedHashMap<>(8); + row.put("productCode", p.getProductCode()); + row.put("productName", p.getProductName()); + row.put("roomTypeCode", p.getRoomTypeCode()); + row.put("priceCode", p.getPriceCode()); + row.put("mealType", p.getMealType()); + row.put("mealCount", p.getMealCount()); + row.put("sellPrice", p.getSellPrice()); + out.add(row); + } + return out; + } + + @Override + public Map filterMeta() { + List rows = hotelZaxMapper.selectList(new LambdaQueryWrapperX() + .select(HotelZaxDO::getDistrict, HotelZaxDO::getDistrictAdcode, HotelZaxDO::getBizArea) + .eq(HotelZaxDO::getStatus, "ACTIVE")); + Map areaByAdcode = new LinkedHashMap<>(); + Set districts = new LinkedHashSet<>(); + Set bizAreas = new LinkedHashSet<>(); + for (HotelZaxDO h : rows) { + if (h == null) { + continue; + } + if (StrUtil.isNotBlank(h.getDistrict())) { + districts.add(h.getDistrict().trim()); + } + if (h.getDistrictAdcode() != null) { + areaByAdcode.putIfAbsent(h.getDistrictAdcode(), + StrUtil.blankToDefault(h.getDistrict(), String.valueOf(h.getDistrictAdcode()))); + } + if (StrUtil.isNotBlank(h.getBizArea()) && !"[]".equals(h.getBizArea().trim()) + && !"{}".equals(h.getBizArea().trim())) { + bizAreas.add(h.getBizArea().trim()); + } + } + List> areas = areaByAdcode.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(e -> { + Map one = new LinkedHashMap<>(4); + one.put("districtAdcode", e.getKey()); + one.put("district", e.getValue()); + return one; + }) + .collect(Collectors.toList()); + List> starLevels = new ArrayList<>(); + starLevels.add(starItem(5, "五星")); + starLevels.add(starItem(4, "高档")); + starLevels.add(starItem(3, "舒适")); + starLevels.add(starItem(2, "经济")); + starLevels.add(starItem(1, "民宿")); + Map data = new LinkedHashMap<>(8); + data.put("areas", areas); + data.put("districts", new ArrayList<>(districts)); + data.put("bizAreas", new ArrayList<>(bizAreas)); + data.put("starLevels", starLevels); + return data; + } + + private static Map starItem(int value, String label) { + Map one = new LinkedHashMap<>(2); + one.put("value", value); + one.put("label", label); + return one; + } + + private Map toBrief(HotelZaxDO h) { + Map row = new LinkedHashMap<>(16); + row.put("hotelCode", h.getHotelCode()); + row.put("hotelName", h.getHotelName()); + row.put("coverImage", h.getCoverImage()); + row.put("address", h.getAddress()); + row.put("district", h.getDistrict()); + row.put("districtAdcode", h.getDistrictAdcode()); + row.put("bizArea", h.getBizArea()); + row.put("starLevel", h.getStarLevel()); + row.put("score", h.getScore()); + row.put("facilityTags", h.getFacilityTags()); + row.put("minSellPrice", h.getMinSellPrice()); + row.put("status", h.getStatus()); + return row; + } + + private static List parseArray(String json) { + if (StrUtil.isBlank(json)) { + return new ArrayList<>(); + } + try { + return JSONUtil.parseArray(json); + } + catch (Exception e) { + return new ArrayList<>(); + } + } + + private static String trim(Object o) { + return o == null ? null : StrUtil.trim(String.valueOf(o)); + } + + private static long toLong(Object o, long def) { + if (o == null) { + return def; + } + try { + return Long.parseLong(String.valueOf(o)); + } + catch (Exception e) { + return def; + } + } + + private static Long toLongObj(Object o) { + if (o == null || StrUtil.isBlank(String.valueOf(o))) { + return null; + } + try { + return Long.parseLong(String.valueOf(o).trim()); + } + catch (Exception e) { + return null; + } + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxOrderCallbackService.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxOrderCallbackService.java new file mode 100644 index 0000000..99c401e --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxOrderCallbackService.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.product.service.hotel; + +import java.util.Map; + +/** + * 供应方订单状态回调落库。 + */ +public interface HotelZaxOrderCallbackService { + + /** + * @param rawBody 原始 JSON(验签后) + * @return 落库摘要 + */ + Map upsertFromNotify(String rawBody); +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxOrderCallbackServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxOrderCallbackServiceImpl.java new file mode 100644 index 0000000..0f5af17 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxOrderCallbackServiceImpl.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.product.service.hotel; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class HotelZaxOrderCallbackServiceImpl implements HotelZaxOrderCallbackService { + + private final HotelZaxTradeService hotelZaxTradeService; + + @Override + public Map upsertFromNotify(String rawBody) { + return hotelZaxTradeService.applySupplierStatusNotify(JSONUtil.parseObj(StrUtil.blankToDefault(rawBody, "{}"))); + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncService.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncService.java new file mode 100644 index 0000000..0620f19 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncService.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.product.service.hotel; + +import java.util.Map; + +/** + * 从资安心开放平台增量同步到本地库。 + */ +public interface HotelZaxSyncService { + + /** + * 拉取一轮同步(默认全量,适配授权范围变更)。 + * @return 统计信息 + */ + Map syncOnce(); + + /** + * @param full true=忽略水位按当前授权全量拉;false=按水位增量 + */ + Map syncOnce(boolean full); + + /** + * 单店拉取详情并落库(资源回调触发)。 + */ + Map syncHotelByCode(String hotelCode); + + /** + * 标记下架/删除(资源回调 HOTEL_OFFLINE / HOTEL_DELETE)。 + */ + void markOffline(String hotelCode); + + /** + * 按本地酒店 id 拉取资安心价态日历并落库。 + */ + Map syncDailyByHotelId(Long hotelId); +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncServiceImpl.java new file mode 100644 index 0000000..f53db81 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncServiceImpl.java @@ -0,0 +1,600 @@ +package cn.iocoder.yudao.module.product.service.hotel; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxDailyDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxProductDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxRoomDO; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxSyncCursorDO; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxDailyMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxProductMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxRoomMapper; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxSyncCursorMapper; +import cn.iocoder.yudao.module.product.framework.hotel.ZaxHotelOpenClient; +import cn.iocoder.yudao.module.product.framework.hotel.ZaxHotelOpenProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 开放平台 hotel/sync + detail → 本地 hotel_zax / room / product(结构对齐供应侧)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HotelZaxSyncServiceImpl implements HotelZaxSyncService { + + private static final String CURSOR_KEY = "default"; + + private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final ZaxHotelOpenClient openClient; + + private final ZaxHotelOpenProperties openProperties; + + private final HotelZaxMapper hotelZaxMapper; + + private final HotelZaxRoomMapper roomMapper; + + private final HotelZaxProductMapper productMapper; + + private final HotelZaxDailyMapper dailyMapper; + + private final HotelZaxSyncCursorMapper cursorMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public Map syncOnce() { + // 管理端「同步资安心」默认全量:改授权范围后增量水位拉不到新授权酒店 + return syncOnce(true); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map syncOnce(boolean full) { + if (!openProperties.isEnabled()) { + throw new IllegalStateException("酒店中转未启用"); + } + HotelZaxSyncCursorDO cursor = getOrCreateCursor(); + // 全量忽略水位;增量才带 updatedSince + String updatedSince = full ? null : cursor.getUpdatedSince(); + int pageNo = 1; + int upsert = 0; + int detailOk = 0; + int dailyOk = 0; + int dailyFail = 0; + int dailyRows = 0; + int offlineOutOfScope = 0; + String nextSince = updatedSince; + boolean hasMore = true; + boolean pullOk = false; + Set syncedCodes = new HashSet<>(); + while (hasMore && pageNo <= 40) { + Map req = new HashMap<>(); + req.put("current", pageNo); + req.put("size", 50); + req.put("includeOffline", true); + if (StrUtil.isNotBlank(updatedSince)) { + req.put("updatedSince", updatedSince); + } + JSONObject resp = openClient.post("hotel/sync", req); + JSONObject data = resp == null ? null : resp.getJSONObject("data"); + if (data == null) { + break; + } + pullOk = true; + JSONArray records = data.getJSONArray("records"); + if (records == null || records.isEmpty()) { + hasMore = false; + break; + } + for (int i = 0; i < records.size(); i++) { + JSONObject row = records.getJSONObject(i); + if (row == null || StrUtil.isBlank(row.getStr("hotelCode"))) { + continue; + } + String code = row.getStr("hotelCode").trim(); + syncedCodes.add(code); + upsertHotelBrief(row); + upsert++; + try { + DetailPullResult dr = pullDetailResult(code); + if (dr.ok) { + detailOk++; + } + dailyRows += dr.dailyRows; + if (dr.dailyFail) { + dailyFail++; + } + else if (dr.ok) { + dailyOk++; + } + sleepQuiet(80L); + } + catch (Exception e) { + log.warn("|hotel-zax-sync|detail失败|code={}|{}", code, e.getMessage()); + dailyFail++; + } + } + String ns = data.getStr("nextUpdatedSince"); + if (StrUtil.isNotBlank(ns)) { + nextSince = ns; + } + hasMore = Boolean.TRUE.equals(data.getBool("hasMore")); + pageNo++; + } + // 全量且拉取成功后:本地有、当前授权已不含的酒店标记下架(避免改范围后仍显示旧数据) + // pullOk=false 时不清理,防止接口异常把本地全下架 + if (full && pullOk) { + offlineOutOfScope = markOutOfScopeOffline(syncedCodes); + if (StrUtil.isBlank(nextSince)) { + nextSince = LocalDateTime.now().format(DT); + } + } + cursor.setUpdatedSince(nextSince); + cursor.setLastSyncAt(LocalDateTime.now()); + cursor.setLastMsg((full ? "full," : "incr,") + "upsert=" + upsert + ",detail=" + detailOk + + ",dailyOk=" + dailyOk + ",dailyRows=" + dailyRows + ",dailyFail=" + dailyFail + + ",offlineOut=" + offlineOutOfScope); + cursorMapper.updateById(cursor); + + Map out = new LinkedHashMap<>(); + out.put("full", full); + out.put("upsert", upsert); + out.put("detailOk", detailOk); + out.put("dailyOk", Integer.valueOf(dailyOk)); + out.put("dailyRows", Integer.valueOf(dailyRows)); + out.put("dailyFail", Integer.valueOf(dailyFail)); + out.put("offlineOutOfScope", offlineOutOfScope); + out.put("updatedSince", nextSince); + return out; + } + + /** 全量同步后,不在本次授权结果集中的本地酒店下架 */ + private int markOutOfScopeOffline(Set syncedCodes) { + List locals = hotelZaxMapper.selectList(new LambdaQueryWrapperX<>()); + int n = 0; + for (HotelZaxDO h : locals) { + if (h == null || StrUtil.isBlank(h.getHotelCode())) { + continue; + } + if (syncedCodes.contains(h.getHotelCode().trim())) { + continue; + } + if ("OFFLINE".equalsIgnoreCase(h.getStatus())) { + continue; + } + h.setStatus("OFFLINE"); + h.setSyncedAt(LocalDateTime.now()); + hotelZaxMapper.updateById(h); + n++; + } + return n; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map syncHotelByCode(String hotelCode) { + if (StrUtil.isBlank(hotelCode)) { + throw new IllegalArgumentException("hotelCode 不能为空"); + } + String code = hotelCode.trim(); + boolean ok = pullDetailResult(code).ok; + if (!ok) { + throw new IllegalStateException("供应方无此酒店详情: " + code); + } + Map out = new LinkedHashMap<>(4); + out.put("hotelCode", code); + out.put("ok", true); + return out; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void markOffline(String hotelCode) { + if (StrUtil.isBlank(hotelCode)) { + return; + } + HotelZaxDO exist = hotelZaxMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxDO::getHotelCode, hotelCode.trim()) + .last("limit 1")); + if (exist == null) { + return; + } + exist.setStatus("OFFLINE"); + exist.setSyncedAt(LocalDateTime.now()); + hotelZaxMapper.updateById(exist); + } + + private void upsertHotelBrief(JSONObject row) { + String code = row.getStr("hotelCode"); + HotelZaxDO exist = hotelZaxMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxDO::getHotelCode, code) + .last("limit 1")); + HotelZaxDO entity = exist == null ? new HotelZaxDO() : exist; + entity.setHotelCode(code); + entity.setHotelName(row.getStr("hotelName")); + entity.setGroupCode(row.getStr("groupCode")); + entity.setCoverImage(row.getStr("coverImage")); + entity.setAddress(row.getStr("address")); + entity.setDistrict(row.getStr("district")); + entity.setDistrictAdcode(row.getLong("districtAdcode")); + entity.setBizArea(cleanJunk(row.getStr("bizArea"))); + entity.setStarLevel(row.getInt("starLevel")); + entity.setScore(row.getBigDecimal("score")); + entity.setFacilityTags(row.getStr("facilityTags")); + entity.setLongitude(row.getBigDecimal("longitude")); + entity.setLatitude(row.getBigDecimal("latitude")); + if (StrUtil.isNotBlank(row.getStr("phone"))) { + entity.setPhone(row.getStr("phone")); + } + BigDecimal minSell = row.getBigDecimal("minSellPrice"); + if (minSell != null) { + entity.setMinSellPrice(minSell); + } + entity.setStatus(StrUtil.blankToDefault(row.getStr("status"), "ACTIVE")); + entity.setSupplierUpdateTime(parseDt(row.getStr("updateTime"))); + entity.setSyncedAt(LocalDateTime.now()); + if (exist == null) { + hotelZaxMapper.insert(entity); + } + else { + hotelZaxMapper.updateById(entity); + } + } + + private DetailPullResult pullDetailResult(String hotelCode) { + DetailPullResult result = new DetailPullResult(); + Map req = new HashMap<>(); + req.put("hotelCode", hotelCode); + JSONObject resp = openClient.post("hotel/detail", req); + JSONObject data = resp == null ? null : resp.getJSONObject("data"); + if (data == null) { + return result; + } + HotelZaxDO exist = hotelZaxMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxDO::getHotelCode, hotelCode) + .last("limit 1")); + if (exist == null) { + upsertHotelBrief(data); + exist = hotelZaxMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxDO::getHotelCode, hotelCode) + .last("limit 1")); + if (exist == null) { + return result; + } + } + exist.setIntro(data.getStr("intro")); + exist.setCarouselImages(data.getStr("carouselImages")); + if (StrUtil.isNotBlank(data.getStr("phone"))) { + exist.setPhone(data.getStr("phone")); + } + Object rooms = data.get("rooms"); + Object products = data.get("products"); + exist.setRoomsJson(rooms == null ? null : JSONUtil.toJsonStr(rooms)); + exist.setProductsJson(products == null ? null : JSONUtil.toJsonStr(products)); + if (data.getBigDecimal("minSellPrice") != null) { + exist.setMinSellPrice(data.getBigDecimal("minSellPrice")); + } + if (StrUtil.isNotBlank(data.getStr("coverImage"))) { + exist.setCoverImage(data.getStr("coverImage")); + } + if (StrUtil.isNotBlank(data.getStr("hotelName"))) { + exist.setHotelName(data.getStr("hotelName")); + } + if (StrUtil.isNotBlank(data.getStr("status"))) { + exist.setStatus(data.getStr("status")); + } + exist.setSyncedAt(LocalDateTime.now()); + hotelZaxMapper.updateById(exist); + + replaceRooms(exist, data.getJSONArray("rooms")); + replaceProducts(exist, data.getJSONArray("products")); + result.ok = true; + try { + result.dailyRows = pullDailyWithRetry(exist); + } + catch (Exception e) { + result.dailyFail = true; + log.warn("|hotel-zax-sync|daily失败|code={}|{}", hotelCode, e.getMessage()); + } + return result; + } + + private static class DetailPullResult { + private boolean ok; + private int dailyRows; + private boolean dailyFail; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map syncDailyByHotelId(Long hotelId) { + if (hotelId == null) { + throw new IllegalArgumentException("hotelId 不能为空"); + } + HotelZaxDO hotel = hotelZaxMapper.selectById(hotelId); + if (hotel == null || StrUtil.isBlank(hotel.getHotelCode())) { + throw new IllegalStateException("本地酒店不存在"); + } + int upsert = pullDailyWithRetry(hotel); + Map out = new LinkedHashMap(4); + out.put("hotelId", hotelId); + out.put("hotelCode", hotel.getHotelCode()); + out.put("upsert", Integer.valueOf(upsert)); + return out; + } + + private int pullDailyWithRetry(HotelZaxDO hotel) { + Exception last = null; + for (int i = 0; i < 4; i++) { + try { + return pullDaily(hotel); + } + catch (Exception e) { + last = e; + String msg = e.getMessage() == null ? "" : e.getMessage(); + if ((msg.contains("频繁") || msg.contains("QPS") || msg.contains("限流")) && i < 3) { + sleepQuiet(400L * (i + 1)); + continue; + } + throw e instanceof RuntimeException ? (RuntimeException) e : new IllegalStateException(msg, e); + } + } + if (last instanceof RuntimeException) { + throw (RuntimeException) last; + } + throw new IllegalStateException(last == null ? "拉取日历失败" : last.getMessage(), last); + } + + /** 拉取资安心 hotel/daily 写入本地日历(窗口默认 90 天)。 */ + private int pullDaily(HotelZaxDO hotel) { + int days = openProperties.getDailySyncDays(); + if (days <= 0) { + return 0; + } + if (days > 90) { + days = 90; + } + LocalDate from = LocalDate.now(); + LocalDate to = from.plusDays(days - 1); + Map req = new HashMap(); + req.put("hotelCode", hotel.getHotelCode()); + req.put("startDate", from.toString()); + req.put("endDate", to.toString()); + JSONObject resp = openClient.post("hotel/daily", req); + JSONObject data = resp == null ? null : resp.getJSONObject("data"); + if (data == null) { + return 0; + } + JSONArray list = data.getJSONArray("list"); + if (list == null || list.isEmpty()) { + log.warn("|hotel-zax-sync|daily空|code={}|from={}|to={}|msg=供应方该窗口无日历,请先在资安心维护价态|", + hotel.getHotelCode(), from, to); + return 0; + } + List products = productMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxProductDO::getHotelId, hotel.getId())); + Map productMap = new HashMap(); + if (products != null) { + for (HotelZaxProductDO p : products) { + if (p != null && StrUtil.isNotBlank(p.getProductCode())) { + productMap.put(p.getProductCode().trim(), p); + } + } + } + int upsert = 0; + for (int i = 0; i < list.size(); i++) { + JSONObject row = list.getJSONObject(i); + if (row == null || StrUtil.isBlank(row.getStr("productCode")) || StrUtil.isBlank(row.getStr("stayDate"))) { + continue; + } + String productCode = row.getStr("productCode").trim(); + LocalDate stayDate = parseDay(row.get("stayDate") == null ? null : String.valueOf(row.get("stayDate"))); + if (stayDate == null) { + continue; + } + HotelZaxProductDO product = productMap.get(productCode); + HotelZaxDailyDO exist = dailyMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxDailyDO::getHotelId, hotel.getId()) + .eq(HotelZaxDailyDO::getProductCode, productCode) + .eq(HotelZaxDailyDO::getStayDate, stayDate) + .last("limit 1")); + HotelZaxDailyDO entity = exist == null ? new HotelZaxDailyDO() : exist; + entity.setHotelId(hotel.getId()); + entity.setHotelCode(hotel.getHotelCode()); + entity.setProductCode(productCode); + if (product != null) { + entity.setProductId(product.getId()); + entity.setRoomTypeCode(product.getRoomTypeCode()); + } + entity.setStayDate(stayDate); + if (row.getBigDecimal("costPrice") != null) { + entity.setCostPrice(row.getBigDecimal("costPrice")); + } + entity.setSellPrice(row.getBigDecimal("sellPrice")); + entity.setStock(row.getInt("stock")); + Integer status = row.getInt("dailyStatus"); + entity.setDailyStatus(status == null ? Integer.valueOf(1) : status); + if (exist == null) { + Integer sold = row.getInt("sold"); + entity.setSold(sold == null ? Integer.valueOf(0) : sold); + dailyMapper.insert(entity); + } + else { + dailyMapper.updateById(entity); + } + upsert++; + } + return upsert; + } + + private static LocalDate parseDay(String raw) { + if (StrUtil.isBlank(raw)) { + return null; + } + try { + String s = raw.trim().replace('/', '-'); + if (s.length() >= 10) { + s = s.substring(0, 10); + } + return LocalDate.parse(s); + } + catch (Exception e) { + return null; + } + } + + /** 全量替换该酒店房型(与供应侧一对多一致) */ + private void replaceRooms(HotelZaxDO hotel, JSONArray rooms) { + List old = roomMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxRoomDO::getHotelCode, hotel.getHotelCode())); + Set keep = new HashSet<>(); + if (rooms != null) { + for (int i = 0; i < rooms.size(); i++) { + JSONObject r = rooms.getJSONObject(i); + if (r == null || StrUtil.isBlank(r.getStr("roomTypeCode"))) { + continue; + } + String rtc = r.getStr("roomTypeCode").trim(); + keep.add(rtc); + HotelZaxRoomDO exist = roomMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxRoomDO::getHotelCode, hotel.getHotelCode()) + .eq(HotelZaxRoomDO::getRoomTypeCode, rtc) + .last("limit 1")); + HotelZaxRoomDO row = exist == null ? new HotelZaxRoomDO() : exist; + row.setHotelId(hotel.getId()); + row.setHotelCode(hotel.getHotelCode()); + row.setGroupCode(hotel.getGroupCode()); + row.setRoomTypeCode(rtc); + row.setRoomTypeName(r.getStr("roomTypeName")); + row.setBedType(r.getStr("bedType")); + row.setCapacity(r.getInt("capacity")); + row.setRoomNumber(r.getInt("roomNumber")); + row.setRawJson(JSONUtil.toJsonStr(r)); + if (exist == null) { + roomMapper.insert(row); + } + else { + roomMapper.updateById(row); + } + } + } + for (HotelZaxRoomDO o : old) { + if (o != null && !keep.contains(o.getRoomTypeCode())) { + roomMapper.deleteById(o.getId()); + } + } + } + + private void replaceProducts(HotelZaxDO hotel, JSONArray products) { + List old = productMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxProductDO::getHotelCode, hotel.getHotelCode())); + Set keep = new HashSet<>(); + if (products != null) { + for (int i = 0; i < products.size(); i++) { + JSONObject p = products.getJSONObject(i); + if (p == null || StrUtil.isBlank(p.getStr("productCode"))) { + continue; + } + String pc = p.getStr("productCode").trim(); + keep.add(pc); + HotelZaxProductDO exist = productMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxProductDO::getHotelCode, hotel.getHotelCode()) + .eq(HotelZaxProductDO::getProductCode, pc) + .last("limit 1")); + HotelZaxProductDO row = exist == null ? new HotelZaxProductDO() : exist; + row.setHotelId(hotel.getId()); + row.setHotelCode(hotel.getHotelCode()); + row.setGroupCode(hotel.getGroupCode()); + row.setRoomTypeCode(p.getStr("roomTypeCode")); + row.setProductCode(pc); + row.setProductName(p.getStr("productName")); + row.setPriceCode(p.getStr("priceCode")); + row.setCancelCode(p.getStr("cancelCode")); + row.setMealType(p.getInt("mealType")); + row.setMealCount(p.getInt("mealCount")); + row.setSellPrice(p.getBigDecimal("sellPrice")); + row.setStock(p.getInt("stock")); + row.setRawJson(JSONUtil.toJsonStr(p)); + if (exist == null) { + productMapper.insert(row); + } + else { + productMapper.updateById(row); + } + } + } + for (HotelZaxProductDO o : old) { + if (o != null && !keep.contains(o.getProductCode())) { + dailyMapper.delete(new LambdaQueryWrapperX() + .eq(HotelZaxDailyDO::getHotelId, hotel.getId()) + .eq(HotelZaxDailyDO::getProductCode, o.getProductCode())); + productMapper.deleteById(o.getId()); + } + } + } + + private HotelZaxSyncCursorDO getOrCreateCursor() { + HotelZaxSyncCursorDO c = cursorMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxSyncCursorDO::getCursorKey, CURSOR_KEY) + .last("limit 1")); + if (c != null) { + return c; + } + c = new HotelZaxSyncCursorDO(); + c.setCursorKey(CURSOR_KEY); + c.setUpdatedSince(null); + cursorMapper.insert(c); + return c; + } + + private static String cleanJunk(String v) { + if (StrUtil.isBlank(v)) { + return null; + } + String s = v.trim(); + if ("[]".equals(s) || "{}".equals(s) || "()".equals(s)) { + return null; + } + return s; + } + + private static void sleepQuiet(long ms) { + try { + Thread.sleep(ms); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static LocalDateTime parseDt(String s) { + if (StrUtil.isBlank(s)) { + return null; + } + try { + return LocalDateTime.parse(s.trim(), DT); + } + catch (Exception e) { + return null; + } + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeService.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeService.java new file mode 100644 index 0000000..98e0302 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeService.java @@ -0,0 +1,59 @@ +package cn.iocoder.yudao.module.product.service.hotel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; + +import java.util.Map; + +/** + * 小如酒店:供应侧下单 + 本地发起微信支付。 + */ +public interface HotelZaxTradeService { + + /** + * 可订校验 → 落本地待支付单 → 创建微信支付单。 + * 供应方 order/create 只在支付成功回调里调用,避免未付款就预定成功。 + */ + Map createOrderAndPay(Long userId, Map body); + + /** 支付回调:标记已支付,再向资安心预定 */ + boolean updatePaidIfMatch(Long orderId, Long payOrderId); + + /** + * 支付成功通知。商户单号可能与商城订单撞号,先按 payOrderId 认酒店单。 + * + * @return true=酒店单已处理 + */ + boolean handlePayNotify(String merchantOrderId, Long payOrderId); + + /** 按支付单查询本地订单详情(供小程序支付结果页) */ + Map getByPayOrderId(Long userId, Long payOrderId); + + /** 按渠道单号查询 */ + Map getByChannelNo(Long userId, String channelNo); + + /** 当前用户酒店订单分页 */ + PageResult> pageMyOrders(Long userId, Integer pageNo, Integer pageSize, + String orderStatus, Boolean payStatus); + + default PageResult> pageMyOrders(Long userId, Integer pageNo, Integer pageSize) { + return pageMyOrders(userId, pageNo, pageSize, null, null); + } + + /** 超时未支付自动取消,返回取消条数 */ + int cancelUnpaidTimeout(); + + /** + * 后台/用户取消:已支付则原路退款;已向资安心预定则先调供应方取消。 + * + * @param notifySupplier true=主动通知资安心取消;资安心回调进来时应为 false,避免循环 + */ + void cancelOrder(Long orderId, String reason, boolean notifySupplier); + + /** 用户按渠道单号取消(校验归属) */ + void cancelByChannelNo(Long userId, String channelNo, String reason); + + /** + * 资安心订单状态回调:更新本地状态;若已取消且已支付则退款给用户。 + */ + Map applySupplierStatusNotify(Map body); +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeServiceImpl.java new file mode 100644 index 0000000..a98f958 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeServiceImpl.java @@ -0,0 +1,856 @@ +package cn.iocoder.yudao.module.product.service.hotel; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONArray; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.pay.api.order.PayOrderApi; +import cn.iocoder.yudao.module.pay.api.order.dto.PayOrderCreateReqDTO; +import cn.iocoder.yudao.module.pay.api.order.dto.PayOrderRespDTO; +import cn.iocoder.yudao.module.pay.api.refund.PayRefundApi; +import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundCreateReqDTO; +import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; +import cn.iocoder.yudao.module.product.dal.dataobject.hotel.HotelZaxOrderDO; +import cn.iocoder.yudao.module.product.dal.mysql.hotel.HotelZaxOrderMapper; +import cn.iocoder.yudao.module.product.framework.hotel.ZaxHotelOpenClient; +import cn.iocoder.yudao.module.product.framework.hotel.ZaxHotelOpenProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.addTime; +import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP; +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.REFUND_EXISTS; +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.REFUND_HAS_REFUNDING; +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.HOTEL_ZAX_ORDER_CANCEL_FAIL; +import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.HOTEL_ZAX_ORDER_NOT_EXISTS; + +@Slf4j +@Service +@Validated +@RequiredArgsConstructor +public class HotelZaxTradeServiceImpl implements HotelZaxTradeService { + + private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final Duration PAY_EXPIRE = Duration.ofMinutes(10); + + /** 支付商户单号前缀,避免与 trade_order.id 撞号 */ + private static final String PAY_MERCHANT_PREFIX = "HZ"; + + private final ZaxHotelOpenClient openClient; + + private final ZaxHotelOpenProperties openProperties; + + private final HotelZaxOrderMapper orderMapper; + + private final HotelZaxLocalService hotelZaxLocalService; + + private final PayOrderApi payOrderApi; + + private final PayRefundApi payRefundApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Map createOrderAndPay(Long userId, Map body) { + if (userId == null) { + throw invalidParamException("请先登录"); + } + Map req = body == null ? new HashMap<>() : new HashMap<>(body); + String hotelCode = trim(req.get("hotelCode")); + String productCode = trim(req.get("productCode")); + String roomTypeCode = trim(req.get("roomTypeCode")); + LocalDate checkIn = parseDay(trim(req.get("checkInDate"))); + LocalDate checkOut = parseDay(trim(req.get("checkOutDate"))); + int roomCount = Math.max(1, toInt(req.get("count"), 1)); + List> guestList = parseGuests(req.get("guestList")); + if (StrUtil.hasBlank(hotelCode, productCode) || checkIn == null || checkOut == null) { + throw invalidParamException("酒店/产品/入住离店日期不能为空"); + } + if (!checkOut.isAfter(checkIn)) { + throw invalidParamException("离店日期必须晚于入住日期"); + } + if (CollUtil.isEmpty(guestList)) { + throw invalidParamException("请至少填写一位入住人(姓名+手机号)"); + } + // 联系人兼容供应方:默认取首位入住人;若单独传了 contact* 则优先用 contact* + String contactName = trim(req.get("contactName")); + String contactPhone = trim(req.get("contactPhone")); + if (StrUtil.isBlank(contactName)) { + contactName = trim(guestList.get(0).get("name")); + } + if (StrUtil.isBlank(contactPhone)) { + contactPhone = trim(guestList.get(0).get("mobile")); + } + if (StrUtil.hasBlank(contactName, contactPhone)) { + throw invalidParamException("请填写入住人姓名与手机号"); + } + if (!contactPhone.matches("^1\\d{10}$")) { + throw invalidParamException("入住人手机号格式不正确"); + } + BigDecimal totalAmount = quoteStayAmount(hotelCode, productCode, checkIn, checkOut, roomCount); + if (totalAmount == null || totalAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw invalidParamException("所选日期无可用日历价态,请更换日期或联系商家维护日历"); + } + int nights = (int) (checkOut.toEpochDay() - checkIn.toEpochDay()); + String channelNo = trim(req.get("channelNo")); + if (StrUtil.isBlank(channelNo)) { + channelNo = "XR" + userId + System.currentTimeMillis(); + } + + Map openBody = new LinkedHashMap<>(); + openBody.put("channelNo", channelNo); + openBody.put("hotelCode", hotelCode); + openBody.put("productCode", productCode); + openBody.put("roomTypeCode", roomTypeCode); + openBody.put("checkInDate", checkIn.format(DAY)); + openBody.put("checkOutDate", checkOut.format(DAY)); + openBody.put("count", roomCount); + openBody.put("totalAmount", totalAmount); + openBody.put("contactName", contactName); + openBody.put("contactPhone", contactPhone); + openBody.put("guestList", guestList); + if (StrUtil.isNotBlank(trim(req.get("groupCode")))) { + openBody.put("groupCode", trim(req.get("groupCode"))); + } + if (StrUtil.isNotBlank(trim(req.get("priceCode")))) { + openBody.put("priceCode", trim(req.get("priceCode"))); + } + + // 1) 可订校验(不落供应方订单) + openClient.post("order/check", openBody); + + // 2) 本地待支付单;供应方预定推迟到微信支付成功 + HotelZaxOrderDO local = new HotelZaxOrderDO(); + local.setUserId(userId); + local.setChannelNo(channelNo); + local.setHotelCode(hotelCode); + local.setHotelName(trim(req.get("hotelName"))); + local.setProductCode(productCode); + local.setProductName(trim(req.get("productName"))); + local.setRoomTypeCode(roomTypeCode); + local.setCheckInDate(checkIn); + local.setCheckOutDate(checkOut); + local.setNights(nights); + local.setRoomCount(roomCount); + local.setContactName(contactName); + local.setContactPhone(contactPhone); + local.setGuestJson(JSONUtil.toJsonStr(guestList)); + local.setSellAmount(totalAmount); + local.setOrderStatus("UNPAID"); + local.setPayStatus(false); + local.setPayloadJson(JSONUtil.toJsonStr(openBody)); + orderMapper.insert(local); + + // 3) 支付单(金额:分) + int priceFen = totalAmount.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP).intValue(); + if (priceFen <= 0) { + throw invalidParamException("支付金额必须大于 0"); + } + String subject = StrUtil.maxLength(StrUtil.blankToDefault(local.getHotelName(), "酒店预订"), 32); + Long payOrderId; + try { + payOrderId = payOrderApi.createOrder(new PayOrderCreateReqDTO() + .setAppKey(StrUtil.blankToDefault(openProperties.getPayAppKey(), "mall")) + .setUserIp(StrUtil.blankToDefault(getClientIP(), "127.0.0.1")) + .setMerchantOrderId(PAY_MERCHANT_PREFIX + local.getId()) + .setSubject(subject) + .setBody(StrUtil.maxLength(StrUtil.blankToDefault(local.getProductName(), "酒店订单"), 128)) + .setPrice(priceFen) + .setExpireTime(addTime(PAY_EXPIRE))); + } + catch (Exception e) { + log.error("|hotel-pay|创建支付单失败|channelNo={}|{}", channelNo, e.getMessage(), e); + throw invalidParamException("创建支付单失败:{}(请确认支付应用 mall 已配置,且已执行 hotel_zax_order_pay.sql)", + StrUtil.maxLength(StrUtil.blankToDefault(e.getMessage(), e.getClass().getSimpleName()), 80)); + } + local.setPayOrderId(payOrderId); + orderMapper.updateById(local); + + Map out = new LinkedHashMap<>(); + out.put("id", local.getId()); + out.put("channelNo", local.getChannelNo()); + out.put("orderNo", local.getOrderNo()); + out.put("platformNo", local.getPlatformNo()); + out.put("payOrderId", payOrderId); + out.put("totalAmount", totalAmount); + out.put("nights", nights); + return out; + } + + @Override + public boolean handlePayNotify(String merchantOrderId, Long payOrderId) { + if (payOrderId != null) { + HotelZaxOrderDO byPay = orderMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxOrderDO::getPayOrderId, payOrderId) + .last("limit 1")); + if (byPay != null) { + return updatePaidIfMatch(byPay.getId(), payOrderId); + } + } + Long hotelId = parseHotelMerchantOrderId(merchantOrderId); + if (hotelId == null) { + return false; + } + return updatePaidIfMatch(hotelId, payOrderId); + } + + @Override + public boolean updatePaidIfMatch(Long orderId, Long payOrderId) { + if (orderId == null || payOrderId == null) { + return false; + } + HotelZaxOrderDO order = orderMapper.selectById(orderId); + if (order == null) { + return false; + } + if (isTerminalUnbookable(order.getOrderStatus())) { + log.warn("|hotel-pay|订单已取消/结束,忽略支付成功回调|orderId={}|payOrderId={}|status={}|", + orderId, payOrderId, order.getOrderStatus()); + return true; + } + if (!Boolean.TRUE.equals(order.getPayStatus())) { + if (order.getPayOrderId() == null || !order.getPayOrderId().equals(payOrderId)) { + log.warn("|hotel-pay|支付单不匹配|orderId={}|expect={}|actual={}|", orderId, order.getPayOrderId(), + payOrderId); + return false; + } + PayOrderRespDTO payOrder = payOrderApi.getOrder(payOrderId); + if (payOrder == null || !PayOrderStatusEnum.isSuccess(payOrder.getStatus())) { + log.warn("|hotel-pay|支付单未成功|orderId={}|payOrderId={}|status={}|", orderId, payOrderId, + payOrder == null ? null : payOrder.getStatus()); + return false; + } + order.setPayStatus(true); + order.setPayTime(LocalDateTime.now()); + if (StrUtil.isBlank(order.getOrderStatus()) || "UNPAID".equalsIgnoreCase(order.getOrderStatus())) { + order.setOrderStatus("PAID"); + } + orderMapper.updateById(order); + } + bookSupplierAfterPaid(order); + return true; + } + + @Override + public Map getByPayOrderId(Long userId, Long payOrderId) { + if (payOrderId == null) { + return null; + } + HotelZaxOrderDO order = orderMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxOrderDO::getPayOrderId, payOrderId) + .eqIfPresent(HotelZaxOrderDO::getUserId, userId) + .last("limit 1")); + syncPaidFromPayOrder(order); + if (order != null && order.getId() != null) { + order = orderMapper.selectById(order.getId()); + } + return toView(order); + } + + @Override + public Map getByChannelNo(Long userId, String channelNo) { + if (StrUtil.isBlank(channelNo)) { + return null; + } + HotelZaxOrderDO order = orderMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxOrderDO::getChannelNo, channelNo.trim()) + .eqIfPresent(HotelZaxOrderDO::getUserId, userId) + .last("limit 1")); + return toView(order); + } + + @Override + public PageResult> pageMyOrders(Long userId, Integer pageNo, Integer pageSize, + String orderStatus, Boolean payStatus) { + if (userId == null) { + throw invalidParamException("请先登录"); + } + PageParam pageParam = new PageParam(); + pageParam.setPageNo(pageNo == null || pageNo < 1 ? 1 : pageNo); + pageParam.setPageSize(pageSize == null || pageSize < 1 ? 10 : pageSize); + PageResult page = orderMapper.selectPage(pageParam, + new LambdaQueryWrapperX() + .eq(HotelZaxOrderDO::getUserId, userId) + .eqIfPresent(HotelZaxOrderDO::getOrderStatus, StrUtil.trim(orderStatus)) + .eqIfPresent(HotelZaxOrderDO::getPayStatus, payStatus) + .orderByDesc(HotelZaxOrderDO::getId)); + List> list = new ArrayList>(); + if (page.getList() != null) { + for (HotelZaxOrderDO row : page.getList()) { + syncPaidFromPayOrder(row); + HotelZaxOrderDO latest = row.getId() == null ? row : orderMapper.selectById(row.getId()); + list.add(toView(latest == null ? row : latest)); + } + } + return new PageResult>(list, page.getTotal()); + } + + @Override + public int cancelUnpaidTimeout() { + LocalDateTime expireBefore = LocalDateTime.now().minus(PAY_EXPIRE); + List list = orderMapper.selectList(new LambdaQueryWrapperX() + .eq(HotelZaxOrderDO::getOrderStatus, "UNPAID") + .and(w -> w.eq(HotelZaxOrderDO::getPayStatus, false).or().isNull(HotelZaxOrderDO::getPayStatus)) + .lt(HotelZaxOrderDO::getCreateTime, expireBefore) + .last("limit 200")); + if (CollUtil.isEmpty(list)) { + return 0; + } + int n = 0; + for (HotelZaxOrderDO order : list) { + if (Boolean.TRUE.equals(order.getPayStatus())) { + continue; + } + order.setOrderStatus("CANCELLED"); + order.setFailMsg("超时未支付自动取消"); + orderMapper.updateById(order); + n++; + } + if (n > 0) { + log.info("|hotel-pay|超时未支付自动取消|count={}|", n); + } + return n; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelOrder(Long orderId, String reason, boolean notifySupplier) { + if (orderId == null) { + throw invalidParamException("订单 id 不能为空"); + } + HotelZaxOrderDO order = orderMapper.selectById(orderId); + if (order == null) { + throw exception(HOTEL_ZAX_ORDER_NOT_EXISTS); + } + doCancel(order, StrUtil.blankToDefault(reason, "后台取消"), notifySupplier); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelByChannelNo(Long userId, String channelNo, String reason) { + if (StrUtil.isBlank(channelNo)) { + throw invalidParamException("channelNo 不能为空"); + } + HotelZaxOrderDO order = orderMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxOrderDO::getChannelNo, channelNo.trim()) + .eqIfPresent(HotelZaxOrderDO::getUserId, userId) + .last("limit 1")); + if (order == null) { + throw exception(HOTEL_ZAX_ORDER_NOT_EXISTS); + } + doCancel(order, StrUtil.blankToDefault(reason, "用户取消"), true); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map applySupplierStatusNotify(Map body) { + Map src = body == null ? new HashMap() : body; + String channelNo = trim(src.get("channelNo")); + if (StrUtil.isBlank(channelNo)) { + throw invalidParamException("channelNo 不能为空"); + } + HotelZaxOrderDO exist = orderMapper.selectOne(new LambdaQueryWrapperX() + .eq(HotelZaxOrderDO::getChannelNo, channelNo) + .last("limit 1")); + HotelZaxOrderDO row = exist == null ? new HotelZaxOrderDO() : exist; + row.setChannelNo(channelNo); + if (StrUtil.isNotBlank(trim(src.get("orderNo")))) { + row.setOrderNo(trim(src.get("orderNo"))); + } + if (StrUtil.isNotBlank(trim(src.get("platformNo")))) { + row.setPlatformNo(trim(src.get("platformNo"))); + } + if (StrUtil.isNotBlank(trim(src.get("orderStatus")))) { + row.setOrderStatus(trim(src.get("orderStatus"))); + } + if (StrUtil.isNotBlank(trim(src.get("bookStatus")))) { + row.setBookStatus(trim(src.get("bookStatus"))); + } + if (StrUtil.isNotBlank(trim(src.get("hotelCode")))) { + row.setHotelCode(trim(src.get("hotelCode"))); + } + if (StrUtil.isNotBlank(trim(src.get("productCode")))) { + row.setProductCode(trim(src.get("productCode"))); + } + if (src.get("sellAmount") != null) { + try { + row.setSellAmount(new BigDecimal(String.valueOf(src.get("sellAmount")))); + } + catch (Exception ignored) { + // 保留原金额 + } + } + if (StrUtil.isNotBlank(trim(src.get("failMsg")))) { + row.setFailMsg(trim(src.get("failMsg"))); + } + row.setLastNotifyAt(LocalDateTime.now()); + if (exist == null) { + orderMapper.insert(row); + } + else { + orderMapper.updateById(row); + } + if (isTerminalUnbookable(row.getOrderStatus()) && Boolean.TRUE.equals(row.getPayStatus())) { + refundIfPaid(row, StrUtil.blankToDefault(trim(src.get("failMsg")), "供应方取消退款")); + row.setOrderStatus("CANCELLED"); + orderMapper.updateById(row); + } + Map out = new LinkedHashMap<>(); + out.put("channelNo", channelNo); + out.put("orderStatus", row.getOrderStatus()); + out.put("id", row.getId()); + return out; + } + + private void doCancel(HotelZaxOrderDO order, String reason, boolean notifySupplier) { + boolean alreadyClosed = isTerminalUnbookable(order.getOrderStatus()); + if (alreadyClosed && (!Boolean.TRUE.equals(order.getPayStatus()) || isLocalRefunded(order))) { + return; + } + if (!alreadyClosed && notifySupplier && shouldNotifySupplierCancel(order)) { + try { + Map req = new HashMap(); + req.put("channelNo", order.getChannelNo()); + openClient.post("order/cancel", req); + } + catch (Exception e) { + log.warn("|hotel-cancel|通知资安心取消失败|channelNo={}|{}", order.getChannelNo(), e.getMessage()); + throw exception(HOTEL_ZAX_ORDER_CANCEL_FAIL, StrUtil.maxLength( + StrUtil.blankToDefault(e.getMessage(), "供应方取消失败"), 80)); + } + } + if (Boolean.TRUE.equals(order.getPayStatus())) { + refundIfPaid(order, reason); + } + order.setOrderStatus("CANCELLED"); + order.setFailMsg(StrUtil.maxLength(StrUtil.blankToDefault(reason, "已取消"), 200)); + orderMapper.updateById(order); + } + + private static boolean shouldNotifySupplierCancel(HotelZaxOrderDO order) { + if (order == null || StrUtil.isBlank(order.getChannelNo())) { + return false; + } + if ("UNPAID".equalsIgnoreCase(StrUtil.trim(order.getOrderStatus()))) { + return false; + } + return StrUtil.isNotBlank(order.getOrderNo()); + } + + private void refundIfPaid(HotelZaxOrderDO order, String reason) { + if (order == null || !Boolean.TRUE.equals(order.getPayStatus()) || order.getPayOrderId() == null) { + return; + } + if (isLocalRefunded(order)) { + return; + } + int priceFen = order.getSellAmount() == null ? 0 + : order.getSellAmount().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP).intValue(); + if (priceFen <= 0) { + markLocalRefunded(order); + return; + } + try { + payRefundApi.createRefund(new PayRefundCreateReqDTO() + .setAppKey(StrUtil.blankToDefault(openProperties.getPayAppKey(), "mall")) + .setUserIp(StrUtil.blankToDefault(getClientIP(), "127.0.0.1")) + .setMerchantOrderId(resolvePayMerchantOrderId(order)) + .setMerchantRefundId("HZ" + order.getId()) + .setReason(StrUtil.maxLength(StrUtil.blankToDefault(reason, "酒店订单取消退款"), 128)) + .setPrice(priceFen)); + } + catch (ServiceException e) { + Integer code = e.getCode(); + if (REFUND_EXISTS.getCode().equals(code) || REFUND_HAS_REFUNDING.getCode().equals(code)) { + log.info("|hotel-refund|退款单已存在|orderId={}|code={}|", order.getId(), code); + } + else { + throw e; + } + } + markLocalRefunded(order); + } + + private static boolean isLocalRefunded(HotelZaxOrderDO order) { + if (order == null || StrUtil.isBlank(order.getPayloadJson())) { + return false; + } + try { + return Boolean.TRUE.equals(JSONUtil.parseObj(order.getPayloadJson()).getBool("localRefunded")); + } + catch (Exception e) { + return false; + } + } + + private static void markLocalRefunded(HotelZaxOrderDO order) { + JSONObject p; + try { + p = JSONUtil.parseObj(StrUtil.blankToDefault(order.getPayloadJson(), "{}")); + } + catch (Exception e) { + p = new JSONObject(); + } + p.set("localRefunded", true); + order.setPayloadJson(p.toString()); + } + + /** + * 支付成功后才向资安心下单。channelNo 幂等,失败记 BOOK_FAIL,不回滚已支付。 + */ + private void bookSupplierAfterPaid(HotelZaxOrderDO order) { + if (order == null || StrUtil.isNotBlank(order.getOrderNo())) { + return; + } + Map openBody = rebuildOpenBody(order); + try { + JSONObject createResp = openClient.post("order/create", openBody); + JSONObject data = createResp == null ? null : createResp.getJSONObject("data"); + if (data != null) { + order.setOrderNo(data.getStr("orderNo")); + order.setPlatformNo(data.getStr("platformNo")); + if (StrUtil.isNotBlank(data.getStr("orderStatus"))) { + order.setOrderStatus(data.getStr("orderStatus")); + } + else { + order.setOrderStatus("CREATED"); + } + order.setBookStatus(data.getStr("bookStatus")); + order.setFailMsg(null); + order.setPayloadJson(data.toString()); + } + else { + order.setOrderStatus("CREATED"); + } + orderMapper.updateById(order); + } + catch (Exception e) { + log.error("|hotel-pay|支付成功但供应方下单失败|channelNo={}|{}", order.getChannelNo(), e.getMessage(), e); + order.setOrderStatus("BOOK_FAIL"); + order.setFailMsg(StrUtil.maxLength(StrUtil.blankToDefault(e.getMessage(), "供应方下单失败"), 200)); + orderMapper.updateById(order); + } + } + + @SuppressWarnings("unchecked") + private Map rebuildOpenBody(HotelZaxOrderDO order) { + if (StrUtil.isNotBlank(order.getPayloadJson())) { + try { + JSONObject p = JSONUtil.parseObj(order.getPayloadJson()); + if (StrUtil.isNotBlank(p.getStr("channelNo")) && StrUtil.isNotBlank(p.getStr("hotelCode"))) { + return p; + } + } + catch (Exception ignored) { + // 回退字段拼装 + } + } + Map openBody = new LinkedHashMap<>(); + openBody.put("channelNo", order.getChannelNo()); + openBody.put("hotelCode", order.getHotelCode()); + openBody.put("productCode", order.getProductCode()); + openBody.put("roomTypeCode", order.getRoomTypeCode()); + openBody.put("checkInDate", order.getCheckInDate() == null ? null : order.getCheckInDate().format(DAY)); + openBody.put("checkOutDate", order.getCheckOutDate() == null ? null : order.getCheckOutDate().format(DAY)); + openBody.put("count", order.getRoomCount() == null ? 1 : order.getRoomCount()); + openBody.put("totalAmount", order.getSellAmount()); + openBody.put("contactName", order.getContactName()); + openBody.put("contactPhone", order.getContactPhone()); + openBody.put("guestList", JSONUtil.parseArray(StrUtil.blankToDefault(order.getGuestJson(), "[]"))); + return openBody; + } + + private BigDecimal quoteStayAmount(String hotelCode, String productCode, LocalDate checkIn, LocalDate checkOut, + int rooms) { + BigDecimal local = hotelZaxLocalService.quoteStay(hotelCode, productCode, checkIn, checkOut, rooms); + if (local != null) { + return local; + } + try { + Map req = new HashMap(); + req.put("hotelCode", hotelCode); + req.put("productCode", productCode); + req.put("startDate", checkIn.format(DAY)); + req.put("endDate", checkOut.minusDays(1).format(DAY)); + JSONObject resp = openClient.post("hotel/daily", req); + JSONObject data = resp == null ? null : resp.getJSONObject("data"); + if (data == null) { + return null; + } + JSONArray list = data.getJSONArray("list"); + if (list == null || list.isEmpty()) { + return null; + } + List> rows = new ArrayList>(); + for (int i = 0; i < list.size(); i++) { + JSONObject row = list.getJSONObject(i); + if (row != null) { + rows.add(row); + } + } + return hotelZaxLocalService.quoteFromDailyRows(rows, productCode, checkIn, checkOut, rooms); + } + catch (Exception e) { + log.warn("|hotel-quote|供应方日历失败|hotel={}|product={}|{}", hotelCode, productCode, e.getMessage()); + return null; + } + } + + private Map toView(HotelZaxOrderDO order) { + if (order == null) { + return null; + } + Map m = new LinkedHashMap<>(); + m.put("id", order.getId()); + m.put("channelNo", order.getChannelNo()); + m.put("orderNo", order.getOrderNo()); + m.put("platformNo", order.getPlatformNo()); + m.put("hotelCode", order.getHotelCode()); + m.put("hotelName", order.getHotelName()); + m.put("productCode", order.getProductCode()); + m.put("productName", order.getProductName()); + m.put("roomTypeCode", order.getRoomTypeCode()); + m.put("checkInDate", order.getCheckInDate() == null ? null : order.getCheckInDate().format(DAY)); + m.put("checkOutDate", order.getCheckOutDate() == null ? null : order.getCheckOutDate().format(DAY)); + m.put("nights", order.getNights()); + m.put("count", order.getRoomCount()); + m.put("contactName", order.getContactName()); + m.put("contactPhone", order.getContactPhone()); + m.put("guestList", JSONUtil.parseArray(StrUtil.blankToDefault(order.getGuestJson(), "[]"))); + m.put("totalAmount", order.getSellAmount()); + m.put("payOrderId", order.getPayOrderId()); + m.put("payStatus", order.getPayStatus()); + m.put("payTime", order.getPayTime()); + m.put("orderStatus", order.getOrderStatus()); + m.put("status", order.getOrderStatus()); + m.put("statusName", customerStatusName(order.getOrderStatus(), order.getPayStatus())); + m.put("bookStatus", order.getBookStatus()); + m.put("failMsg", order.getFailMsg()); + m.put("createTime", order.getCreateTime()); + boolean canPay = isAwaitingPay(order); + m.put("canPay", canPay); + if (canPay && order.getCreateTime() != null) { + m.put("payExpireTime", order.getCreateTime().plus(PAY_EXPIRE)); + m.put("expireTime", order.getCreateTime().plus(PAY_EXPIRE)); + } + return m; + } + + @SuppressWarnings("unchecked") + private static List> parseGuests(Object raw) { + List> list = new ArrayList<>(); + if (raw == null) { + return list; + } + if (raw instanceof List) { + for (Object o : (List) raw) { + if (o instanceof Map) { + Map src = (Map) o; + String name = trim(src.get("name")); + String mobile = trim(src.get("mobile")); + if (StrUtil.isBlank(mobile)) { + mobile = trim(src.get("guestPhone")); + } + if (StrUtil.isBlank(mobile)) { + mobile = trim(src.get("phone")); + } + if (StrUtil.isBlank(name)) { + continue; + } + Map g = new LinkedHashMap<>(); + g.put("name", name); + if (StrUtil.isNotBlank(mobile)) { + g.put("mobile", mobile); + g.put("guestPhone", mobile); + } + list.add(g); + } + else if (o != null && StrUtil.isNotBlank(String.valueOf(o))) { + Map g = new LinkedHashMap<>(); + g.put("name", String.valueOf(o).trim()); + list.add(g); + } + } + return list; + } + JSONArray arr = JSONUtil.parseArray(JSONUtil.toJsonStr(raw)); + for (int i = 0; i < arr.size(); i++) { + JSONObject o = arr.getJSONObject(i); + if (o == null || StrUtil.isBlank(o.getStr("name"))) { + continue; + } + Map g = new LinkedHashMap<>(); + g.put("name", o.getStr("name").trim()); + String mobile = StrUtil.blankToDefault(o.getStr("mobile"), + StrUtil.blankToDefault(o.getStr("guestPhone"), o.getStr("phone"))); + if (StrUtil.isNotBlank(mobile)) { + g.put("mobile", mobile.trim()); + g.put("guestPhone", mobile.trim()); + } + list.add(g); + } + return list; + } + + private static LocalDate parseDay(String s) { + if (StrUtil.isBlank(s)) { + return null; + } + try { + return LocalDate.parse(s.trim(), DAY); + } + catch (Exception e) { + return null; + } + } + + private static BigDecimal toMoney(Object o) { + if (o == null || StrUtil.isBlank(String.valueOf(o))) { + return null; + } + try { + return new BigDecimal(String.valueOf(o).trim()).setScale(2, RoundingMode.HALF_UP); + } + catch (Exception e) { + return null; + } + } + + private static int toInt(Object o, int def) { + if (o == null) { + return def; + } + try { + return Integer.parseInt(String.valueOf(o).trim()); + } + catch (Exception e) { + return def; + } + } + + private static String trim(Object o) { + return o == null ? null : StrUtil.trim(String.valueOf(o)); + } + + private void syncPaidFromPayOrder(HotelZaxOrderDO order) { + if (order == null || order.getPayOrderId() == null || Boolean.TRUE.equals(order.getPayStatus())) { + return; + } + try { + updatePaidIfMatch(order.getId(), order.getPayOrderId()); + } + catch (Exception e) { + log.warn("|hotel-pay|同步支付状态失败|orderId={}|payOrderId={}|{}", order.getId(), order.getPayOrderId(), + e.getMessage()); + } + } + + private String resolvePayMerchantOrderId(HotelZaxOrderDO order) { + if (order.getPayOrderId() != null) { + try { + PayOrderRespDTO payOrder = payOrderApi.getOrder(order.getPayOrderId()); + if (payOrder != null && StrUtil.isNotBlank(payOrder.getMerchantOrderId())) { + return payOrder.getMerchantOrderId(); + } + } + catch (Exception ignored) { + // 回退本地约定 + } + } + return PAY_MERCHANT_PREFIX + order.getId(); + } + + private static Long parseHotelMerchantOrderId(String merchantOrderId) { + String raw = StrUtil.trim(merchantOrderId); + if (StrUtil.isBlank(raw)) { + return null; + } + String num = raw; + if (StrUtil.startWithIgnoreCase(raw, PAY_MERCHANT_PREFIX)) { + num = raw.substring(PAY_MERCHANT_PREFIX.length()); + } + else if (StrUtil.startWithIgnoreCase(raw, "hotel-")) { + num = raw.substring("hotel-".length()); + } + try { + return Long.valueOf(num); + } + catch (Exception e) { + return null; + } + } + + private static boolean isAwaitingPay(HotelZaxOrderDO order) { + if (order == null || Boolean.TRUE.equals(order.getPayStatus())) { + return false; + } + String s = StrUtil.trim(order.getOrderStatus()); + if (StrUtil.isBlank(s) || "UNPAID".equalsIgnoreCase(s)) { + return true; + } + return false; + } + + private static boolean isTerminalUnbookable(String orderStatus) { + String s = StrUtil.trim(orderStatus); + return "CANCELLED".equalsIgnoreCase(s) || "CANCELED".equalsIgnoreCase(s) + || "COMPLETED".equalsIgnoreCase(s) || "BOOK_FAIL".equalsIgnoreCase(s) + || "UPSTREAM_FAIL".equalsIgnoreCase(s); + } + + /** 顾客端状态文案,不直接展示内部英文码 */ + private static String customerStatusName(String orderStatus, Boolean payStatus) { + String s = StrUtil.trim(orderStatus); + if ("UNPAID".equals(s)) { + return "待支付"; + } + if ("PAID".equals(s) || "PENDING".equals(s)) { + return "已支付,预定中"; + } + if ("PENDING_CONFIRM".equals(s) || "NEED_MANUAL".equals(s)) { + return "预定处理中"; + } + if ("CREATED".equals(s) || "UPSTREAM_OK".equals(s)) { + return "预定成功"; + } + if ("CHECKED_IN".equals(s)) { + return "已入住"; + } + if ("COMPLETED".equals(s)) { + return "已完成"; + } + if ("CANCELLED".equals(s) || "CANCELED".equals(s)) { + return "已取消"; + } + if ("BOOK_FAIL".equals(s) || "UPSTREAM_FAIL".equals(s)) { + return "预定失败"; + } + if (Boolean.TRUE.equals(payStatus)) { + return "已支付"; + } + return "待支付"; + } +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/order/AppTradeOrderController.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/order/AppTradeOrderController.java index 00d3ce6..c73b1ff 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/order/AppTradeOrderController.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/order/AppTradeOrderController.java @@ -1,8 +1,10 @@ package cn.iocoder.yudao.module.trade.controller.app.order; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.pay.api.notify.dto.PayOrderNotifyReqDTO; +import cn.iocoder.yudao.module.product.api.hotel.HotelZaxOrderApi; import cn.iocoder.yudao.module.trade.controller.app.order.vo.*; import cn.iocoder.yudao.module.trade.controller.app.order.vo.item.AppTradeOrderItemCommentCreateReqVO; import cn.iocoder.yudao.module.trade.controller.app.order.vo.item.AppTradeOrderItemRespVO; @@ -36,6 +38,7 @@ import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.ORDER_NOT_FOUND; @Tag(name = "用户 App - 交易订单") @RestController @@ -49,6 +52,8 @@ public class AppTradeOrderController { @Resource private TradeOrderQueryService tradeOrderQueryService; @Resource + private HotelZaxOrderApi hotelZaxOrderApi; + @Resource private DeliveryExpressService deliveryExpressService; @Resource private AfterSaleService afterSaleService; @@ -111,8 +116,25 @@ public class AppTradeOrderController { @Operation(summary = "更新订单为已支付") // 由 pay-module 支付服务,进行回调,可见 PayNotifyJob @PermitAll public CommonResult updateOrderPaid(@RequestBody PayOrderNotifyReqDTO notifyReqDTO) { - tradeOrderUpdateService.updateOrderPaid(Long.valueOf(notifyReqDTO.getMerchantOrderId()), - notifyReqDTO.getPayOrderId()); + Long payOrderId = notifyReqDTO.getPayOrderId(); + // 酒店与商城共用 mall 支付应用:商户单号可能与 trade_order.id 撞号,必须先按支付单认酒店单 + if (hotelZaxOrderApi.handlePayNotify(notifyReqDTO.getMerchantOrderId(), payOrderId)) { + return success(true); + } + Long merchantOrderId; + try { + merchantOrderId = Long.valueOf(notifyReqDTO.getMerchantOrderId()); + } catch (Exception e) { + throw new ServiceException(ORDER_NOT_FOUND); + } + try { + tradeOrderUpdateService.updateOrderPaid(merchantOrderId, payOrderId); + } catch (ServiceException ex) { + if (hotelZaxOrderApi.handlePayNotify(notifyReqDTO.getMerchantOrderId(), payOrderId)) { + return success(true); + } + throw ex; + } return success(true); } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/order/vo/AppTradeOrderPageItemRespVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/order/vo/AppTradeOrderPageItemRespVO.java index ba7b813..6ed423b 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/order/vo/AppTradeOrderPageItemRespVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/order/vo/AppTradeOrderPageItemRespVO.java @@ -32,6 +32,9 @@ public class AppTradeOrderPageItemRespVO { @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime createTime; + @Schema(description = "支付超时时间") + private LocalDateTime payExpireTime; + // ========== 价格 + 支付基本信息 ========== @Schema(description = "支付订单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java index 10cf8ec..39f0485 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java @@ -205,7 +205,11 @@ public interface TradeOrderConvert { // 转化 List List orderVOs = CollectionUtils.convertList(pageResult.getList(), order -> { List xOrderItems = orderItemMap.get(order.getId()); - return convert02(order, xOrderItems); + AppTradeOrderPageItemRespVO vo = convert02(order, xOrderItems); + if (vo != null && order.getCreateTime() != null) { + vo.setPayExpireTime(order.getCreateTime().plusMinutes(10)); + } + return vo; }); return new PageResult<>(orderVOs, pageResult.getTotal()); } diff --git a/yudao-server/src/main/resources/application-dev.yaml b/yudao-server/src/main/resources/application-dev.yaml index 0035dd6..0f08978 100644 --- a/yudao-server/src/main/resources/application-dev.yaml +++ b/yudao-server/src/main/resources/application-dev.yaml @@ -225,4 +225,14 @@ iot: # 插件配置 pf4j: - pluginsDir: ${user.home}/plugins # 插件目录 \ No newline at end of file + pluginsDir: ${user.home}/plugins # 插件目录 + +# 酒店供应(资安心开放平台中转,密钥勿下发小程序) +xrhome: + hotel: + zax: + enabled: false + # 必须含到 /third/party/open,例:http://localhost:9999/api/supermarket/third/party/open + base-url: "" + app-key: "" + app-secret: "" \ No newline at end of file diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml index dcded3c..02e0ee2 100644 --- a/yudao-server/src/main/resources/application-local.yaml +++ b/yudao-server/src/main/resources/application-local.yaml @@ -261,4 +261,22 @@ justauth: --- #################### iot相关配置 TODO 芋艿【IOT】:再瞅瞅 #################### pf4j: # pluginsDir: /tmp/ - pluginsDir: ../plugins \ No newline at end of file + pluginsDir: ../plugins + +--- #################### 酒店供应(资安心开放平台中转,密钥勿下发小程序) #################### +xrhome: + hotel: + zax: + enabled: true + # 本地网关:不要带 /api(全局 Strip 第一段后路由是 /supermarket/**) + # 生产示例:https://www.zianxin.com/api/supermarket/third/party/open + base-url: "http://localhost:9999/supermarket/third/party/open" + app-key: "OA2112419111353806850" + app-secret: "13bdf82c3c3046119942e65c6d51a665f7010d1c" + # 回调无租户头时落库租户;资安心开放应用回调根地址示例: + # callbackBaseUrl=http://localhost:48089/app-api + # callbackResourcePath=/hotel/resource/notify + # callbackOrderPath=/order/status/notify + callback-tenant-id: 1 + # 微信支付应用(支付管理→应用信息),默认复用商城 mall + pay-app-key: mall \ No newline at end of file diff --git a/yudao-server/src/main/resources/application-prod.yaml b/yudao-server/src/main/resources/application-prod.yaml index dc11f31..62e82cb 100644 --- a/yudao-server/src/main/resources/application-prod.yaml +++ b/yudao-server/src/main/resources/application-prod.yaml @@ -243,3 +243,19 @@ justauth: prefix: 'social_auth_state:' # 缓存前缀,目前只对 Redis 缓存生效,默认 JUSTAUTH::STATE:: timeout: 24h # 超时时长,目前只对 Redis 缓存生效,默认 3 分钟 +xrhome: + hotel: + zax: + enabled: true + # 本地网关:不要带 /api(全局 Strip 第一段后路由是 /supermarket/**) + # 生产示例:https://www.zianxin.com/api/supermarket/third/party/open + base-url: "https://www.zianxin.com/api/supermarket/third/party/open" + app-key: "OA2112419111353806850" + app-secret: "13bdf82c3c3046119942e65c6d51a665f7010d1c" + # 回调无租户头时落库租户;资安心开放应用回调根地址示例: + # callbackBaseUrl=http://localhost:48089/app-api + # callbackResourcePath=/hotel/resource/notify + # callbackOrderPath=/order/status/notify + callback-tenant-id: 1 + # 微信支付应用(支付管理→应用信息),默认复用商城 mall + pay-app-key: mall \ No newline at end of file diff --git a/yudao-server/src/main/resources/application.yaml b/yudao-server/src/main/resources/application.yaml index f8c6bfb..cff59f2 100644 --- a/yudao-server/src/main/resources/application.yaml +++ b/yudao-server/src/main/resources/application.yaml @@ -245,6 +245,9 @@ yudao: security: permit-all_urls: - /admin-api/mp/open/** # 微信公众号开放平台,微信回调接口,不需要登录 + - /app-api/hotel/resource/notify # 资安心 → 小如 资源回调 + - /app-api/order/status/notify # 资安心 → 小如 订单状态回调 + - /app-api/hotel/order/status/notify websocket: enable: true # websocket的开关 path: /infra/ws # 路径 @@ -284,6 +287,9 @@ yudao: - /admin-api/pay/notify/** # 支付回调通知,不携带租户编号 - /jmreport/* # 积木报表,无法携带租户编号 - /admin-api/mp/open/** # 微信公众号开放平台,微信回调接口,无法携带租户编号 + - /app-api/hotel/resource/notify # 资安心酒店资源回调 + - /app-api/order/status/notify # 资安心酒店订单状态回调 + - /app-api/hotel/order/status/notify # 资安心酒店订单状态回调(备用路径) ignore-tables: - system_tenant - system_tenant_package @@ -346,7 +352,7 @@ yudao: end-code: 9999 # 这里配置 9999 的原因是,测试方便。 trade: order: - pay-expire-time: 2h # 支付的过期时间 + pay-expire-time: 10m # 待支付超时自动取消 receive-expire-time: 14d # 收货的过期时间 comment-expire-time: 7d # 评论的过期时间 express: