This commit is contained in:
zjf committed 2026-10-13 16:42:01 +08:00
1 parent 8ef2f8ccd6
commit 7a8210aeb4
12 files changed
+857 -53

No files matched your search

@@ -28,4 +28,11 @@ public interface RemoteSmHotelOrderService {
@NoToken
@PostMapping("/inner/sm-hotel-order/pay-success")
R<Boolean> paySuccess(@RequestParam("orderId") Long orderId);
/**
* 第三方退款成功后置:标记已退款并关闭预订(幂等)。
*/
@NoToken
@PostMapping("/inner/sm-hotel-order/refund-success")
R<Boolean> refundSuccess(@RequestParam("orderId") Long orderId);
}
@@ -114,6 +114,11 @@ public class SmHotelOrderEntity extends Model<SmHotelOrderEntity> {
@Schema(description = "酒店名称")
private String hotelName;
/** 详情展示:产品名称(非表字段) */
@TableField(exist = false)
@Schema(description = "产品名称")
private String productName;
/** 列表展示:下单来源 APP/ADMIN/DOWNSTREAM(非表字段,由 channelNo 推断) */
@TableField(exist = false)
@Schema(description = "下单来源")
@@ -0,0 +1,119 @@
package com.bsd.pjcloud.supermarket.vo;
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 后台酒店订单详情:订单 + 酒店基础信息 + 产品 + 入住日历 + 入住人。
*/
@Data
@Schema(description = "酒店订单详情(后台)")
public class SmHotelOrderDetailVo {
@Schema(description = "订单主体")
private SmHotelOrderEntity order;
@Schema(description = "酒店基础信息")
private HotelBrief hotel;
@Schema(description = "下单产品")
private ProductBrief product;
@Schema(description = "房型名称")
private String roomTypeName;
@Schema(description = "入住区间日历明细(含当日价库存)")
private List<SmHotelDailyEntity> dailyList;
@Schema(description = "入住人列表(解析自 guestJson)")
private Object guests;
@Schema(description = "下单请求快照(解析自 reqJson)")
private Object reqSnapshot;
@Schema(description = "上游响应快照(解析自 upstreamRespJson)")
private Object upstreamResp;
@Data
@Schema(description = "酒店摘要")
public static class HotelBrief {
private Long id;
private String hotelCode;
private String hotelName;
private String groupCode;
/** local / shangyou */
private String dataSource;
private String upstreamStatus;
private String coverImage;
private String address;
private String district;
private String bizArea;
private Integer starLevel;
private BigDecimal score;
private String phone;
private String intro;
private String facilityTags;
private BigDecimal longitude;
private BigDecimal latitude;
private Long deptId;
}
@Data
@Schema(description = "产品摘要")
public static class ProductBrief {
private Long id;
private String productCode;
private String productName;
private String roomTypeCode;
private String priceCode;
private String cancelCode;
private Integer mealType;
private Integer mealCount;
private BigDecimal costPrice;
private BigDecimal sellPrice;
private Integer stock;
private Integer canCancel;
private String cancelRule;
private Integer productType;
private Integer apartmentType;
}
}
@@ -91,6 +91,12 @@
<artifactId>pjcloud-wallet-api</artifactId>
<version>5.7.0</version>
</dependency>
<!-- 酒店订单后台退款:复用第三方支付 remoterRefund -->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-thirdparty-api</artifactId>
<version>5.7.0</version>
</dependency>
<!--必备: log 依赖-->
<dependency>
<groupId>com.bsd</groupId>
@@ -69,6 +69,12 @@ public class ShangyouHotelProperties {
/** HTTP 超时毫秒 */
private int timeoutMs = 60000;
/**
* App 待支付订单超时分钟数(按 createTime 推算,不落库)。
* 超时自动取消;本地酒店会释放创单时预占的日历库存。默认 10 分钟。
*/
private int appPayTimeoutMinutes = 10;
/** token 提前失效秒数;默认 120 → 缓存约 5 小时 58 分(21600-120),与文档建议一致 */
private int tokenSkewSeconds = 120;
@@ -25,6 +25,7 @@ import org.springdoc.core.annotations.ParameterObject;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
@@ -190,9 +191,33 @@ public class SmHotelAppController {
if ("OFFLINE".equalsIgnoreCase(r.getData().getHotel().getUpstreamStatus())) {
return R.failed("酒店已下架");
}
// App 侧:没有未来日历的产品无法预订,不展示
hideProductsWithoutCalendar(r.getData());
return r;
}
/**
* 过滤无未来日历的产品。无日历则无法计价/占库存,列表展示无意义。
*/
private void hideProductsWithoutCalendar(SmHotelDetailVO vo) {
if (vo == null || vo.getHotel() == null || vo.getHotel().getId() == null
|| vo.getProducts() == null || vo.getProducts().isEmpty()) {
return;
}
List<SmHotelDailyEntity> rows = Db.lambdaQuery(SmHotelDailyEntity.class)
.eq(SmHotelDailyEntity::getHotelId, vo.getHotel().getId())
.ge(SmHotelDailyEntity::getStayDate, LocalDate.now())
.select(SmHotelDailyEntity::getProductCode)
.list();
Set<String> withDaily = rows.stream()
.map(SmHotelDailyEntity::getProductCode)
.filter(StrUtil::isNotBlank)
.collect(Collectors.toCollection(LinkedHashSet::new));
vo.setProducts(vo.getProducts().stream()
.filter(p -> p != null && withDaily.contains(p.getProductCode()))
.collect(Collectors.toList()));
}
@Operation(summary = "酒店日历分页")
@GetMapping("/detail/{id}/daily")
public R<Page<SmHotelDailyEntity>> daily(@PathVariable Long id,
@@ -218,6 +243,8 @@ public class SmHotelAppController {
Map<String, Object> req = body == null ? new HashMap<>() : new HashMap<>(body);
// 渠道单号绑定用户 ID:APP{userId}{ts},列表/详情/取消均按此前缀认主(与用户名无关)
req.put("channelNo", "APP" + user.getId() + System.currentTimeMillis());
// 供待支付幂等复用:同用户同房同日期未付单直接返回,避免连点重复创单
req.put("appUserId", user.getId());
if (StrUtil.isBlank(str(req.get("contactName")))) {
req.put("contactName", StrUtil.blankToDefault(user.getName(), user.getUsername()));
}
@@ -287,9 +314,12 @@ public class SmHotelAppController {
row.put("sellAmount", o.getSellAmount());
row.put("orderStatus", o.getOrderStatus());
row.put("bookStatus", o.getBookStatus());
row.put("payStatus", o.getPayStatus());
row.put("contactName", o.getContactName());
row.put("contactPhone", o.getContactPhone());
row.put("createTime", o.getCreateTime());
row.put("payExpireTime", orderService.calcAppPayExpireTime(o));
row.put("remainSeconds", orderService.calcAppPayRemainSeconds(o));
return row;
}).toList());
return R.ok(out);
@@ -309,6 +339,12 @@ public class SmHotelAppController {
if (!orderService.isOwnedByAppUser(order, user)) {
return R.failed("无权查看该订单");
}
// 详情懒清理超时未付
orderService.cancelIfAppPayExpired(order);
order = orderService.getDetail(id);
if (order == null) {
return R.failed("订单不存在");
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", order.getId());
row.put("channelNo", order.getChannelNo());
@@ -323,10 +359,13 @@ public class SmHotelAppController {
row.put("sellAmount", order.getSellAmount());
row.put("orderStatus", order.getOrderStatus());
row.put("bookStatus", order.getBookStatus());
row.put("payStatus", order.getPayStatus());
row.put("contactName", order.getContactName());
row.put("contactPhone", order.getContactPhone());
row.put("createTime", order.getCreateTime());
row.put("failMsg", order.getFailMsg());
row.put("payExpireTime", orderService.calcAppPayExpireTime(order));
row.put("remainSeconds", orderService.calcAppPayRemainSeconds(order));
if (order.getHotelId() != null) {
SmHotelEntity hotel = hotelService.getById(order.getHotelId());
if (hotel != null) {
@@ -334,6 +373,7 @@ public class SmHotelAppController {
row.put("coverImage", hotel.getCoverImage());
row.put("address", hotel.getAddress());
row.put("phone", hotel.getPhone());
row.put("hotelDeptId", hotel.getDeptId());
}
if (StrUtil.isNotBlank(order.getProductCode())) {
SmHotelProductEntity p = Db.lambdaQuery(SmHotelProductEntity.class)
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.supermarket.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderDetailVo;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import io.swagger.v3.oas.annotations.Operation;
@@ -33,11 +34,15 @@ public class SmHotelOrderController {
return R.ok(orderService.pageOrders(page, query));
}
@Operation(summary = "订单详情")
@Operation(summary = "订单详情(含酒店/产品/日历)")
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_view')")
public R<SmHotelOrderEntity> detail(@PathVariable Long id) {
return R.ok(orderService.getDetail(id));
public R<SmHotelOrderDetailVo> detail(@PathVariable Long id) {
SmHotelOrderDetailVo vo = orderService.getAdminDetail(id);
if (vo == null || vo.getOrder() == null) {
return R.failed("订单不存在");
}
return R.ok(vo);
}
@SysLog("后台取消酒店订单")
@@ -49,6 +54,15 @@ public class SmHotelOrderController {
return orderService.cancelByAdmin(id, reason);
}
@SysLog("后台退款酒店订单")
@Operation(summary = "后台退款:已支付订单原路退款并关闭预订")
@PostMapping("/refund/{id}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelOrder_cancel')")
public R<Boolean> refund(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> body) {
String reason = body == null ? null : String.valueOf(body.getOrDefault("reason", "后台退款"));
return orderService.refundByAdmin(id, reason);
}
@SysLog("后台试下单(调上游)")
@Operation(summary = "后台试下单:校验可定后调上游 order/add")
@PostMapping("/create")
@@ -37,4 +37,11 @@ public class SmHotelOrderInnerController {
public R<Boolean> paySuccess(@RequestParam("orderId") Long orderId) {
return R.ok(orderService.onAppPaySuccess(orderId));
}
@Inner
@Operation(summary = "退款成功后置")
@PostMapping("/refund-success")
public R<Boolean> refundSuccess(@RequestParam("orderId") Long orderId) {
return R.ok(orderService.onAppRefundSuccess(orderId));
}
}
@@ -1,6 +1,8 @@
package com.bsd.pjcloud.supermarket.job;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelSyncService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -22,6 +24,8 @@ public class SmHotelShangyouSpringSchedule {
private final SmHotelSyncService syncService;
private final SmHotelOrderService hotelOrderService;
@Scheduled(cron = "${shangyou-hotel.hotel-list-daily-cron:0 0 2 * * ?}")
public void scheduledListSync() {
if (!properties.isSpringScheduleEnabled()) {
@@ -72,4 +76,20 @@ public class SmHotelShangyouSpringSchedule {
log.error("|酒店日更|Spring 本地滚加异常", e);
}
}
/**
* App 待支付超时清理:默认每分钟扫一次,取消过期单并释放本地预占库存。
* 不依赖 spring-schedule-enabled(支付超时与日更无关)。
* 租户与订单补偿 Worker 一致,走 callback-tenant-id。
*/
@Scheduled(fixedDelayString = "${shangyou-hotel.app-pay-expire-scan-ms:60000}")
public void scheduledCancelExpiredAppUnpaid() {
try {
Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId();
TenantBroker.runAs(tenantId, tid -> hotelOrderService.cancelExpiredAppUnpaidOrders(100));
}
catch (Exception e) {
log.error("|酒店待支付超时|定时清理异常", e);
}
}
}
@@ -4,9 +4,11 @@ import cn.hutool.json.JSONObject;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderDetailVo;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo;
import com.bsd.pjcloud.common.core.util.R;
import java.time.LocalDateTime;
import java.util.Map;
public interface SmHotelOrderService {
@@ -21,6 +23,9 @@ public interface SmHotelOrderService {
SmHotelOrderEntity getDetail(Long id);
/** PC 后台详情:订单 + 酒店基础信息 + 产品 + 入住日历 + 入住人 */
SmHotelOrderDetailVo getAdminDetail(Long id);
/** 下游回传下单 → 可定校验 → 上游创建 → 必要时挂补偿 */
R<JSONObject> createFromDownstream(String appKey, Map<String, Object> body);
@@ -31,18 +36,48 @@ public interface SmHotelOrderService {
R<JSONObject> createByAdmin(Map<String, Object> body);
/**
* App 待支付创单:只落业务订单,不扣本地库存、不调上游;支付成功后再履约。
* App 待支付创单:落业务订单;本地酒店预占日历库存,上游酒店此时不调上游。
* 支付成功后再确认本地单 / 调上游;超时未付自动取消并释放预占。
*/
R<JSONObject> createAppPendingOrder(Map<String, Object> body);
/** 供 thirdparty 拉起支付前校验 */
/** 供 thirdparty 拉起支付前校验(含支付超时自动取消) */
SmHotelOrderPayInfoVo getPayInfo(Long orderId);
/**
* App 支付成功履约。本地酒店只确认库存;上游酒店再调上游下单。
* App 支付成功履约。本地酒店确认已预占库存;上游酒店再调上游下单。
*/
Boolean onAppPaySuccess(Long orderId);
/**
* 第三方退款成功后置(幂等):关闭预订并标记已退款。
*/
Boolean onAppRefundSuccess(Long orderId);
/**
* PC 后台对已支付订单发起原路退款,并关闭预订。
*/
R<Boolean> refundByAdmin(Long id, String reason);
/**
* 扫描并取消已超时的 App 待支付订单(释放本地预占库存)。
*
* @return 本次取消条数
*/
int cancelExpiredAppUnpaidOrders(int limit);
/**
* 若仍为待支付且已超时则取消;返回是否已取消。
* 用于列表/详情懒清理。
*/
boolean cancelIfAppPayExpired(SmHotelOrderEntity order);
/** App 待支付剩余秒数;非待支付返回 null */
Long calcAppPayRemainSeconds(SmHotelOrderEntity order);
/** App 待支付截止时间;非待支付返回 null */
LocalDateTime calcAppPayExpireTime(SmHotelOrderEntity order);
/** 下游回传取消 → 调上游取消 */
R<JSONObject> cancelFromDownstream(String appKey, Map<String, Object> body);
@@ -16,11 +16,15 @@ import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouOrderCallResult;
import com.bsd.pjcloud.supermarket.mapper.*;
import com.bsd.pjcloud.supermarket.service.SmHotelOrderService;
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderDetailVo;
import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -28,6 +32,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -63,6 +68,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
/** App 本地支付状态:已支付 */
private static final String PAY_PAID = "PAID";
/** App 本地支付状态:已退款 */
private static final String PAY_REFUNDED = "REFUNDED";
private final SmHotelDownstreamAppMapper appMapper;
private final SmHotelMapper hotelMapper;
@@ -77,6 +85,10 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
private final com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties shangyouProperties;
private final RemoterThirdAppPlusPayService remoterThirdAppPlusPayService;
private final SmHotelRoomMapper roomMapper;
@Override
public Page<SmHotelOrderEntity> pageOrders(Page<SmHotelOrderEntity> page, SmHotelOrderEntity query) {
Page<SmHotelOrderEntity> result = baseMapper.selectPage(page, Wrappers.<SmHotelOrderEntity>lambdaQuery()
@@ -112,6 +124,12 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
.eq(StrUtil.isNotBlank(orderStatus), SmHotelOrderEntity::getOrderStatus, orderStatus)
.likeRight(SmHotelOrderEntity::getChannelNo, appChannelPrefix)
.orderByDesc(SmHotelOrderEntity::getCreateTime));
// 列表懒清理:超时未付自动取消并释放本地预占
if (result.getRecords() != null) {
for (SmHotelOrderEntity o : result.getRecords()) {
cancelIfAppPayExpired(o);
}
}
fillOrderListDisplay(result.getRecords());
return result;
}
@@ -186,7 +204,148 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
@Override
public SmHotelOrderEntity getDetail(Long id) {
return baseMapper.selectById(id);
SmHotelOrderEntity order = baseMapper.selectById(id);
if (order == null) {
return null;
}
fillOrderListDisplay(List.of(order));
fillProductName(order);
return order;
}
@Override
public SmHotelOrderDetailVo getAdminDetail(Long id) {
SmHotelOrderEntity order = getDetail(id);
if (order == null) {
return null;
}
SmHotelOrderDetailVo vo = new SmHotelOrderDetailVo();
vo.setOrder(order);
vo.setGuests(parseJsonLenient(order.getGuestJson()));
vo.setReqSnapshot(parseJsonLenient(order.getReqJson()));
vo.setUpstreamResp(parseJsonLenient(order.getUpstreamRespJson()));
SmHotelEntity hotel = null;
if (order.getHotelId() != null) {
hotel = hotelMapper.selectById(order.getHotelId());
}
if (hotel == null && StrUtil.isNotBlank(order.getHotelCode())) {
hotel = hotelMapper.selectOne(Wrappers.<SmHotelEntity>lambdaQuery()
.eq(SmHotelEntity::getHotelCode, order.getHotelCode())
.last("limit 1"));
}
if (hotel != null) {
SmHotelOrderDetailVo.HotelBrief hb = new SmHotelOrderDetailVo.HotelBrief();
hb.setId(hotel.getId());
hb.setHotelCode(hotel.getHotelCode());
hb.setHotelName(hotel.getHotelName());
hb.setGroupCode(hotel.getGroupCode());
hb.setDataSource(SmHotelDataSource.isLocal(hotel.getSheetName()) ? "local" : "shangyou");
hb.setUpstreamStatus(hotel.getUpstreamStatus());
hb.setCoverImage(hotel.getCoverImage());
hb.setAddress(hotel.getAddress());
hb.setDistrict(hotel.getDistrict());
hb.setBizArea(hotel.getBizArea());
hb.setStarLevel(hotel.getStarLevel());
hb.setScore(hotel.getScore());
hb.setPhone(hotel.getPhone());
hb.setIntro(hotel.getIntro());
hb.setFacilityTags(hotel.getFacilityTags());
hb.setLongitude(hotel.getLongitude());
hb.setLatitude(hotel.getLatitude());
hb.setDeptId(hotel.getDeptId());
vo.setHotel(hb);
if (StrUtil.isBlank(order.getHotelName())) {
order.setHotelName(hotel.getHotelName());
}
}
SmHotelProductEntity product = null;
Long hotelId = hotel != null ? hotel.getId() : order.getHotelId();
if (StrUtil.isNotBlank(order.getProductCode())) {
product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(hotelId != null, SmHotelProductEntity::getHotelId, hotelId)
.eq(SmHotelProductEntity::getProductCode, order.getProductCode())
.last("limit 1"));
}
if (product != null) {
SmHotelOrderDetailVo.ProductBrief pb = new SmHotelOrderDetailVo.ProductBrief();
pb.setId(product.getId());
pb.setProductCode(product.getProductCode());
pb.setProductName(product.getProductName());
pb.setRoomTypeCode(product.getRoomTypeCode());
pb.setPriceCode(product.getPriceCode());
pb.setCancelCode(product.getCancelCode());
pb.setMealType(product.getMealType());
pb.setMealCount(product.getMealCount());
pb.setCostPrice(product.getCostPrice());
pb.setSellPrice(product.getSellPrice());
pb.setStock(product.getStock());
pb.setCanCancel(product.getCanCancel());
pb.setCancelRule(product.getCancelRule());
pb.setProductType(product.getProductType());
pb.setApartmentType(product.getApartmentType());
vo.setProduct(pb);
order.setProductName(product.getProductName());
}
String roomTypeCode = StrUtil.blankToDefault(order.getRoomTypeCode(),
product != null ? product.getRoomTypeCode() : null);
if (hotelId != null && StrUtil.isNotBlank(roomTypeCode)) {
SmHotelRoomEntity room = roomMapper.selectOne(Wrappers.<SmHotelRoomEntity>lambdaQuery()
.eq(SmHotelRoomEntity::getHotelId, hotelId)
.eq(SmHotelRoomEntity::getRoomTypeCode, roomTypeCode)
.last("limit 1"));
if (room != null) {
vo.setRoomTypeName(room.getRoomTypeName());
}
}
if (hotelId != null && StrUtil.isNotBlank(order.getProductCode()) && order.getCheckInDate() != null
&& order.getCheckOutDate() != null) {
List<SmHotelDailyEntity> dailies = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getHotelId, hotelId)
.eq(SmHotelDailyEntity::getProductCode, order.getProductCode())
.ge(SmHotelDailyEntity::getStayDate, order.getCheckInDate())
.lt(SmHotelDailyEntity::getStayDate, order.getCheckOutDate())
.orderByAsc(SmHotelDailyEntity::getStayDate));
for (SmHotelDailyEntity d : dailies) {
int stock = d.getStock() == null ? 0 : d.getStock();
int sold = d.getSold() == null ? 0 : d.getSold();
d.setRemainStock(Math.max(0, stock - sold));
}
vo.setDailyList(dailies);
}
else {
vo.setDailyList(List.of());
}
return vo;
}
/** 详情补产品名称,便于后台抽屉展示。 */
private void fillProductName(SmHotelOrderEntity order) {
if (order == null || StrUtil.isBlank(order.getProductCode())) {
return;
}
SmHotelProductEntity product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(order.getHotelId() != null, SmHotelProductEntity::getHotelId, order.getHotelId())
.eq(SmHotelProductEntity::getProductCode, order.getProductCode())
.last("limit 1"));
if (product != null) {
order.setProductName(product.getProductName());
}
}
private Object parseJsonLenient(String raw) {
if (StrUtil.isBlank(raw)) {
return null;
}
try {
return JSONUtil.parse(raw);
}
catch (Exception e) {
return raw;
}
}
@Override
@@ -358,6 +517,9 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) {
return R.failed("入住/离店日期无效");
}
if (java.time.temporal.ChronoUnit.DAYS.between(checkIn, checkOut) > 30) {
return R.failed("连住最多30晚,请缩短离店日期");
}
String contactName = str(body.get("contactName"));
String contactPhone = str(body.get("contactPhone"));
if (StrUtil.hasBlank(contactName, contactPhone)) {
@@ -467,7 +629,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
/**
* App 待支付创单:校验与金额落库后返回,支付成功再履约。
* 本地酒店仅预检库存不占用;上游酒店此时不调上游。
* 本地酒店创单即预占日历 sold;上游酒店此时不调上游。
*/
@Override
@Transactional(rollbackFor = Exception.class)
@@ -521,11 +683,22 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) {
return R.failed("入住/离店日期无效");
}
if (checkIn.isBefore(LocalDate.now())) {
return R.failed("入住日期不能早于今天");
}
// App 连住上限 30 晚,避免超长区间拖垮日历校验与前端展示
long stayNights = java.time.temporal.ChronoUnit.DAYS.between(checkIn, checkOut);
if (stayNights > 30) {
return R.failed("连住最多30晚,请缩短离店日期");
}
String contactName = str(body.get("contactName"));
String contactPhone = str(body.get("contactPhone"));
if (StrUtil.hasBlank(contactName, contactPhone)) {
return R.failed("联系人姓名和手机号不能为空");
}
if (!contactPhone.matches("^1\\d{10}$")) {
return R.failed("手机号格式不正确");
}
String channelNo = str(body.get("channelNo"));
if (StrUtil.isBlank(channelNo)) {
@@ -535,18 +708,51 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
.eq(SmHotelOrderEntity::getChannelNo, channelNo)
.last("limit 1"));
if (exist != null) {
JSONObject existOut = buildOut(exist);
existOut.set("hotelName", hotel.getHotelName());
existOut.set("productName", product.getProductName());
existOut.set("sellAmount", exist.getSellAmount());
existOut.set("amountFen", toAmountFen(exist.getSellAmount()));
return R.ok(existOut, "订单已存在(幂等)");
cancelIfAppPayExpired(exist);
exist = baseMapper.selectById(exist.getId());
if (exist == null) {
return R.failed("订单不存在");
}
return R.ok(buildAppPendingOut(exist, hotel, product), "订单已存在(幂等)");
}
int count = body.get("count") == null ? 1 : Integer.parseInt(String.valueOf(body.get("count")));
if (count < 1) {
count = 1;
}
if (count > 10) {
return R.failed("单次预订间数不能超过10间");
}
// 同用户 + 酒店 + 房型 + 入离店 + 间数:复用未过期待支付单,避免支付失败连点刷出多单
Long appUserId = null;
Object appUserIdObj = body.get("appUserId");
if (appUserIdObj != null && StrUtil.isNotBlank(String.valueOf(appUserIdObj))) {
try {
appUserId = Long.valueOf(String.valueOf(appUserIdObj));
}
catch (Exception ignored) {
appUserId = null;
}
}
if (appUserId == null) {
appUserId = parseAppUserId(channelNo);
}
if (appUserId != null) {
SmHotelOrderEntity pending = findReusableAppUnpaidOrder(appUserId, hotel.getId(), productCode, checkIn,
checkOut, count);
if (pending != null) {
cancelIfAppPayExpired(pending);
pending = baseMapper.selectById(pending.getId());
if (pending != null && isAppUnpaidPending(pending)) {
// 刷新联系人信息(仍同一待支付单)
pending.setContactName(contactName);
pending.setContactPhone(contactPhone);
baseMapper.updateById(pending);
return R.ok(buildAppPendingOut(pending, hotel, product), "已复用待支付订单");
}
}
}
SmHotelOrderEntity order = new SmHotelOrderEntity();
order.setDownstreamAppId(null);
@@ -587,7 +793,13 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (precheck != null) {
return R.failed(precheck);
}
// 待支付阶段按日历卖价锁定应付金额,支付成功履约时再占用库存
// 待支付即预占日历 sold,超时/取消时回补,避免占坑不付
String deductMsg = deductLocalInventory(product.getId(), checkIn, checkOut, count);
if (deductMsg != null) {
return R.failed(deductMsg);
}
order.setInventoryStatus(SmHotelInventoryStatus.APPLIED);
order.setLastCompensateMsg("待支付预占本地库存");
BigDecimal sellSum = sumLocalSellAmount(product.getId(), checkIn, checkOut, count);
if (sellSum != null) {
order.setSellAmount(sellSum);
@@ -611,17 +823,54 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
if (order.getSellAmount() == null || order.getSellAmount().compareTo(BigDecimal.ZERO) <= 0) {
return R.failed("应付金额无效,无法发起支付");
}
// 倒计时按 createTime 推算,插入前落本地时间避免 fill 延迟导致 remainSeconds 为空
if (order.getCreateTime() == null) {
order.setCreateTime(LocalDateTime.now());
}
baseMapper.insert(order);
JSONObject out = buildOut(order);
out.set("hotelName", hotel.getHotelName());
out.set("productName", product.getProductName());
out.set("sellAmount", order.getSellAmount());
out.set("amountFen", toAmountFen(order.getSellAmount()));
JSONObject out = buildAppPendingOut(order, hotel, product);
out.set("hotelDeptId", hotel.getDeptId());
return R.ok(out, "待支付订单已创建");
}
/**
* 查找可复用的 App 待支付单(未超时):同用户、同酒店、同产品、同入离店、同间数。
*/
private SmHotelOrderEntity findReusableAppUnpaidOrder(Long appUserId, Long hotelId, String productCode,
LocalDate checkIn, LocalDate checkOut, int count) {
if (appUserId == null || hotelId == null || StrUtil.isBlank(productCode) || checkIn == null || checkOut == null) {
return null;
}
return baseMapper.selectOne(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.likeRight(SmHotelOrderEntity::getChannelNo, "APP" + appUserId)
.eq(SmHotelOrderEntity::getHotelId, hotelId)
.eq(SmHotelOrderEntity::getProductCode, productCode)
.eq(SmHotelOrderEntity::getCheckInDate, checkIn)
.eq(SmHotelOrderEntity::getCheckOutDate, checkOut)
.eq(SmHotelOrderEntity::getCount, count)
.eq(SmHotelOrderEntity::getPayStatus, PAY_UNPAID)
.eq(SmHotelOrderEntity::getOrderStatus, "CREATED")
.orderByDesc(SmHotelOrderEntity::getCreateTime)
.last("limit 1"));
}
private JSONObject buildAppPendingOut(SmHotelOrderEntity order, SmHotelEntity hotel, SmHotelProductEntity product) {
JSONObject out = buildOut(order);
if (hotel != null) {
out.set("hotelName", hotel.getHotelName());
out.set("hotelDeptId", hotel.getDeptId());
}
if (product != null) {
out.set("productName", product.getProductName());
}
out.set("sellAmount", order.getSellAmount());
out.set("amountFen", toAmountFen(order.getSellAmount()));
out.set("payExpireTime", calcAppPayExpireTime(order));
out.set("remainSeconds", calcAppPayRemainSeconds(order));
return out;
}
@Override
public SmHotelOrderPayInfoVo getPayInfo(Long orderId) {
SmHotelOrderPayInfoVo vo = new SmHotelOrderPayInfoVo();
@@ -634,6 +883,13 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
vo.setPayable(Boolean.FALSE);
return vo;
}
// 支付前再拦一层超时,避免过期单继续拉起渠道
cancelIfAppPayExpired(order);
order = baseMapper.selectById(orderId);
if (order == null) {
vo.setPayable(Boolean.FALSE);
return vo;
}
vo.setOrderId(order.getId());
vo.setUserId(parseAppUserId(order.getChannelNo()));
vo.setAmountFen(toAmountFen(order.getSellAmount()));
@@ -646,16 +902,94 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
vo.setHotelDeptId(hotel.getDeptId());
}
}
Long remain = calcAppPayRemainSeconds(order);
boolean payable = PAY_UNPAID.equals(order.getPayStatus())
&& "CREATED".equals(order.getOrderStatus())
&& vo.getUserId() != null
&& vo.getHotelDeptId() != null
&& vo.getAmountFen() != null
&& vo.getAmountFen() > 0;
&& vo.getAmountFen() > 0
&& remain != null
&& remain > 0;
vo.setPayable(payable);
return vo;
}
@Override
public int cancelExpiredAppUnpaidOrders(int limit) {
int batch = limit < 1 ? 50 : Math.min(limit, 200);
int minutes = appPayTimeoutMinutes();
LocalDateTime deadline = LocalDateTime.now().minusMinutes(minutes);
List<SmHotelOrderEntity> list = baseMapper.selectList(Wrappers.<SmHotelOrderEntity>lambdaQuery()
.eq(SmHotelOrderEntity::getPayStatus, PAY_UNPAID)
.eq(SmHotelOrderEntity::getOrderStatus, "CREATED")
.likeRight(SmHotelOrderEntity::getChannelNo, "APP")
.lt(SmHotelOrderEntity::getCreateTime, deadline)
.orderByAsc(SmHotelOrderEntity::getCreateTime)
.last("limit " + batch));
int n = 0;
for (SmHotelOrderEntity order : list) {
R<Boolean> r = cancelByAdmin(order.getId(), "支付超时自动取消");
if (r != null && r.isOk()) {
n++;
}
}
if (n > 0) {
log.info("|酒店待支付超时|自动取消|count={}|timeoutMinutes={}|", n, minutes);
}
return n;
}
@Override
public boolean cancelIfAppPayExpired(SmHotelOrderEntity order) {
if (order == null || order.getId() == null) {
return false;
}
if (!isAppUnpaidPending(order)) {
return false;
}
Long remain = calcAppPayRemainSeconds(order);
if (remain == null || remain > 0) {
return false;
}
R<Boolean> r = cancelByAdmin(order.getId(), "支付超时自动取消");
if (r != null && r.isOk()) {
order.setOrderStatus("CANCELLED");
order.setFailMsg("支付超时自动取消");
return true;
}
return false;
}
@Override
public Long calcAppPayRemainSeconds(SmHotelOrderEntity order) {
if (!isAppUnpaidPending(order) || order.getCreateTime() == null) {
return null;
}
LocalDateTime expire = order.getCreateTime().plusMinutes(appPayTimeoutMinutes());
long sec = Duration.between(LocalDateTime.now(), expire).getSeconds();
return Math.max(0L, sec);
}
@Override
public LocalDateTime calcAppPayExpireTime(SmHotelOrderEntity order) {
if (!isAppUnpaidPending(order) || order.getCreateTime() == null) {
return null;
}
return order.getCreateTime().plusMinutes(appPayTimeoutMinutes());
}
private boolean isAppUnpaidPending(SmHotelOrderEntity order) {
return order != null
&& PAY_UNPAID.equals(order.getPayStatus())
&& "CREATED".equals(order.getOrderStatus());
}
private int appPayTimeoutMinutes() {
int minutes = shangyouProperties == null ? 10 : shangyouProperties.getAppPayTimeoutMinutes();
return minutes > 0 ? minutes : 10;
}
/**
* 支付成功履约:本地酒店扣日历库存确认;上游酒店再调上游下单。幂等可重入。
*/
@@ -785,28 +1119,139 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
return "入住/离店日期无效";
}
int need = rooms < 1 ? 1 : rooms;
List<LocalDate> missing = new ArrayList<>();
List<LocalDate> closed = new ArrayList<>();
List<String> shortStock = new ArrayList<>();
for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) {
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getProductId, productId)
.eq(SmHotelDailyEntity::getStayDate, d)
.last("limit 1"));
if (row == null) {
return "本地库存不足:缺少日期 " + d + " 的日历,请先在后台维护本地房价房量";
missing.add(d);
continue;
}
if (!Integer.valueOf(1).equals(row.getDailyStatus())) {
return "本地库存不足:日期 " + d + " 已关房";
closed.add(d);
continue;
}
int stock = row.getStock() == null ? 0 : row.getStock();
int sold = row.getSold() == null ? 0 : row.getSold();
if (stock - sold < need) {
return "本地库存不足:日期 " + d + " 可售 " + Math.max(0, stock - sold) + ",需要 " + need;
shortStock.add(d + " 可售" + Math.max(0, stock - sold));
}
}
return null;
if (missing.isEmpty() && closed.isEmpty() && shortStock.isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder("所选入住区间不可订");
if (!missing.isEmpty()) {
sb.append(":缺少日历 ").append(formatDateList(missing));
}
if (!closed.isEmpty()) {
sb.append(missing.isEmpty() ? ":" : ";").append("已关房 ").append(formatDateList(closed));
}
if (!shortStock.isEmpty()) {
sb.append(missing.isEmpty() && closed.isEmpty() ? ":" : ";")
.append("库存不足 ").append(String.join("、", shortStock));
}
String hint = buildAvailableDatesHint(productId, need);
if (StrUtil.isNotBlank(hint)) {
sb.append("。").append(hint);
}
else {
sb.append("。请更换入住日期或联系商家维护房价房量");
}
return sb.toString();
}
/** 汇总近期可住日期,便于用户改期。 */
private String buildAvailableDatesHint(Long productId, int need) {
if (productId == null) {
return "";
}
int rooms = need < 1 ? 1 : need;
LocalDate from = LocalDate.now();
LocalDate to = from.plusDays(60);
List<SmHotelDailyEntity> rows = dailyMapper.selectList(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getProductId, productId)
.ge(SmHotelDailyEntity::getStayDate, from)
.lt(SmHotelDailyEntity::getStayDate, to)
.eq(SmHotelDailyEntity::getDailyStatus, 1)
.orderByAsc(SmHotelDailyEntity::getStayDate));
List<LocalDate> ok = new ArrayList<>();
for (SmHotelDailyEntity row : rows) {
if (row.getStayDate() == null) {
continue;
}
int stock = row.getStock() == null ? 0 : row.getStock();
int sold = row.getSold() == null ? 0 : row.getSold();
if (stock - sold >= rooms) {
ok.add(row.getStayDate());
}
}
if (ok.isEmpty()) {
return "该房型近60天暂无可住日期";
}
return "近期可住:" + formatDateRanges(ok);
}
private static String formatDateList(List<LocalDate> dates) {
if (dates == null || dates.isEmpty()) {
return "";
}
int limit = Math.min(dates.size(), 8);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < limit; i++) {
if (i > 0) {
sb.append("、");
}
sb.append(dates.get(i));
}
if (dates.size() > limit) {
sb.append("等").append(dates.size()).append("天");
}
return sb.toString();
}
/** 将连续日期压成区间文案,如 2026-03-01~03、2026-03-05 */
private static String formatDateRanges(List<LocalDate> dates) {
if (dates == null || dates.isEmpty()) {
return "";
}
List<String> parts = new ArrayList<>();
LocalDate start = dates.get(0);
LocalDate prev = start;
for (int i = 1; i < dates.size(); i++) {
LocalDate cur = dates.get(i);
if (prev.plusDays(1).equals(cur)) {
prev = cur;
continue;
}
parts.add(formatRangePart(start, prev));
if (parts.size() >= 6) {
parts.add("…");
return String.join("、", parts);
}
start = cur;
prev = cur;
}
parts.add(formatRangePart(start, prev));
return String.join("、", parts);
}
private static String formatRangePart(LocalDate start, LocalDate end) {
if (start.equals(end)) {
return start.toString();
}
if (start.getYear() == end.getYear() && start.getMonth() == end.getMonth()) {
return start + "~" + end.getDayOfMonth();
}
return start + "~" + end;
}
/**
* 本地酒店下单:按入住夜 CAS 增加 sold,成功则本地确认,不调上游。
* 本地酒店确认:若待支付阶段已预占库存则不再二次扣减,只落确认状态。
*/
private R<JSONObject> finishLocalOrder(SmHotelOrderEntity order, SmHotelProductEntity product) {
if (product == null || product.getId() == null) {
@@ -817,19 +1262,22 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
throw new IllegalArgumentException("入住/离店日期无效");
}
int rooms = order.getCount() == null || order.getCount() < 1 ? 1 : order.getCount();
String deductMsg = deductLocalInventory(product.getId(), order.getCheckInDate(), order.getCheckOutDate(),
rooms);
if (deductMsg != null) {
// 抛错回滚本事务(含订单 insert 与已 CAS 的 sold),避免半成功
throw new IllegalArgumentException(deductMsg);
// App 待支付创单已预占:支付成功只确认,避免 sold 双扣
if (!SmHotelInventoryStatus.isApplied(order.getInventoryStatus())) {
String deductMsg = deductLocalInventory(product.getId(), order.getCheckInDate(), order.getCheckOutDate(),
rooms);
if (deductMsg != null) {
// 抛错回滚本事务(含订单 insert 与已 CAS 的 sold),避免半成功
throw new IllegalArgumentException(deductMsg);
}
order.setInventoryStatus(SmHotelInventoryStatus.APPLIED);
}
order.setPlatformNo("LOCAL-" + order.getId());
order.setOrderStatus("UPSTREAM_OK");
order.setBookStatus(ShangyouBookStatus.R);
order.setCompensateStatus(COMP_DONE);
order.setInventoryStatus(SmHotelInventoryStatus.APPLIED);
order.setNextCompensateTime(null);
order.setLastCompensateMsg("本地日历库存已占用");
order.setLastCompensateMsg("本地日历库存已确认");
// 按日历卖价重算订单金额(有则覆盖)
BigDecimal sellSum = sumLocalSellAmount(product.getId(), order.getCheckInDate(), order.getCheckOutDate(),
rooms);
@@ -846,22 +1294,23 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
* @return null=成功;非空=失败原因(调用方应回滚事务)
*/
private String deductLocalInventory(Long productId, LocalDate checkIn, LocalDate checkOut, int rooms) {
// 与预检同一套文案,避免占库存阶段提示仍只有「缺少日期」
String pre = precheckLocalInventory(productId, checkIn, checkOut, rooms);
if (pre != null) {
return pre;
}
for (LocalDate d = checkIn; d.isBefore(checkOut); d = d.plusDays(1)) {
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getProductId, productId)
.eq(SmHotelDailyEntity::getStayDate, d)
.last("limit 1"));
if (row == null) {
return "本地库存不足:缺少日期 " + d + " 的日历";
}
if (!Integer.valueOf(1).equals(row.getDailyStatus())) {
return "本地库存不足:日期 " + d + " 已关房";
}
int updated = dailyMapper.increaseSoldIfEnough(productId, d, rooms);
if (updated == 0) {
int stock = row.getStock() == null ? 0 : row.getStock();
int sold = row.getSold() == null ? 0 : row.getSold();
return "本地库存不足:日期 " + d + " 可售 " + Math.max(0, stock - sold) + ",需要 " + rooms;
SmHotelDailyEntity row = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getProductId, productId)
.eq(SmHotelDailyEntity::getStayDate, d)
.last("limit 1"));
int stock = row == null || row.getStock() == null ? 0 : row.getStock();
int sold = row == null || row.getSold() == null ? 0 : row.getSold();
String hint = buildAvailableDatesHint(productId, rooms);
return "所选入住区间不可订:日期 " + d + " 可售 " + Math.max(0, stock - sold) + ",需要 " + rooms
+ (StrUtil.isNotBlank(hint) ? "。" + hint : "");
}
}
return null;
@@ -1339,9 +1788,12 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
baseMapper.updateById(order);
return R.ok(true, "本地订单已取消并回补库存");
}
// App 待支付订单:尚未占用库存、未调上游,直接取消
if (StrUtil.isBlank(order.getPlatformNo()) && PAY_UNPAID.equals(order.getPayStatus())
&& "CREATED".equals(order.getOrderStatus())) {
// 尚无上游/本地确认单号:未支付或支付后履约失败等,仅关本地单并回补预占
if (StrUtil.isBlank(order.getPlatformNo())) {
if (SmHotelInventoryStatus.isApplied(order.getInventoryStatus())) {
restoreLocalInventory(order);
}
applyBookStatus(order, ShangyouBookStatus.X, order.getPayStatus());
order.setOrderStatus("CANCELLED");
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
@@ -1349,10 +1801,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
order.setFailMsg(StrUtil.sub(reason, 0, 200));
}
baseMapper.updateById(order);
return R.ok(true, "未支付订单已取消");
}
if (StrUtil.isBlank(order.getPlatformNo())) {
return R.failed("尚无上游订单号");
return R.ok(true, PAY_UNPAID.equals(order.getPayStatus()) ? "未支付订单已取消" : "本地订单已关闭");
}
try {
boolean ok = upstreamClient.cancelOrder(order.getPlatformNo(), order.getHotelCode());
@@ -1374,6 +1823,94 @@ public class SmHotelOrderServiceImpl extends ServiceImpl<SmHotelOrderMapper, SmH
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> refundByAdmin(Long id, String reason) {
SmHotelOrderEntity order = baseMapper.selectById(id);
if (order == null) {
return R.failed("订单不存在");
}
if (PAY_REFUNDED.equals(order.getPayStatus())) {
return R.ok(true, "订单已退款");
}
if (!PAY_PAID.equals(order.getPayStatus())) {
return R.failed("仅已支付订单可退款");
}
if (order.getSellAmount() == null || order.getSellAmount().compareTo(BigDecimal.ZERO) <= 0) {
return R.failed("订单金额异常,无法退款");
}
int refundFen = order.getSellAmount().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP)
.intValue();
if (refundFen <= 0) {
return R.failed("退款金额无效");
}
ThirdRemoterRefundDto refundDto = new ThirdRemoterRefundDto();
refundDto.setOrderId(order.getId());
// 整单退款用业务订单 ID 作售后幂等号,避免重复发起渠道退款
refundDto.setAfterSaleId(order.getId());
refundDto.setBizType(ThirdPayOrderBizTypeEnum.HOTEL_ORDER_PAY);
refundDto.setPrice(refundFen);
R<String> refundRes;
try {
refundRes = remoterThirdAppPlusPayService.remoterRefund(refundDto);
}
catch (Exception e) {
log.error("|酒店订单退款|调用第三方退款异常|orderId={}|", id, e);
return R.failed("退款失败: " + e.getMessage());
}
if (refundRes == null || !refundRes.isOk()) {
return R.failed(refundRes == null ? "退款失败" : StrUtil.blankToDefault(refundRes.getMsg(), "退款失败"));
}
// 渠道受理后关闭预订并落已退款(回调后置也会幂等执行)
Boolean closed = onAppRefundSuccess(id, StrUtil.blankToDefault(reason, "后台退款"));
if (!Boolean.TRUE.equals(closed)) {
return R.failed("支付已退款,但关闭预订失败,请人工核对");
}
return R.ok(true, "退款已受理");
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean onAppRefundSuccess(Long orderId) {
return onAppRefundSuccess(orderId, "退款成功关闭预订");
}
/**
* 退款成功后置:关闭预订、回补库存、标记已退款(可重复调用)。
*/
private Boolean onAppRefundSuccess(Long orderId, String reason) {
SmHotelOrderEntity order = baseMapper.selectById(orderId);
if (order == null) {
return false;
}
if (PAY_REFUNDED.equals(order.getPayStatus()) && "CANCELLED".equals(order.getOrderStatus())) {
return true;
}
// 先尽量关闭预订;已取消则跳过
if (!"CANCELLED".equals(order.getOrderStatus())
&& !ShangyouBookStatus.X.equalsIgnoreCase(order.getBookStatus())) {
R<Boolean> cancelR = cancelByAdmin(orderId, reason);
if (cancelR == null || !cancelR.isOk()) {
log.warn("|酒店订单退款|关闭预订失败|orderId={}|msg={}|", orderId,
cancelR == null ? "null" : cancelR.getMsg());
return false;
}
order = baseMapper.selectById(orderId);
if (order == null) {
return false;
}
}
order.setPayStatus(PAY_REFUNDED);
order.setOrderStatus("CANCELLED");
order.setCompensateStatus(COMP_DONE);
order.setNextCompensateTime(null);
if (StrUtil.isNotBlank(reason)) {
order.setFailMsg(StrUtil.sub(reason, 0, 200));
}
baseMapper.updateById(order);
return true;
}
private JSONObject buildOut(SmHotelOrderEntity order) {
JSONObject out = new JSONObject();
out.set("channelNo", order.getChannelNo());
@@ -1752,6 +1752,14 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
log.error("|APP支付-购买商品(有订单)|刷新退款聚合状态异常", e);
return false;
}
} else if (ObjectUtil.equals(thirdPayOrder.getBizType(), ThirdPayOrderBizTypeEnum.HOTEL_ORDER_PAY.getType())) {
try {
R<Boolean> result = remoteSmHotelOrderService.refundSuccess(Long.valueOf(thirdPayOrder.getBizId()));
return result != null && result.isOk() && Boolean.TRUE.equals(result.getData());
} catch (Exception e) {
log.error("|酒店订单支付|处理退款成功回调异常", e);
return false;
}
} else {
return true;
}