小如酒店

This commit is contained in:
zjf committed 2026-11-04 13:23:52 +08:00
1 parent 0a320a2c5a
commit 87a2b76250
15 files changed
+759 -162

No files matched your search

@@ -85,5 +85,10 @@ public interface ErrorCodeConstants {
/** 供应方业务拒绝(可订校验/下单等),msg 透传给 C 端 */
ErrorCode HOTEL_ZAX_SUPPLIER_BIZ_FAIL = new ErrorCode(1_008_020_006, "{}");
ErrorCode HOTEL_ZAX_OFFLINE = new ErrorCode(1_008_020_007, "酒店已下架");
/** 用户超半小时取消:需人工审核,不可重复提交 */
ErrorCode HOTEL_ZAX_ORDER_CANCEL_APPLY_EXISTS = new ErrorCode(1_008_020_008, "已提交取消申请,请等待人工审核");
ErrorCode HOTEL_ZAX_ORDER_CANCEL_NOT_ALLOWED = new ErrorCode(1_008_020_009, "当前订单状态不可取消");
/** 异常退款:未支付或已退过 */
ErrorCode HOTEL_ZAX_ORDER_ABNORMAL_REFUND_FAIL = new ErrorCode(1_008_020_010, "异常退款失败:{}");
}
@@ -209,6 +209,16 @@ public class HotelZaxController {
return success(true);
}
@PostMapping("/order/abnormal-refund")
@Operation(summary = "异常退款(已支付且未退时强制原路退款,不依赖可取消状态)")
@Parameter(name = "id", required = true)
@PreAuthorize("@ss.hasPermission('product:hotel-zax-order:query')")
public CommonResult<Boolean> abnormalRefund(@RequestParam("id") Long id,
@RequestParam(value = "reason", required = false) String reason) {
hotelZaxAdminService.abnormalRefund(id, reason);
return success(true);
}
@PostMapping("/sync")
@Operation(summary = "从资安心开放平台同步到本地(默认全量,适配授权范围变更)")
@PreAuthorize("@ss.hasPermission('product:hotel-zax:sync')")
@@ -59,5 +59,8 @@ public class HotelZaxOrderRespVO {
private String failMsg;
@Schema(description = "本地是否已发起退款")
private Boolean localRefunded;
private LocalDateTime createTime;
}
@@ -53,7 +53,13 @@ public class AppHotelController {
if (hotelZaxLocalService.hasLocalData()) {
return success(hotelZaxLocalService.pageList(body));
}
return success(dataOf(zaxHotelOpenClient.post("hotel/list", body)));
try {
return success(dataOf(zaxHotelOpenClient.post("hotel/list", body)));
}
catch (Exception e) {
// 供应方未授权等:C 端只展示空列表,不抛配置文案
return success(emptyHotelPage(body));
}
}
@PermitAll
@@ -63,8 +69,42 @@ public class AppHotelController {
if (hotelZaxLocalService.hasLocalData()) {
return success(hotelZaxLocalService.filterMeta());
}
return success(dataOf(zaxHotelOpenClient.post("hotel/filter-meta",
body == null ? new HashMap<>() : body)));
try {
return success(dataOf(zaxHotelOpenClient.post("hotel/filter-meta",
body == null ? new HashMap<>() : body)));
}
catch (Exception e) {
Map<String, Object> empty = new HashMap<>(4);
empty.put("areas", new java.util.ArrayList<>());
empty.put("districts", new java.util.ArrayList<>());
empty.put("bizAreas", new java.util.ArrayList<>());
empty.put("starLevels", new java.util.ArrayList<>());
return success(empty);
}
}
private static Map<String, Object> emptyHotelPage(Map<String, Object> body) {
long current = 1L;
long size = 10L;
if (body != null) {
try {
if (body.get("current") != null) {
current = Long.parseLong(String.valueOf(body.get("current")));
}
if (body.get("size") != null) {
size = Long.parseLong(String.valueOf(body.get("size")));
}
}
catch (Exception ignored) {
// ignore
}
}
Map<String, Object> data = new HashMap<>(8);
data.put("records", new java.util.ArrayList<>());
data.put("total", 0);
data.put("current", current);
data.put("size", size);
return data;
}
@PermitAll
@@ -119,7 +159,7 @@ public class AppHotelController {
}
@PostMapping("/order/cancel")
@Operation(summary = "取消订单(已支付则退款,并通知资安心)")
@Operation(summary = "取消订单:支付后半小时内直接取消退款;超时则提交取消申请,待后台人工二次确认")
public CommonResult<Object> orderCancel(@RequestBody Map<String, Object> body) {
String channelNo = body == null ? null : str(body.get("channelNo"));
hotelZaxTradeService.cancelByChannelNo(getLoginUserId(), channelNo, "用户取消");
@@ -160,31 +200,7 @@ public class AppHotelController {
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) {
// 本地单仍可展示
}
}
// getByChannelNo / getByPayOrderId 内已按需拉取供应方并落库,这里直接返回最新本地视图
return success(local);
}
return success(dataOf(zaxHotelOpenClient.post("order/query", body == null ? new HashMap<>() : body)));
@@ -63,20 +63,27 @@ public class HotelZaxOpenCallbackController {
@PermitAll
@PostMapping("/hotel/resource/notify")
@Operation(summary = "资源变更回调(验签后异步拉 detail)")
@Operation(summary = "资源变更回调(验签后异步拉 detail;DAILY_FULL 则全量拉取)")
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)) {
boolean dailyFull = "DAILY_FULL".equalsIgnoreCase(changeType) || "FULL_SYNC".equalsIgnoreCase(changeType)
|| "*".equals(hotelCode) || "_ALL_".equalsIgnoreCase(hotelCode);
if (!dailyFull && StrUtil.isBlank(hotelCode)) {
throw new IllegalArgumentException("hotelCode 不能为空");
}
Long tenantId = openProperties.getCallbackTenantId() == null ? 1L : openProperties.getCallbackTenantId();
// 先快速 ACK,再异步同步,避免供应方回调超时
ASYNC.execute(() -> TenantUtils.execute(tenantId, () -> {
try {
if (dailyFull) {
Map<String, Object> r = hotelZaxSyncService.syncOnce(true);
log.info("|hotel-zax-callback|日更全量拉取完成|{}", r);
return;
}
if ("HOTEL_DELETE".equalsIgnoreCase(changeType) || "HOTEL_OFFLINE".equalsIgnoreCase(changeType)
|| "OFFLINE".equalsIgnoreCase(changeType) || "DELETE".equalsIgnoreCase(changeType)) {
hotelZaxSyncService.markOffline(hotelCode);
@@ -90,8 +97,8 @@ public class HotelZaxOpenCallbackController {
}
}));
Map<String, Object> ack = new LinkedHashMap<>(4);
ack.put("hotelCode", hotelCode);
ack.put("changeType", changeType);
ack.put("hotelCode", dailyFull ? "*" : hotelCode);
ack.put("changeType", dailyFull ? "DAILY_FULL" : changeType);
ack.put("accepted", true);
return success(ack);
}
@@ -21,12 +21,18 @@ import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.HOTEL_ZAX
/**
* 调用资安心酒店下游开放 API(服务端代签)。
* 网络/5xx 即时重试;业务拒绝(code!=0)不重试。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ZaxHotelOpenClient {
private static final int INLINE_ATTEMPTS = 3;
/** 第 1/2/3 次发起前等待:0 → 5s → 15s */
private static final long[] INLINE_BACKOFF_MS = { 0L, 5_000L, 15_000L };
private final ZaxHotelOpenProperties properties;
public JSONObject post(String apiPath, Map<String, Object> body) {
@@ -37,38 +43,74 @@ public class ZaxHotelOpenClient {
throw exception(HOTEL_ZAX_SUPPLIER_BIZ_FAIL, "酒店预订服务配置不完整");
}
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));
// 业务可读异常,避免被 GlobalExceptionHandler 吞成「系统异常」
throw exception(HOTEL_ZAX_SUPPLIER_BIZ_FAIL, "酒店供应方接口异常,请稍后重试");
ServiceException lastBiz = null;
Exception lastIo = null;
for (int i = 0; i < INLINE_ATTEMPTS; i++) {
if (i > 0) {
long sleep = INLINE_BACKOFF_MS[Math.min(i, INLINE_BACKOFF_MS.length - 1)];
try {
Thread.sleep(sleep);
}
catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw exception(HOTEL_ZAX_SUPPLIER_BIZ_FAIL, "酒店供应方接口异常,请稍后重试");
}
log.info("|zax-hotel|即时重试|attempt={}|url={}|", i + 1, url);
}
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 exception(HOTEL_ZAX_SUPPLIER_BIZ_FAIL, msg);
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = IdUtil.fastSimpleUUID();
String sign = sign(properties.getAppKey(), properties.getAppSecret(), timestamp, nonce, bodyJson);
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={}|attempt={}|body={}|", url, resp.getStatus(),
i + 1, StrUtil.maxLength(raw, 300));
// 4xx 业务/鉴权问题不重试;5xx/超时类继续重试
if (resp.getStatus() >= 400 && resp.getStatus() < 500 && resp.getStatus() != 408
&& resp.getStatus() != 429) {
throw exception(HOTEL_ZAX_SUPPLIER_BIZ_FAIL, "酒店供应方接口异常,请稍后重试");
}
lastIo = new IllegalStateException("HTTP " + resp.getStatus());
continue;
}
JSONObject json = JSONUtil.parseObj(raw);
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 exception(HOTEL_ZAX_SUPPLIER_BIZ_FAIL, msg);
}
if (i > 0) {
log.info("|zax-hotel|即时重试成功|attempt={}|url={}|", i + 1, url);
}
return json;
}
catch (ServiceException e) {
lastBiz = e;
// 业务异常直接抛
throw e;
}
catch (Exception e) {
lastIo = e;
log.warn("|zax-hotel|调用异常|url={}|attempt={}|{}", url, i + 1, e.getMessage());
}
return json;
}
if (lastBiz != null) {
throw lastBiz;
}
log.warn("|zax-hotel|即时重试耗尽|url={}|last={}|", url, lastIo == null ? null : lastIo.getMessage());
throw exception(HOTEL_ZAX_SUPPLIER_BIZ_FAIL, "酒店供应方接口异常,请稍后重试");
}
/** 是否供应方业务拒绝(可订校验失败等),便于调用方决定是否刷新本地日历 */
@@ -48,4 +48,14 @@ public class ZaxHotelOpenProperties {
* 同步详情时顺带拉取日历的天数(含今天)。0=不同步日历;最大 90(开放平台窗口上限)。
*/
private int dailySyncDays = 90;
/**
* 是否启用每日定时从资安心全量拉取(兜底:对方日更推送丢失时仍能对齐)。
*/
private boolean dailyPullEnabled = true;
/**
* 每日全量拉取 Cron,默认 03:20(建议晚于资安心开放应用日更推送 02:10)。
*/
private String dailyPullCron = "0 20 3 * * ?";
}
@@ -15,6 +15,8 @@ import java.util.Map;
/**
* 每天从资安心拉取酒店主数据、房型、产品与价态日历到本地。
* <p>
* 与资安心「开放应用日更推送 DAILY_FULL」互补:推送触发即时全量;本任务作兜底防推送丢失。
*/
@Slf4j
@Component
@@ -34,10 +36,13 @@ public class HotelZaxDailySyncJob implements JobHandler {
return formatResult(full, r);
}
/** 每天 03:20 全量同步(适配授权范围变更;增量水位拉不到新授权酒店) */
@Scheduled(cron = "0 20 3 * * ?")
/**
* 每天定时全量同步(适配授权范围变更;增量水位拉不到新授权酒店)。
* Cron 可通过 xrhome.hotel.zax.daily-pull-cron 配置。
*/
@Scheduled(cron = "${xrhome.hotel.zax.daily-pull-cron:0 20 3 * * ?}")
public void scheduleDaily() {
if (!openProperties.isEnabled()) {
if (openProperties == null || !openProperties.isEnabled() || !openProperties.isDailyPullEnabled()) {
return;
}
Long tenantId = openProperties.getCallbackTenantId() == null ? 1L : openProperties.getCallbackTenantId();
@@ -0,0 +1,53 @@
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.framework.hotel.ZaxHotelOpenProperties;
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;
/**
* 酒店订单状态补偿:主动拉资安心,覆盖对方维护导致回调丢失;关单未退则补退款。
*/
@Slf4j
@Component
public class HotelZaxOrderStatusSyncJob implements JobHandler {
@Resource
private HotelZaxTradeService hotelZaxTradeService;
@Resource
private ZaxHotelOpenProperties openProperties;
@Override
@TenantJob
public String execute(String param) {
int n = hotelZaxTradeService.syncPendingOrders(50);
return String.format("酒店订单状态同步 %s 单", n);
}
/** 默认每 2 分钟扫一轮 */
@Scheduled(cron = "40 */2 * * * ?")
public void schedule() {
if (openProperties == null || !openProperties.isEnabled()) {
return;
}
Long tenantId = openProperties.getCallbackTenantId() == null ? 1L : openProperties.getCallbackTenantId();
TenantUtils.execute(tenantId, () -> {
try {
int n = hotelZaxTradeService.syncPendingOrders(40);
if (n > 0) {
log.info("|hotel-status|定时同步|count={}|", n);
}
}
catch (Exception e) {
log.warn("|hotel-status|定时同步失败|{}", e.getMessage());
}
});
}
}
@@ -94,43 +94,8 @@ public class DeviceServiceImpl implements DeviceService {
// 传入部门
device.setDeptId(getLoginUserDeptId());
deviceMapper.insert(device);
DispenseRequestBo request = new DispenseRequestBo();
request.setDeviceId(createReqVO.getDeviceId());
request.setMessage(createReqVO.getProductUrl() + "," + createReqVO.getFileUrl());
// 设置请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", token);
// 创建请求实体
HttpEntity<DispenseRequestBo> requestEntity = new HttpEntity<>(request, headers);
// 创建RestTemplate
RestTemplate restTemplate = new RestTemplate();
try {
// 发送请求并解析响应
ResponseEntity<DispenseResponseBo> response = restTemplate.exchange(
api_url,
HttpMethod.POST,
requestEntity,
DispenseResponseBo.class
);
DispenseResponseBo body = response.getBody();
if (body != null) {
System.out.println("响应结果: " + body);
if (body.getCode() == 0) {
System.out.println("上传成功!详情: " + body.getData());
} else {
System.out.println("操作失败!原因: " + body.getMsg());
log.error("设备--{}:[图片上传失败]:原因: {}", createReqVO.getDeviceId(), body.getMsg());
}
}
} catch (Exception e) {
System.err.println("图片上传请求异常: " + e.getMessage());
log.error("设备--{}:[图片上传异常]:原因: {}", createReqVO.getDeviceId(), e.getMessage());
}
// 推送设备屏广告:二维码 + 广告图/视频地址
pushDeviceScreenAd(createReqVO.getDeviceId(), createReqVO.getProductUrl(), createReqVO.getFileUrl(), "create");
// 返回
return device.getId();
}
@@ -143,44 +108,67 @@ public class DeviceServiceImpl implements DeviceService {
checkDeviceDataPermission(device.getId());
// 更新
DeviceDO updateObj = BeanUtils.toBean(updateReqVO, DeviceDO.class);
// 推送设备屏广告:二维码 + 广告图/视频地址
pushDeviceScreenAd(updateReqVO.getDeviceId(), updateReqVO.getProductDeviceUrl(), updateReqVO.getFileUrl(), "update");
deviceMapper.updateById(updateObj);
}
/**
* 调用第三方接口,把设备屏广告(二维码 + 文件地址)推到售货机
*
* @param deviceId 设备 IMEI
* @param qrUrl 商品二维码地址
* @param fileUrl 广告图/视频地址
* @param scene create / update
*/
private void pushDeviceScreenAd(String deviceId, String qrUrl, String fileUrl, String scene) {
DispenseRequestBo request = new DispenseRequestBo();
request.setDeviceId(updateReqVO.getDeviceId());
request.setMessage(updateReqVO.getProductDeviceUrl() + "," + updateReqVO.getFileUrl());
// 设置请求头
request.setDeviceId(deviceId);
request.setMessage((qrUrl == null ? "" : qrUrl) + "," + (fileUrl == null ? "" : fileUrl));
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", token);
// 创建请求实体
HttpEntity<DispenseRequestBo> requestEntity = new HttpEntity<>(request, headers);
// 创建RestTemplate
RestTemplate restTemplate = new RestTemplate();
log.info("[设备屏广告推送-请求] scene={}, url={}, deviceId={}, qrUrl={}, fileUrl={}, message={}, Authorization={}",
scene, api_url, deviceId, qrUrl, fileUrl, request.getMessage(), maskToken(token));
try {
// 发送请求并解析响应
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<DispenseResponseBo> response = restTemplate.exchange(
api_url,
HttpMethod.POST,
requestEntity,
DispenseResponseBo.class
);
DispenseResponseBo body = response.getBody();
if (body != null) {
System.out.println("响应结果: " + body);
if (body.getCode() == 0) {
System.out.println("上传成功!详情: " + body.getData());
} else {
System.out.println("操作失败!原因: " + body.getMsg());
log.error("设备--{}:[图片上传失败]:原因: {}", updateReqVO.getDeviceId(), body.getMsg());
}
log.info("[设备屏广告推送-响应] scene={}, deviceId={}, httpStatus={}, body={}",
scene, deviceId, response.getStatusCodeValue(), body);
if (body == null) {
log.warn("[设备屏广告推送-响应为空] scene={}, deviceId={}", scene, deviceId);
return;
}
if (body.getCode() == 0) {
log.info("[设备屏广告推送-成功] scene={}, deviceId={}, data={}", scene, deviceId, body.getData());
} else {
log.error("[设备屏广告推送-失败] scene={}, deviceId={}, code={}, msg={}, data={}",
scene, deviceId, body.getCode(), body.getMsg(), body.getData());
}
} catch (Exception e) {
System.err.println("图片上传请求异常: " + e.getMessage());
log.error("设备--{}:[图片上传异常]:原因: {}", updateReqVO.getDeviceId(), e.getMessage());
log.error("[设备屏广告推送-异常] scene={}, deviceId={}, url={}, message={}, err={}",
scene, deviceId, api_url, request.getMessage(), e.getMessage(), e);
}
deviceMapper.updateById(updateObj);
}
private String maskToken(String raw) {
if (raw == null || raw.isEmpty()) {
return "";
}
if (raw.length() <= 8) {
return "****";
}
return raw.substring(0, 4) + "****" + raw.substring(raw.length() - 4);
}
@Override
@@ -53,4 +53,7 @@ public interface HotelZaxAdminService {
/** 后台取消:已支付原路退款,并通知资安心取消预订 */
void cancelOrder(Long id, String reason);
/** 异常退款:已支付且未退时强制原路退款,不依赖可取消状态 */
void abnormalRefund(Long id, String reason);
}
@@ -598,8 +598,8 @@ public class HotelZaxAdminServiceImpl implements HotelZaxAdminService {
}
/**
* 后台上下架:下架写成 LOCAL_OFFLINE,避免与供应方 OFFLINE、授权外 OUT_OF_SCOPE 混淆;
* 同步不会把 LOCAL_OFFLINE 冲回 ACTIVE。
* 后台上下架:下架写成 LOCAL_OFFLINE,避免与供应方 OFFLINE、授权外 OUT_OF_SCOPE 混淆。
* 下次「同步资安心」若供应方仍为上架,会按供应方状态恢复为 ACTIVE。
*/
private static String normalizeStatus(String status) {
String s = StrUtil.trim(status);
@@ -622,7 +622,30 @@ public class HotelZaxAdminServiceImpl implements HotelZaxAdminService {
.eqIfPresent(HotelZaxOrderDO::getContactPhone, req.getContactPhone())
.eqIfPresent(HotelZaxOrderDO::getUserId, req.getUserId())
.orderByDesc(HotelZaxOrderDO::getId));
return BeanUtils.toBean(page, HotelZaxOrderRespVO.class);
// 当前页待确认单:主动拉供应方,避免人工确认后列表仍显示旧状态
if (page.getList() != null) {
for (HotelZaxOrderDO row : page.getList()) {
if (row == null || StrUtil.isBlank(row.getChannelNo())) {
continue;
}
String st = StrUtil.trim(row.getOrderStatus());
if ("PENDING_CONFIRM".equalsIgnoreCase(st) || "NEED_MANUAL".equalsIgnoreCase(st)
|| "PAID".equalsIgnoreCase(st) || "PENDING".equalsIgnoreCase(st)) {
hotelZaxTradeService.syncStatusByChannelNo(row.getChannelNo());
}
}
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 toOrderPage(page);
}
@Override
@@ -634,7 +657,11 @@ public class HotelZaxAdminServiceImpl implements HotelZaxAdminService {
if (order == null) {
throw exception(HOTEL_ZAX_ORDER_NOT_EXISTS);
}
return BeanUtils.toBean(order, HotelZaxOrderRespVO.class);
if (StrUtil.isNotBlank(order.getChannelNo())) {
hotelZaxTradeService.syncStatusByChannelNo(order.getChannelNo());
order = orderMapper.selectById(id);
}
return toOrderVo(order);
}
@Override
@@ -642,6 +669,40 @@ public class HotelZaxAdminServiceImpl implements HotelZaxAdminService {
hotelZaxTradeService.cancelOrder(id, StrUtil.blankToDefault(reason, "后台取消"), true);
}
@Override
public void abnormalRefund(Long id, String reason) {
hotelZaxTradeService.abnormalRefund(id, StrUtil.blankToDefault(reason, "异常退款"));
}
private PageResult<HotelZaxOrderRespVO> toOrderPage(PageResult<HotelZaxOrderDO> page) {
if (page == null) {
return PageResult.empty();
}
List<HotelZaxOrderRespVO> list = page.getList() == null ? Collections.emptyList()
: page.getList().stream().map(this::toOrderVo).collect(Collectors.toList());
return new PageResult<>(list, page.getTotal());
}
private HotelZaxOrderRespVO toOrderVo(HotelZaxOrderDO order) {
HotelZaxOrderRespVO vo = BeanUtils.toBean(order, HotelZaxOrderRespVO.class);
if (vo != null) {
vo.setLocalRefunded(isLocalRefunded(order));
}
return vo;
}
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 String normalizeCsv(String v) {
if (StrUtil.isBlank(v)) {
return null;
@@ -85,12 +85,16 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService {
int offlineOutOfScope = 0;
String nextSince = updatedSince;
boolean hasMore = true;
boolean pullOk = false;
// 必须整页拉完(hasMore=false)才允许按授权范围清库,避免中途失败/触顶把大量酒店误标未授权
boolean listComplete = false;
Set<String> syncedCodes = new HashSet<>();
while (hasMore && pageNo <= 40) {
// 单页 50,上限覆盖约 2.5 万家;触顶仍不 listComplete
final int maxPages = 500;
while (hasMore && pageNo <= maxPages) {
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);
@@ -98,12 +102,13 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService {
JSONObject resp = openClient.post("hotel/sync", req);
JSONObject data = resp == null ? null : resp.getJSONObject("data");
if (data == null) {
log.warn("|hotel-zax-sync|列表页无data|page={}|中止,跳过授权外清理", pageNo);
break;
}
pullOk = true;
JSONArray records = data.getJSONArray("records");
if (records == null || records.isEmpty()) {
hasMore = false;
listComplete = true;
break;
}
for (int i = 0; i < records.size(); i++) {
@@ -143,11 +148,17 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService {
nextSince = ns;
}
hasMore = Boolean.TRUE.equals(data.getBool("hasMore"));
if (!hasMore) {
listComplete = true;
}
pageNo++;
}
// 全量且拉取成功后:本地有、当前授权已不含的酒店标记下架(避免改范围后仍显示旧数据)
// pullOk=false 时不清理,防止接口异常把本地全下架
if (full && pullOk) {
if (hasMore && pageNo > maxPages) {
log.warn("|hotel-zax-sync|列表分页触顶|pages={}|synced={}|跳过授权外清理,请加大分页或排查",
maxPages, syncedCodes.size());
}
// 仅全量且列表完整拉完后,才把「本次授权结果集没有」的本地酒店标为 OUT_OF_SCOPE
if (full && listComplete) {
offlineOutOfScope = markOutOfScopeOffline(syncedCodes);
if (StrUtil.isBlank(nextSince)) {
nextSince = LocalDateTime.now().format(DT);
@@ -157,7 +168,7 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService {
cursor.setLastSyncAt(LocalDateTime.now());
cursor.setLastMsg((full ? "full," : "incr,") + "upsert=" + upsert + ",detail=" + detailOk
+ ",dailyOk=" + dailyOk + ",dailyRows=" + dailyRows + ",dailyFail=" + dailyFail
+ ",offlineOut=" + offlineOutOfScope);
+ ",offlineOut=" + offlineOutOfScope + ",listComplete=" + listComplete);
cursorMapper.updateById(cursor);
Map<String, Object> out = new LinkedHashMap<>();
@@ -271,7 +282,7 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService {
if (minSell != null) {
entity.setMinSellPrice(minSell);
}
entity.setStatus(resolveSyncStatus(exist == null ? null : exist.getStatus(), row.getStr("status")));
entity.setStatus(resolveSyncStatus(row.getStr("status")));
entity.setSupplierUpdateTime(parseDt(row.getStr("updateTime")));
entity.setSyncedAt(LocalDateTime.now());
if (exist == null) {
@@ -283,29 +294,16 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService {
}
/**
* 同步状态合并:
* 同步状态(以供应方为准):
* - 供应方 OFFLINE → OFFLINE
* - 本地 LOCAL_OFFLINE(后台手动下架)不被 ACTIVE 冲掉
* - OUT_OF_SCOPE / 历史误标的 OFFLINE:供应方 ACTIVE 时可恢复上架(重新授权场景)
* - 其余(ACTIVE/空/未知)→ ACTIVE,覆盖本地 LOCAL_OFFLINE / OUT_OF_SCOPE / 历史 OFFLINE
*/
private static String resolveSyncStatus(String localStatus, String incomingRaw) {
String local = StrUtil.trim(localStatus);
private static String resolveSyncStatus(String incomingRaw) {
String incoming = StrUtil.trim(incomingRaw);
if (isOfflineStatus(incoming)) {
return "OFFLINE";
}
if (isLocalOffline(local)) {
return "LOCAL_OFFLINE";
}
boolean incomingActive = StrUtil.isBlank(incoming) || "ACTIVE".equalsIgnoreCase(incoming);
if (incomingActive) {
// 重新进入授权范围:OUT_OF_SCOPE 恢复;历史撤权曾写成 OFFLINE 的也可恢复
if (isOutOfScope(local) || isOfflineStatus(local)) {
return "ACTIVE";
}
return StrUtil.blankToDefault(local, "ACTIVE");
}
return StrUtil.blankToDefault(local, "ACTIVE");
return "ACTIVE";
}
private static boolean isOfflineStatus(String status) {
@@ -359,8 +357,8 @@ public class HotelZaxSyncServiceImpl implements HotelZaxSyncService {
if (StrUtil.isNotBlank(data.getStr("hotelName"))) {
exist.setHotelName(data.getStr("hotelName"));
}
// 详情同步同样遵守:仅供应方 OFFLINE 可改状态,不把本地下架冲成上架
exist.setStatus(resolveSyncStatus(exist.getStatus(), data.getStr("status")));
// 详情同步同样以供应方货架为准
exist.setStatus(resolveSyncStatus(data.getStr("status")));
exist.setSyncedAt(LocalDateTime.now());
hotelZaxMapper.updateById(exist);
@@ -49,6 +49,12 @@ public interface HotelZaxTradeService {
*/
void cancelOrder(Long orderId, String reason, boolean notifySupplier);
/**
* 异常退款:已支付且本地未退款时强制原路退款,不依赖可取消状态,不通知供应方。
* 用于状态卡死、取消入口消失、或已取消但钱未退回等运维补救。
*/
void abnormalRefund(Long orderId, String reason);
/** 用户按渠道单号取消(校验归属) */
void cancelByChannelNo(Long userId, String channelNo, String reason);
@@ -56,4 +62,17 @@ public interface HotelZaxTradeService {
* 资安心订单状态回调:更新本地状态;若已取消且已支付则退款给用户。
*/
Map<String, Object> applySupplierStatusNotify(Map<String, Object> body);
/**
* 主动向供应方查询并落库(待确认等中间态;回调丢失时的补偿)。
*/
void syncStatusByChannelNo(String channelNo);
/**
* 定时补偿:扫描中间态/已取消未退订单,主动拉供应方状态并补退款。
* 对方维护导致回调丢失时的兜底。
*
* @return 本轮处理条数
*/
int syncPendingOrders(int limit);
}
@@ -23,6 +23,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
@@ -44,7 +46,10 @@ import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.add
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_ABNORMAL_REFUND_FAIL;
import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.HOTEL_ZAX_ORDER_CANCEL_APPLY_EXISTS;
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_CANCEL_NOT_ALLOWED;
import static cn.iocoder.yudao.module.product.enums.ErrorCodeConstants.HOTEL_ZAX_ORDER_NOT_EXISTS;
@Slf4j
@@ -57,6 +62,12 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
private static final Duration PAY_EXPIRE = Duration.ofMinutes(10);
/** 用户自助取消窗口:支付(或下单)后半小时内可直接取消;超时需人工二次确认 */
private static final Duration USER_CANCEL_FREE_WINDOW = Duration.ofMinutes(30);
/** 用户超时取消申请中(待后台确认) */
private static final String STATUS_CANCEL_APPLY = "CANCEL_APPLY";
/** 支付商户单号前缀,避免与 trade_order.id 撞号 */
private static final String PAY_MERCHANT_PREFIX = "HZ";
@@ -275,6 +286,7 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
if (order != null && order.getId() != null) {
order = orderMapper.selectById(order.getId());
}
order = refreshStatusFromSupplier(order);
return toView(order);
}
@@ -287,6 +299,7 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
.eq(HotelZaxOrderDO::getChannelNo, channelNo.trim())
.eqIfPresent(HotelZaxOrderDO::getUserId, userId)
.last("limit 1"));
order = refreshStatusFromSupplier(order);
return toView(order);
}
@@ -310,6 +323,7 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
for (HotelZaxOrderDO row : page.getList()) {
syncPaidFromPayOrder(row);
HotelZaxOrderDO latest = row.getId() == null ? row : orderMapper.selectById(row.getId());
latest = refreshStatusFromSupplier(latest == null ? row : latest);
list.add(toView(latest == null ? row : latest));
}
}
@@ -356,6 +370,38 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
doCancel(order, StrUtil.blankToDefault(reason, "后台取消"), notifySupplier);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void abnormalRefund(Long orderId, String reason) {
if (orderId == null) {
throw invalidParamException("订单 id 不能为空");
}
HotelZaxOrderDO order = orderMapper.selectById(orderId);
if (order == null) {
throw exception(HOTEL_ZAX_ORDER_NOT_EXISTS);
}
if (!Boolean.TRUE.equals(order.getPayStatus())) {
throw exception(HOTEL_ZAX_ORDER_ABNORMAL_REFUND_FAIL, "订单未支付,无需退款");
}
if (order.getPayOrderId() == null) {
throw exception(HOTEL_ZAX_ORDER_ABNORMAL_REFUND_FAIL, "缺少支付单,无法原路退款");
}
if (isLocalRefunded(order)) {
throw exception(HOTEL_ZAX_ORDER_ABNORMAL_REFUND_FAIL, "本地已标记退款,请勿重复操作");
}
String msg = StrUtil.blankToDefault(reason, "异常退款");
refundIfPaid(order, msg);
// 关本地单;不通知供应方,避免状态卡死时被对方拦截
String st = StrUtil.trim(order.getOrderStatus());
if (!"CANCELLED".equalsIgnoreCase(st) && !"CANCELED".equalsIgnoreCase(st)
&& !"COMPLETED".equalsIgnoreCase(st)) {
order.setOrderStatus("CANCELLED");
}
order.setFailMsg(StrUtil.maxLength(msg, 200));
orderMapper.updateById(order);
log.info("|hotel-refund|异常退款完成|orderId={}|channelNo={}|", order.getId(), order.getChannelNo());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void cancelByChannelNo(Long userId, String channelNo, String reason) {
@@ -369,7 +415,85 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
if (order == null) {
throw exception(HOTEL_ZAX_ORDER_NOT_EXISTS);
}
doCancel(order, StrUtil.blankToDefault(reason, "用户取消"), true);
if (isTerminalUnbookable(order.getOrderStatus())) {
throw exception(HOTEL_ZAX_ORDER_CANCEL_NOT_ALLOWED);
}
if (STATUS_CANCEL_APPLY.equalsIgnoreCase(StrUtil.trim(order.getOrderStatus()))) {
throw exception(HOTEL_ZAX_ORDER_CANCEL_APPLY_EXISTS);
}
// 未支付:只关本地,不调供应方
if (!Boolean.TRUE.equals(order.getPayStatus())) {
doCancel(order, StrUtil.blankToDefault(reason, "用户取消"), false);
return;
}
// 半小时内:直接取消并通知供应方即时关单
if (withinUserCancelFreeWindow(order)) {
doCancel(order, StrUtil.blankToDefault(reason, "用户取消"), true);
return;
}
// 超半小时:必须先调供应方挂起「待取消确认」,再落本地申请态,等对方人工确认后回调更新
try {
Map<String, Object> req = new HashMap<>(4);
req.put("channelNo", order.getChannelNo());
req.put("applyOnly", true);
req.put("reason", StrUtil.blankToDefault(reason, "用户申请取消(超半小时)"));
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));
}
order.setOrderStatus(STATUS_CANCEL_APPLY);
order.setFailMsg(StrUtil.maxLength(
StrUtil.blankToDefault(reason, "用户申请取消(超半小时,待酒店确认)"), 200));
orderMapper.updateById(order);
log.info("|hotel-cancel|用户超时取消申请已提交供应方|channelNo={}|userId={}|payTime={}|createTime={}|",
order.getChannelNo(), userId, order.getPayTime(), order.getCreateTime());
}
@Override
public void syncStatusByChannelNo(String channelNo) {
if (StrUtil.isBlank(channelNo)) {
return;
}
HotelZaxOrderDO order = orderMapper.selectOne(new LambdaQueryWrapperX<HotelZaxOrderDO>()
.eq(HotelZaxOrderDO::getChannelNo, channelNo.trim())
.last("limit 1"));
refreshStatusFromSupplier(order);
}
@Override
public int syncPendingOrders(int limit) {
int size = Math.max(1, Math.min(limit, 100));
// 中间态:主动拉供应方;已取消已付:补偿退款(refresh 内处理)
List<HotelZaxOrderDO> rows = orderMapper.selectList(new LambdaQueryWrapperX<HotelZaxOrderDO>()
.eq(HotelZaxOrderDO::getPayStatus, true)
.and(w -> w.in(HotelZaxOrderDO::getOrderStatus,
"PENDING_CONFIRM", "NEED_MANUAL", "PAID", "PENDING", "CREATED", STATUS_CANCEL_APPLY,
"CANCELLED", "CANCELED", "BOOK_FAIL", "UPSTREAM_FAIL")
.or()
.isNull(HotelZaxOrderDO::getOrderStatus))
.orderByDesc(HotelZaxOrderDO::getId)
.last("limit " + size));
int n = 0;
for (HotelZaxOrderDO row : rows) {
if (row == null || StrUtil.isBlank(row.getChannelNo())) {
continue;
}
try {
// 已关单且已退:跳过,避免无意义打供应方
if (isTerminalUnbookable(row.getOrderStatus()) && isLocalRefunded(row)) {
continue;
}
refreshStatusFromSupplier(row);
n++;
}
catch (Exception e) {
log.warn("|hotel-status|定时同步失败|channelNo={}|{}", row.getChannelNo(), e.getMessage());
}
}
return n;
}
@Override
@@ -392,11 +516,24 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
row.setPlatformNo(trim(src.get("platformNo")));
}
if (StrUtil.isNotBlank(trim(src.get("orderStatus")))) {
row.setOrderStatus(trim(src.get("orderStatus")));
String remoteSt = trim(src.get("orderStatus"));
// 供应方待取消确认 → 本地取消审核中
if ("PENDING_CANCEL".equalsIgnoreCase(remoteSt)) {
row.setOrderStatus(STATUS_CANCEL_APPLY);
}
else {
row.setOrderStatus(remoteSt);
}
}
if (StrUtil.isNotBlank(trim(src.get("bookStatus")))) {
row.setBookStatus(trim(src.get("bookStatus")));
}
// 供应方取消:orderStatus=CANCELLED 或 bookStatus=X 都视为关单
String book = StrUtil.trim(row.getBookStatus());
if ("X".equalsIgnoreCase(book) || "CANCEL".equalsIgnoreCase(book)
|| "CANCELLED".equalsIgnoreCase(book)) {
row.setOrderStatus("CANCELLED");
}
if (StrUtil.isNotBlank(trim(src.get("hotelCode")))) {
row.setHotelCode(trim(src.get("hotelCode")));
}
@@ -415,24 +552,85 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
row.setFailMsg(trim(src.get("failMsg")));
}
row.setLastNotifyAt(LocalDateTime.now());
boolean closed = isTerminalUnbookable(row.getOrderStatus());
boolean needRefund = closed && Boolean.TRUE.equals(row.getPayStatus()) && !isLocalRefunded(row);
String changeType = StrUtil.trim(trim(src.get("changeType")));
String refundReason = friendlyRefundReason(trim(src.get("failMsg")), changeType);
// 拒单/同意取消等:明确 changeType 时也强制走退款
if ("ORDER_REJECTED".equalsIgnoreCase(changeType) || "CANCEL_APPROVED".equalsIgnoreCase(changeType)
|| "REJECT".equalsIgnoreCase(changeType)) {
if (Boolean.TRUE.equals(row.getPayStatus()) && !isLocalRefunded(row)) {
needRefund = true;
row.setOrderStatus("CANCELLED");
closed = true;
}
}
// 展示给用户的失败原因也用友好文案
if (StrUtil.isNotBlank(row.getFailMsg())) {
row.setFailMsg(StrUtil.maxLength(friendlyRefundReason(row.getFailMsg(), changeType), 200));
}
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);
if (needRefund) {
// 先落取消态,再退款,避免退款异常把关单状态一起回滚
scheduleRefundAfterClose(row.getId(), channelNo, refundReason);
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("channelNo", channelNo);
out.put("orderStatus", row.getOrderStatus());
out.put("id", row.getId());
out.put("refundScheduled", needRefund);
return out;
}
/**
* 关单后退款:事务提交后执行,幂等(已退则跳过)。
* 覆盖回调与主动查询两条路径,避免「状态已取消但钱没退回」。
*/
private void scheduleRefundAfterClose(Long orderId, String channelNo, String reason) {
if (orderId == null) {
return;
}
final String refundReason = friendlyRefundReason(reason, null);
Runnable refundTask = () -> {
try {
HotelZaxOrderDO latest = orderMapper.selectById(orderId);
if (latest == null) {
return;
}
if (!Boolean.TRUE.equals(latest.getPayStatus()) || isLocalRefunded(latest)) {
return;
}
refundIfPaid(latest, refundReason);
if (!"CANCELLED".equalsIgnoreCase(StrUtil.trim(latest.getOrderStatus()))
&& !"CANCELED".equalsIgnoreCase(StrUtil.trim(latest.getOrderStatus()))) {
latest.setOrderStatus("CANCELLED");
}
orderMapper.updateById(latest);
log.info("|hotel-refund|供应方关单已退款|channelNo={}|orderId={}|", channelNo, orderId);
}
catch (Exception e) {
log.error("|hotel-refund|供应方关单退款失败|channelNo={}|orderId={}|{}", channelNo, orderId,
e.getMessage(), e);
}
};
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
refundTask.run();
}
});
}
else {
refundTask.run();
}
}
private void doCancel(HotelZaxOrderDO order, String reason, boolean notifySupplier) {
boolean alreadyClosed = isTerminalUnbookable(order.getOrderStatus());
if (alreadyClosed && (!Boolean.TRUE.equals(order.getPayStatus()) || isLocalRefunded(order))) {
@@ -440,9 +638,19 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
}
if (!alreadyClosed && notifySupplier && shouldNotifySupplierCancel(order)) {
try {
Map<String, Object> req = new HashMap<String, Object>();
Map<String, Object> req = new HashMap<>(4);
req.put("channelNo", order.getChannelNo());
openClient.post("order/cancel", req);
JSONObject resp = openClient.post("order/cancel", req);
JSONObject data = resp == null ? null : resp.getJSONObject("data");
String remoteSt = data == null ? null : data.getStr("orderStatus");
// 供应方已挂起待确认:本地改为取消审核中,等对方人工确认后再回调关单/退款
if ("PENDING_CANCEL".equalsIgnoreCase(remoteSt)) {
order.setOrderStatus(STATUS_CANCEL_APPLY);
order.setFailMsg(StrUtil.maxLength(
StrUtil.blankToDefault(reason, "取消申请已提交,待酒店确认"), 200));
orderMapper.updateById(order);
return;
}
}
catch (Exception e) {
log.warn("|hotel-cancel|通知资安心取消失败|channelNo={}|{}", order.getChannelNo(), e.getMessage());
@@ -534,11 +742,15 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
return;
}
Map<String, Object> openBody = rebuildOpenBody(order);
// 支付成功后才调供应方下单,显式带已支付,便于对方拒单时按已付单回调退款
openBody.put("payStatus", "PAID");
try {
JSONObject createResp = openClient.post("order/create", openBody);
JSONObject data = createResp == null ? null : createResp.getJSONObject("data");
if (data != null) {
order.setOrderNo(data.getStr("orderNo"));
String supplierOrderNo = firstNonBlank(data.getStr("orderNo"),
data.getStr("innerOrderId"), data.getStr("platformNo"));
order.setOrderNo(supplierOrderNo);
order.setPlatformNo(data.getStr("platformNo"));
if (StrUtil.isNotBlank(data.getStr("orderStatus"))) {
order.setOrderStatus(data.getStr("orderStatus"));
@@ -695,9 +907,33 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
m.put("payExpireTime", order.getCreateTime().plus(PAY_EXPIRE));
m.put("expireTime", order.getCreateTime().plus(PAY_EXPIRE));
}
boolean cancelApply = STATUS_CANCEL_APPLY.equalsIgnoreCase(StrUtil.trim(order.getOrderStatus()));
boolean cancelDirect = !isTerminalUnbookable(order.getOrderStatus()) && !cancelApply
&& Boolean.TRUE.equals(order.getPayStatus()) && withinUserCancelFreeWindow(order);
boolean cancelNeedManual = !isTerminalUnbookable(order.getOrderStatus()) && !cancelApply
&& Boolean.TRUE.equals(order.getPayStatus()) && !withinUserCancelFreeWindow(order);
m.put("cancelApplyPending", cancelApply);
m.put("canCancelDirect", cancelDirect);
m.put("canCancelApply", cancelNeedManual);
LocalDateTime cancelBase = order.getPayTime() != null ? order.getPayTime() : order.getCreateTime();
if (cancelBase != null) {
m.put("cancelDeadline", cancelBase.plus(USER_CANCEL_FREE_WINDOW));
}
return m;
}
/** 支付成功后半小时内(未支付则按下单时间)可自助取消 */
private static boolean withinUserCancelFreeWindow(HotelZaxOrderDO order) {
if (order == null) {
return false;
}
LocalDateTime base = order.getPayTime() != null ? order.getPayTime() : order.getCreateTime();
if (base == null) {
return false;
}
return !LocalDateTime.now().isAfter(base.plus(USER_CANCEL_FREE_WINDOW));
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> parseGuests(Object raw) {
List<Map<String, Object>> list = new ArrayList<>();
@@ -807,6 +1043,115 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
}
}
/**
* 待确认/预定中:主动查供应方并落库,避免回调丢失后小程序一直停在「待酒店确认中」。
* 若已关单且已付未退,同步发起退款(拒单回调丢失时的补偿)。
*/
private HotelZaxOrderDO refreshStatusFromSupplier(HotelZaxOrderDO order) {
if (order == null || StrUtil.isBlank(order.getChannelNo())) {
return order;
}
if (!Boolean.TRUE.equals(order.getPayStatus())) {
return order;
}
// 已关单但未退:直接补偿退款(例如回调丢了、只靠查询刷成了 CANCELLED)
if (isTerminalUnbookable(order.getOrderStatus()) && !isLocalRefunded(order)) {
scheduleRefundAfterClose(order.getId(), order.getChannelNo(),
StrUtil.blankToDefault(order.getFailMsg(), "供应方拒单/取消退款"));
return order;
}
String st = StrUtil.trim(order.getOrderStatus());
boolean need = "PENDING_CONFIRM".equalsIgnoreCase(st) || "NEED_MANUAL".equalsIgnoreCase(st)
|| "PAID".equalsIgnoreCase(st) || "PENDING".equalsIgnoreCase(st)
|| "CREATED".equalsIgnoreCase(st) || STATUS_CANCEL_APPLY.equalsIgnoreCase(st);
if (!need) {
return order;
}
try {
Map<String, Object> q = new HashMap<>(2);
q.put("channelNo", order.getChannelNo());
JSONObject resp = openClient.post("order/query", q);
JSONObject data = resp == null ? null : resp.getJSONObject("data");
if (data == null) {
return order;
}
boolean changed = false;
String remoteStatus = firstNonBlank(data.getStr("orderStatus"), data.getStr("status"));
if ("PENDING_CANCEL".equalsIgnoreCase(remoteStatus)) {
remoteStatus = STATUS_CANCEL_APPLY;
}
if (StrUtil.isNotBlank(remoteStatus)
&& !remoteStatus.equalsIgnoreCase(StrUtil.trim(order.getOrderStatus()))) {
// 本地已是取消申请中时,不要被供应方仍显示的「已订成」冲掉(等对方人工处理回调)
if (STATUS_CANCEL_APPLY.equalsIgnoreCase(st)
&& !"CANCELLED".equalsIgnoreCase(remoteStatus)
&& !STATUS_CANCEL_APPLY.equalsIgnoreCase(remoteStatus)) {
// 若供应方已恢复为 UPSTREAM_OK,说明拒绝取消,允许覆盖
if ("UPSTREAM_OK".equalsIgnoreCase(remoteStatus) || "CREATED".equalsIgnoreCase(remoteStatus)
|| "CHECKED_IN".equalsIgnoreCase(remoteStatus)) {
order.setOrderStatus(remoteStatus);
order.setFailMsg(null);
changed = true;
}
}
else {
order.setOrderStatus(remoteStatus);
changed = true;
}
}
String remoteBook = data.getStr("bookStatus");
if (StrUtil.isNotBlank(remoteBook) && !remoteBook.equalsIgnoreCase(StrUtil.trim(order.getBookStatus()))) {
order.setBookStatus(remoteBook);
changed = true;
}
String remoteOrderNo = firstNonBlank(data.getStr("orderNo"), data.getStr("innerOrderId"));
if (StrUtil.isNotBlank(remoteOrderNo) && StrUtil.isBlank(order.getOrderNo())) {
order.setOrderNo(remoteOrderNo);
changed = true;
}
String remotePlatform = data.getStr("platformNo");
if (StrUtil.isNotBlank(remotePlatform) && !remotePlatform.equals(StrUtil.trim(order.getPlatformNo()))) {
order.setPlatformNo(remotePlatform);
changed = true;
}
if ("X".equalsIgnoreCase(StrUtil.trim(order.getBookStatus()))) {
order.setOrderStatus("CANCELLED");
changed = true;
}
String remoteFail = data.getStr("failMsg");
if (StrUtil.isNotBlank(remoteFail) && StrUtil.isBlank(order.getFailMsg())) {
order.setFailMsg(StrUtil.maxLength(remoteFail, 200));
changed = true;
}
if (changed) {
orderMapper.updateById(order);
log.info("|hotel-status|已从供应方刷新|channelNo={}|status={}|", order.getChannelNo(),
order.getOrderStatus());
}
// 查询发现已关单:同步退款(不依赖回调是否到达)
if (isTerminalUnbookable(order.getOrderStatus()) && !isLocalRefunded(order)) {
scheduleRefundAfterClose(order.getId(), order.getChannelNo(),
StrUtil.blankToDefault(order.getFailMsg(), "供应方拒单/取消退款"));
}
}
catch (Exception e) {
log.warn("|hotel-status|刷新供应方状态失败|channelNo={}|{}", order.getChannelNo(), e.getMessage());
}
return order;
}
private static String firstNonBlank(String... vals) {
if (vals == null) {
return null;
}
for (String v : vals) {
if (StrUtil.isNotBlank(v)) {
return v.trim();
}
}
return null;
}
private String resolvePayMerchantOrderId(HotelZaxOrderDO order) {
if (order.getPayOrderId() != null) {
try {
@@ -860,6 +1205,35 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
|| "UPSTREAM_FAIL".equalsIgnoreCase(s);
}
/** 退款/关单原因对用户展示友好化(避免「人工拒绝接单」等内部话术) */
private static String friendlyRefundReason(String raw, String changeType) {
String msg = StrUtil.trim(raw);
if ("ORDER_REJECTED".equalsIgnoreCase(StrUtil.trim(changeType))
|| "REJECT".equalsIgnoreCase(StrUtil.trim(changeType))) {
if (StrUtil.isBlank(msg) || looksInternalRejectWording(msg)) {
return "酒店暂无法确认预订,已为您取消并全额退款";
}
}
if (looksInternalRejectWording(msg)) {
return "酒店暂无法确认预订,已为您取消并全额退款";
}
if (StrUtil.isBlank(msg) || "供应方取消/拒单退款".equals(msg) || "供应方拒单/取消退款".equals(msg)) {
if ("CANCEL_APPROVED".equalsIgnoreCase(StrUtil.trim(changeType))) {
return "预订已取消,退款将原路退回";
}
return "预订未成功,已为您全额退款";
}
return msg;
}
private static boolean looksInternalRejectWording(String msg) {
if (StrUtil.isBlank(msg)) {
return false;
}
return msg.contains("人工拒绝") || msg.contains("拒绝接单") || "后台取消".equals(msg)
|| "后台取消退款".equals(msg);
}
/** 顾客端状态文案,不直接展示内部英文码 */
private static String customerStatusName(String orderStatus, Boolean payStatus) {
String s = StrUtil.trim(orderStatus);
@@ -870,7 +1244,10 @@ public class HotelZaxTradeServiceImpl implements HotelZaxTradeService {
return "已支付,预定中";
}
if ("PENDING_CONFIRM".equals(s) || "NEED_MANUAL".equals(s)) {
return "预定处理中";
return "待酒店确认中";
}
if (STATUS_CANCEL_APPLY.equalsIgnoreCase(s)) {
return "取消审核中";
}
if ("CREATED".equals(s) || "UPSTREAM_OK".equals(s)) {
return "预定成功";