小如酒店,优化修复部门界面问题

This commit is contained in:
zjf committed 2026-10-22 15:59:00 +08:00
1 parent 72fd5a4358
commit 5b88ce844c
55 files changed
+4723 -6

No files matched your search

@@ -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);
}
@@ -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, "酒店订单取消失败:{}");
}
@@ -28,6 +28,11 @@
<artifactId>yudao-module-member-api</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-module-pay-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 业务组件 -->
@@ -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);
}
}
@@ -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<PageResult<HotelZaxRespVO>> 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<HotelZaxRespVO> get(@RequestParam("id") Long id) {
return success(hotelZaxAdminService.getDetail(id));
}
@PutMapping("/update")
@Operation(summary = "编辑本地酒店基础信息")
@PreAuthorize("@ss.hasPermission('product:hotel-zax:update')")
public CommonResult<Boolean> 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<Boolean> 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<HotelZaxRoomRespVO> saveRoom(@Valid @RequestBody HotelZaxRoomSaveReqVO req) {
return success(hotelZaxAdminService.saveRoom(req));
}
@DeleteMapping("/room/delete")
@Operation(summary = "删除房型")
@PreAuthorize("@ss.hasPermission('product:hotel-zax:update')")
public CommonResult<Boolean> 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<HotelZaxProductRespVO> saveProduct(@Valid @RequestBody HotelZaxProductSaveReqVO req) {
return success(hotelZaxAdminService.saveProduct(req));
}
@DeleteMapping("/product/delete")
@Operation(summary = "删除产品")
@PreAuthorize("@ss.hasPermission('product:hotel-zax:update')")
public CommonResult<Boolean> 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<PageResult<HotelZaxDailyRespVO>> pageDaily(@Valid HotelZaxDailyPageReqVO req) {
return success(hotelZaxAdminService.pageDaily(req));
}
@PostMapping("/daily/save")
@Operation(summary = "保存日历")
@PreAuthorize("@ss.hasPermission('product:hotel-zax:update')")
public CommonResult<HotelZaxDailyRespVO> 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<Map<String, Object>> batchDaily(@Valid @RequestBody HotelZaxDailyBatchReqVO req) {
return success(hotelZaxAdminService.batchDaily(req));
}
@DeleteMapping("/daily/delete")
@Operation(summary = "删除日历")
@PreAuthorize("@ss.hasPermission('product:hotel-zax:update')")
public CommonResult<Boolean> 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<Map<String, Object>> syncDaily(@RequestParam("hotelId") Long hotelId) {
return success(hotelZaxSyncService.syncDailyByHotelId(hotelId));
}
@GetMapping("/sync-cursor")
@Operation(summary = "同步水位")
@PreAuthorize("@ss.hasPermission('product:hotel-zax:query')")
public CommonResult<Map<String, Object>> syncCursor() {
HotelZaxSyncCursorDO c = cursorMapper.selectOne(new LambdaQueryWrapperX<HotelZaxSyncCursorDO>()
.eq(HotelZaxSyncCursorDO::getCursorKey, "default")
.last("limit 1"));
Map<String, Object> 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<HotelZaxDO>()
.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<PageResult<HotelZaxOrderRespVO>> 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<HotelZaxOrderRespVO> 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<Boolean> 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<Map<String, Object>> 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<Boolean> resetCursor() {
HotelZaxSyncCursorDO c = cursorMapper.selectOne(new LambdaQueryWrapperX<HotelZaxSyncCursorDO>()
.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);
}
}
@@ -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<String> productCodes;
private BigDecimal costPrice;
private BigDecimal sellPrice;
private Integer stock;
private Integer dailyStatus;
@Schema(description = "ADJUST 时是否同时补齐缺失日期")
private Boolean fillMissing;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<HotelZaxRoomRespVO> rooms;
@Schema(description = "产品列表(详情)")
private List<HotelZaxProductRespVO> products;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<Object> list(@RequestBody(required = false) Map<String, Object> 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<Object> filterMeta(@RequestBody(required = false) Map<String, Object> 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<Object> detail(@RequestBody Map<String, Object> body) {
String hotelCode = body == null ? null : str(body.get("hotelCode"));
Map<String, Object> 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<Object> daily(@RequestBody Map<String, Object> body) {
Map<String, Object> 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<Object> orderCheck(@RequestBody Map<String, Object> body) {
return success(dataOf(zaxHotelOpenClient.post("order/check", body)));
}
@PostMapping("/order/create")
@Operation(summary = "创建订单并发起微信支付")
public CommonResult<Object> orderCreate(@RequestBody Map<String, Object> body) {
return success(hotelZaxTradeService.createOrderAndPay(getLoginUserId(), body));
}
@PostMapping("/order/cancel")
@Operation(summary = "取消订单(已支付则退款,并通知资安心)")
public CommonResult<Object> orderCancel(@RequestBody Map<String, Object> body) {
String channelNo = body == null ? null : str(body.get("channelNo"));
hotelZaxTradeService.cancelByChannelNo(getLoginUserId(), channelNo, "用户取消");
Map<String, Object> local = hotelZaxTradeService.getByChannelNo(getLoginUserId(), channelNo);
return success(local);
}
@PostMapping("/order/page")
@Operation(summary = "我的酒店订单分页")
public CommonResult<PageResult<Map<String, Object>>> orderPage(
@RequestBody(required = false) Map<String, Object> 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<Object> orderQuery(@RequestBody Map<String, Object> 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<String, Object> 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<String, Object> 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<Object> 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);
}
}
@@ -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;
/**
* 资安心 → 小如:开放平台回调接收(与第三方下游约定路径一致,便于把小如当标准样板)。
* <p>
* 资安心开放应用配置示例:
* <ul>
* <li>callbackBaseUrl = http://{小如}:{port}/app-api</li>
* <li>callbackResourcePath = /hotel/resource/notify</li>
* <li>callbackOrderPath = /order/status/notify</li>
* </ul>
*/
@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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> orderStatusNotify(HttpServletRequest request,
@RequestBody String rawBody) {
callbackAuth.assertValid(request, rawBody);
Long tenantId = openProperties.getCallbackTenantId() == null ? 1L : openProperties.getCallbackTenantId();
Map<String, Object> result = TenantUtils.execute(tenantId, () -> orderCallbackService.upsertFromNotify(rawBody));
return success(result);
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<HotelZaxDailyDO> {
}
@@ -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<HotelZaxDO> {
}
@@ -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<HotelZaxOrderDO> {
}
@@ -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<HotelZaxProductDO> {
}
@@ -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<HotelZaxRoomDO> {
}
@@ -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<HotelZaxSyncCursorDO> {
}
@@ -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<String, String> 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();
}
}
@@ -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<String, Object> 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<String, String> 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;
}
}
@@ -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;
}
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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"));
}
}
@@ -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());
}
});
}
}
@@ -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<HotelZaxRespVO> 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<HotelZaxDailyRespVO> pageDaily(HotelZaxDailyPageReqVO req);
HotelZaxDailyRespVO saveDaily(HotelZaxDailySaveReqVO req);
void removeDaily(Long hotelId, Long dailyId);
Map<String, Object> batchDaily(HotelZaxDailyBatchReqVO req);
PageResult<HotelZaxOrderRespVO> pageOrders(HotelZaxOrderPageReqVO req);
HotelZaxOrderRespVO getOrder(Long id);
/** 后台取消:已支付原路退款,并通知资安心取消预订 */
void cancelOrder(Long id, String reason);
}
@@ -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<HotelZaxRespVO> page(HotelZaxPageReqVO req) {
PageResult<HotelZaxDO> page = hotelZaxMapper.selectPage(req, new LambdaQueryWrapperX<HotelZaxDO>()
.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<HotelZaxRespVO> 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<HotelZaxRoomDO> rooms = roomMapper.selectList(new LambdaQueryWrapperX<HotelZaxRoomDO>()
.eq(HotelZaxRoomDO::getHotelId, id)
.orderByAsc(HotelZaxRoomDO::getRoomTypeCode));
List<HotelZaxProductDO> products = productMapper.selectList(new LambdaQueryWrapperX<HotelZaxProductDO>()
.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<HotelZaxRoomDO>()
.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<HotelZaxProductDO>()
.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<HotelZaxRoomDO>()
.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<HotelZaxProductDO>()
.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<HotelZaxDailyDO>()
.eq(HotelZaxDailyDO::getHotelId, hotelId)
.eq(HotelZaxDailyDO::getProductCode, old.getProductCode()));
productMapper.deleteById(productId);
refreshHotelProductsJson(hotelId);
}
@Override
public PageResult<HotelZaxDailyRespVO> pageDaily(HotelZaxDailyPageReqVO req) {
requireHotel(req.getHotelId());
LocalDate from = parseDay(req.getStayFrom());
LocalDate to = parseDay(req.getStayTo());
PageResult<HotelZaxDailyDO> page = dailyMapper.selectPage(req, new LambdaQueryWrapperX<HotelZaxDailyDO>()
.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<HotelZaxDailyRespVO> 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<HotelZaxDailyDO>()
.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<String, Object> 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<HotelZaxProductDO> 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<String> productCodes = products.stream().map(HotelZaxProductDO::getProductCode).collect(Collectors.toList());
List<HotelZaxDailyDO> existRows = dailyMapper.selectList(new LambdaQueryWrapperX<HotelZaxDailyDO>()
.eq(HotelZaxDailyDO::getHotelId, hotel.getId())
.in(HotelZaxDailyDO::getProductCode, productCodes)
.ge(HotelZaxDailyDO::getStayDate, req.getStartDate())
.le(HotelZaxDailyDO::getStayDate, req.getEndDate()));
Map<String, HotelZaxDailyDO> 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<String, Object> out = new LinkedHashMap<>(8);
out.put("inserted", inserted);
out.put("updated", updated);
out.put("skippedExist", skippedExist);
out.put("skippedStock", skippedStock);
return out;
}
private List<HotelZaxProductDO> loadProductsForBatch(Long hotelId, List<String> productCodes) {
LambdaQueryWrapperX<HotelZaxProductDO> w = new LambdaQueryWrapperX<HotelZaxProductDO>()
.eq(HotelZaxProductDO::getHotelId, hotelId);
if (CollUtil.isNotEmpty(productCodes)) {
List<String> 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<HotelZaxProductDO>()
.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<HotelZaxRespVO> list) {
if (CollUtil.isEmpty(list)) {
return;
}
List<Long> ids = list.stream().map(HotelZaxRespVO::getId).filter(Objects::nonNull).collect(Collectors.toList());
if (ids.isEmpty()) {
return;
}
List<HotelZaxRoomDO> rooms = roomMapper.selectList(new LambdaQueryWrapperX<HotelZaxRoomDO>()
.in(HotelZaxRoomDO::getHotelId, ids)
.select(HotelZaxRoomDO::getHotelId));
List<HotelZaxProductDO> products = productMapper.selectList(new LambdaQueryWrapperX<HotelZaxProductDO>()
.in(HotelZaxProductDO::getHotelId, ids)
.select(HotelZaxProductDO::getHotelId));
Map<Long, Integer> roomMap = new HashMap<>();
Map<Long, Integer> 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<HotelZaxRoomDO> rooms = roomMapper.selectList(new LambdaQueryWrapperX<HotelZaxRoomDO>()
.eq(HotelZaxRoomDO::getHotelId, hotelId)
.orderByAsc(HotelZaxRoomDO::getRoomTypeCode));
List<Map<String, Object>> arr = rooms.stream().map(r -> {
Map<String, Object> 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<HotelZaxProductDO> products = productMapper.selectList(new LambdaQueryWrapperX<HotelZaxProductDO>()
.eq(HotelZaxProductDO::getHotelId, hotelId)
.orderByAsc(HotelZaxProductDO::getProductCode));
List<Map<String, Object>> arr = products.stream().map(p -> {
Map<String, Object> 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<HotelZaxOrderRespVO> pageOrders(HotelZaxOrderPageReqVO req) {
PageResult<HotelZaxOrderDO> page = orderMapper.selectPage(req, new LambdaQueryWrapperX<HotelZaxOrderDO>()
.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;
}
}
@@ -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<String, Object> pageList(Map<String, Object> body);
Map<String, Object> detail(String hotelCode);
Map<String, Object> filterMeta();
/** 本地是否已有上架数据 */
boolean hasLocalData();
/**
* 本地日历;无本地日历时返回 null,由调用方回退供应方。
*/
Map<String, Object> daily(Map<String, Object> body);
/**
* 按日历逐夜累计房价(入住日含、离店日不含)× 间数。
* 任一夜无开房日历或库存不足则返回 null。
*/
BigDecimal quoteStay(String hotelCode, String productCode, LocalDate checkIn, LocalDate checkOut, int rooms);
/** 用已查到的日历行报价,规则同 quoteStay。 */
BigDecimal quoteFromDailyRows(List<Map<String, Object>> rows, String productCode, LocalDate checkIn,
LocalDate checkOut, int rooms);
}
@@ -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<HotelZaxDO>()
.eq(HotelZaxDO::getStatus, "ACTIVE")) > 0;
}
@Override
public Map<String, Object> pageList(Map<String, Object> 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<HotelZaxDO> qw = new LambdaQueryWrapperX<HotelZaxDO>()
.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<HotelZaxDO> page = hotelZaxMapper.selectPage(new Page<>(current, size), qw);
List<Map<String, Object>> records = page.getRecords().stream().map(this::toBrief).collect(Collectors.toList());
Map<String, Object> 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<String, Object> detail(String hotelCode) {
if (StrUtil.isBlank(hotelCode)) {
return null;
}
HotelZaxDO h = hotelZaxMapper.selectOne(new LambdaQueryWrapperX<HotelZaxDO>()
.eq(HotelZaxDO::getHotelCode, hotelCode.trim())
.last("limit 1"));
if (h == null) {
return null;
}
Map<String, Object> data = toBrief(h);
data.put("carouselImages", h.getCarouselImages());
data.put("intro", h.getIntro());
data.put("phone", h.getPhone());
List<Map<String, Object>> rooms = loadRooms(h.getHotelCode());
List<Map<String, Object>> 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<String, Object> daily(Map<String, Object> body) {
String hotelCode = trim(body == null ? null : body.get("hotelCode"));
if (StrUtil.isBlank(hotelCode)) {
return null;
}
HotelZaxDO h = hotelZaxMapper.selectOne(new LambdaQueryWrapperX<HotelZaxDO>()
.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<HotelZaxDailyDO> dailies = dailyMapper.selectList(new LambdaQueryWrapperX<HotelZaxDailyDO>()
.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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> body = new LinkedHashMap<String, Object>(8);
body.put("hotelCode", hotelCode);
body.put("productCode", productCode);
body.put("startDate", checkIn.toString());
body.put("endDate", checkOut.minusDays(1).toString());
Map<String, Object> data = daily(body);
if (data == null || data.get("list") == null) {
return null;
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = (List<Map<String, Object>>) data.get("list");
return quoteFromDailyRows(rows, productCode, checkIn, checkOut, rooms);
}
@Override
public BigDecimal quoteFromDailyRows(List<Map<String, Object>> 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<String, Map<String, Object>> byDay = new LinkedHashMap<String, Map<String, Object>>();
for (Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> loadRooms(String hotelCode) {
List<HotelZaxRoomDO> list = roomMapper.selectList(new LambdaQueryWrapperX<HotelZaxRoomDO>()
.eq(HotelZaxRoomDO::getHotelCode, hotelCode)
.orderByAsc(HotelZaxRoomDO::getRoomTypeCode));
List<Map<String, Object>> out = new ArrayList<>();
for (HotelZaxRoomDO r : list) {
if (r == null) {
continue;
}
Map<String, Object> 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<Map<String, Object>> loadProducts(String hotelCode) {
List<HotelZaxProductDO> list = productMapper.selectList(new LambdaQueryWrapperX<HotelZaxProductDO>()
.eq(HotelZaxProductDO::getHotelCode, hotelCode)
.orderByAsc(HotelZaxProductDO::getProductCode));
List<Map<String, Object>> out = new ArrayList<>();
for (HotelZaxProductDO p : list) {
if (p == null) {
continue;
}
Map<String, Object> 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<String, Object> filterMeta() {
List<HotelZaxDO> rows = hotelZaxMapper.selectList(new LambdaQueryWrapperX<HotelZaxDO>()
.select(HotelZaxDO::getDistrict, HotelZaxDO::getDistrictAdcode, HotelZaxDO::getBizArea)
.eq(HotelZaxDO::getStatus, "ACTIVE"));
Map<Long, String> areaByAdcode = new LinkedHashMap<>();
Set<String> districts = new LinkedHashSet<>();
Set<String> 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<Map<String, Object>> areas = areaByAdcode.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(e -> {
Map<String, Object> one = new LinkedHashMap<>(4);
one.put("districtAdcode", e.getKey());
one.put("district", e.getValue());
return one;
})
.collect(Collectors.toList());
List<Map<String, Object>> starLevels = new ArrayList<>();
starLevels.add(starItem(5, "五星"));
starLevels.add(starItem(4, "高档"));
starLevels.add(starItem(3, "舒适"));
starLevels.add(starItem(2, "经济"));
starLevels.add(starItem(1, "民宿"));
Map<String, Object> 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<String, Object> starItem(int value, String label) {
Map<String, Object> one = new LinkedHashMap<>(2);
one.put("value", value);
one.put("label", label);
return one;
}
private Map<String, Object> toBrief(HotelZaxDO h) {
Map<String, Object> 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;
}
}
}
@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.product.service.hotel;
import java.util.Map;
/**
* 供应方订单状态回调落库。
*/
public interface HotelZaxOrderCallbackService {
/**
* @param rawBody 原始 JSON(验签后)
* @return 落库摘要
*/
Map<String, Object> upsertFromNotify(String rawBody);
}
@@ -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<String, Object> upsertFromNotify(String rawBody) {
return hotelZaxTradeService.applySupplierStatusNotify(JSONUtil.parseObj(StrUtil.blankToDefault(rawBody, "{}")));
}
}
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.product.service.hotel;
import java.util.Map;
/**
* 从资安心开放平台增量同步到本地库。
*/
public interface HotelZaxSyncService {
/**
* 拉取一轮同步(默认全量,适配授权范围变更)。
* @return 统计信息
*/
Map<String, Object> syncOnce();
/**
* @param full true=忽略水位按当前授权全量拉;false=按水位增量
*/
Map<String, Object> syncOnce(boolean full);
/**
* 单店拉取详情并落库(资源回调触发)。
*/
Map<String, Object> syncHotelByCode(String hotelCode);
/**
* 标记下架/删除(资源回调 HOTEL_OFFLINE / HOTEL_DELETE)。
*/
void markOffline(String hotelCode);
/**
* 按本地酒店 id 拉取资安心价态日历并落库。
*/
Map<String, Object> syncDailyByHotelId(Long hotelId);
}
@@ -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<String, Object> syncOnce() {
// 管理端「同步资安心」默认全量:改授权范围后增量水位拉不到新授权酒店
return syncOnce(true);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> 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<String> syncedCodes = new HashSet<>();
while (hasMore && pageNo <= 40) {
Map<String, Object> 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<String, Object> 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<String> syncedCodes) {
List<HotelZaxDO> 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<String, Object> 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<String, Object> 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<HotelZaxDO>()
.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<HotelZaxDO>()
.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<String, Object> 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<HotelZaxDO>()
.eq(HotelZaxDO::getHotelCode, hotelCode)
.last("limit 1"));
if (exist == null) {
upsertHotelBrief(data);
exist = hotelZaxMapper.selectOne(new LambdaQueryWrapperX<HotelZaxDO>()
.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<String, Object> 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<String, Object> out = new LinkedHashMap<String, Object>(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<String, Object> req = new HashMap<String, Object>();
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<HotelZaxProductDO> products = productMapper.selectList(new LambdaQueryWrapperX<HotelZaxProductDO>()
.eq(HotelZaxProductDO::getHotelId, hotel.getId()));
Map<String, HotelZaxProductDO> productMap = new HashMap<String, HotelZaxProductDO>();
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<HotelZaxDailyDO>()
.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<HotelZaxRoomDO> old = roomMapper.selectList(new LambdaQueryWrapperX<HotelZaxRoomDO>()
.eq(HotelZaxRoomDO::getHotelCode, hotel.getHotelCode()));
Set<String> 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<HotelZaxRoomDO>()
.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<HotelZaxProductDO> old = productMapper.selectList(new LambdaQueryWrapperX<HotelZaxProductDO>()
.eq(HotelZaxProductDO::getHotelCode, hotel.getHotelCode()));
Set<String> 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<HotelZaxProductDO>()
.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<HotelZaxDailyDO>()
.eq(HotelZaxDailyDO::getHotelId, hotel.getId())
.eq(HotelZaxDailyDO::getProductCode, o.getProductCode()));
productMapper.deleteById(o.getId());
}
}
}
private HotelZaxSyncCursorDO getOrCreateCursor() {
HotelZaxSyncCursorDO c = cursorMapper.selectOne(new LambdaQueryWrapperX<HotelZaxSyncCursorDO>()
.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;
}
}
}
@@ -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<String, Object> createOrderAndPay(Long userId, Map<String, Object> body);
/** 支付回调:标记已支付,再向资安心预定 */
boolean updatePaidIfMatch(Long orderId, Long payOrderId);
/**
* 支付成功通知。商户单号可能与商城订单撞号,先按 payOrderId 认酒店单。
*
* @return true=酒店单已处理
*/
boolean handlePayNotify(String merchantOrderId, Long payOrderId);
/** 按支付单查询本地订单详情(供小程序支付结果页) */
Map<String, Object> getByPayOrderId(Long userId, Long payOrderId);
/** 按渠道单号查询 */
Map<String, Object> getByChannelNo(Long userId, String channelNo);
/** 当前用户酒店订单分页 */
PageResult<Map<String, Object>> pageMyOrders(Long userId, Integer pageNo, Integer pageSize,
String orderStatus, Boolean payStatus);
default PageResult<Map<String, Object>> 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<String, Object> applySupplierStatusNotify(Map<String, Object> body);
}
@@ -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<String, Object> createOrderAndPay(Long userId, Map<String, Object> body) {
if (userId == null) {
throw invalidParamException("请先登录");
}
Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<HotelZaxOrderDO>()
.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<String, Object> getByPayOrderId(Long userId, Long payOrderId) {
if (payOrderId == null) {
return null;
}
HotelZaxOrderDO order = orderMapper.selectOne(new LambdaQueryWrapperX<HotelZaxOrderDO>()
.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<String, Object> getByChannelNo(Long userId, String channelNo) {
if (StrUtil.isBlank(channelNo)) {
return null;
}
HotelZaxOrderDO order = orderMapper.selectOne(new LambdaQueryWrapperX<HotelZaxOrderDO>()
.eq(HotelZaxOrderDO::getChannelNo, channelNo.trim())
.eqIfPresent(HotelZaxOrderDO::getUserId, userId)
.last("limit 1"));
return toView(order);
}
@Override
public PageResult<Map<String, Object>> 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<HotelZaxOrderDO> page = orderMapper.selectPage(pageParam,
new LambdaQueryWrapperX<HotelZaxOrderDO>()
.eq(HotelZaxOrderDO::getUserId, userId)
.eqIfPresent(HotelZaxOrderDO::getOrderStatus, StrUtil.trim(orderStatus))
.eqIfPresent(HotelZaxOrderDO::getPayStatus, payStatus)
.orderByDesc(HotelZaxOrderDO::getId));
List<Map<String, Object>> list = new ArrayList<Map<String, Object>>();
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<Map<String, Object>>(list, page.getTotal());
}
@Override
public int cancelUnpaidTimeout() {
LocalDateTime expireBefore = LocalDateTime.now().minus(PAY_EXPIRE);
List<HotelZaxOrderDO> list = orderMapper.selectList(new LambdaQueryWrapperX<HotelZaxOrderDO>()
.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<HotelZaxOrderDO>()
.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<String, Object> applySupplierStatusNotify(Map<String, Object> body) {
Map<String, Object> src = body == null ? new HashMap<String, Object>() : body;
String channelNo = trim(src.get("channelNo"));
if (StrUtil.isBlank(channelNo)) {
throw invalidParamException("channelNo 不能为空");
}
HotelZaxOrderDO exist = orderMapper.selectOne(new LambdaQueryWrapperX<HotelZaxOrderDO>()
.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<String, Object> 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<String, Object> req = new HashMap<String, Object>();
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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> req = new HashMap<String, Object>();
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<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
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<String, Object> toView(HotelZaxOrderDO order) {
if (order == null) {
return null;
}
Map<String, Object> 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<Map<String, Object>> parseGuests(Object raw) {
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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 "待支付";
}
}
@@ -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<Boolean> 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);
}
@@ -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")
@@ -205,7 +205,11 @@ public interface TradeOrderConvert {
// 转化 List
List<AppTradeOrderPageItemRespVO> orderVOs = CollectionUtils.convertList(pageResult.getList(), order -> {
List<TradeOrderItemDO> 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());
}
@@ -225,4 +225,14 @@ iot:
# 插件配置
pf4j:
pluginsDir: ${user.home}/plugins # 插件目录
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: ""
@@ -261,4 +261,22 @@ justauth:
--- #################### iot相关配置 TODO 芋艿【IOT】:再瞅瞅 ####################
pf4j:
# pluginsDir: /tmp/
pluginsDir: ../plugins
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
@@ -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
@@ -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: