diff --git a/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java b/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java index 88b4ad3..be63597 100644 --- a/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java +++ b/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/ErrorCodeConstants.java @@ -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, "异常退款失败:{}"); } diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/HotelZaxController.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/HotelZaxController.java index 696c84b..5023181 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/HotelZaxController.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/HotelZaxController.java @@ -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 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')") diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxOrderRespVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxOrderRespVO.java index 207f3ec..ff6f077 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxOrderRespVO.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/hotel/vo/HotelZaxOrderRespVO.java @@ -59,5 +59,8 @@ public class HotelZaxOrderRespVO { private String failMsg; + @Schema(description = "本地是否已发起退款") + private Boolean localRefunded; + private LocalDateTime createTime; } diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/AppHotelController.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/AppHotelController.java index 8b2582c..35cb24a 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/AppHotelController.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/AppHotelController.java @@ -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 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 emptyHotelPage(Map 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 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 orderCancel(@RequestBody Map 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 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))); diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/HotelZaxOpenCallbackController.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/HotelZaxOpenCallbackController.java index 1229852..970c69a 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/HotelZaxOpenCallbackController.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/hotel/HotelZaxOpenCallbackController.java @@ -63,20 +63,27 @@ public class HotelZaxOpenCallbackController { @PermitAll @PostMapping("/hotel/resource/notify") - @Operation(summary = "资源变更回调(验签后异步拉 detail)") + @Operation(summary = "资源变更回调(验签后异步拉 detail;DAILY_FULL 则全量拉取)") public CommonResult> resourceNotify(HttpServletRequest request, @RequestBody String rawBody) { callbackAuth.assertValid(request, rawBody); JSONObject body = JSONUtil.parseObj(StrUtil.blankToDefault(rawBody, "{}")); String hotelCode = StrUtil.trim(body.getStr("hotelCode")); String changeType = StrUtil.blankToDefault(body.getStr("changeType"), "HOTEL_UPSERT"); - if (StrUtil.isBlank(hotelCode)) { + 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 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 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); } diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenClient.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenClient.java index 0cecef8..e441589 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenClient.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenClient.java @@ -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 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, "酒店供应方接口异常,请稍后重试"); } /** 是否供应方业务拒绝(可订校验失败等),便于调用方决定是否刷新本地日历 */ diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenProperties.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenProperties.java index 4128e92..496529c 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenProperties.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/hotel/ZaxHotelOpenProperties.java @@ -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 * * ?"; } diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxDailySyncJob.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxDailySyncJob.java index 47088fe..cd567ed 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxDailySyncJob.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxDailySyncJob.java @@ -15,6 +15,8 @@ import java.util.Map; /** * 每天从资安心拉取酒店主数据、房型、产品与价态日历到本地。 + *

+ * 与资安心「开放应用日更推送 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(); diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxOrderStatusSyncJob.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxOrderStatusSyncJob.java new file mode 100644 index 0000000..7124e69 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/job/hotel/HotelZaxOrderStatusSyncJob.java @@ -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()); + } + }); + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/device/DeviceServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/device/DeviceServiceImpl.java index 3147866..11d9a9f 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/device/DeviceServiceImpl.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/device/DeviceServiceImpl.java @@ -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 requestEntity = new HttpEntity<>(request, headers); - - // 创建RestTemplate - RestTemplate restTemplate = new RestTemplate(); - - try { - // 发送请求并解析响应 - ResponseEntity 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 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 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 diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminService.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminService.java index edf3f17..09f5828 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminService.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminService.java @@ -53,4 +53,7 @@ public interface HotelZaxAdminService { /** 后台取消:已支付原路退款,并通知资安心取消预订 */ void cancelOrder(Long id, String reason); + + /** 异常退款:已支付且未退时强制原路退款,不依赖可取消状态 */ + void abnormalRefund(Long id, String reason); } diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminServiceImpl.java index 531b4f3..8ffedb9 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminServiceImpl.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxAdminServiceImpl.java @@ -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() + .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 toOrderPage(PageResult page) { + if (page == null) { + return PageResult.empty(); + } + List 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; diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncServiceImpl.java index b4b1bfd..ea0643a 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncServiceImpl.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxSyncServiceImpl.java @@ -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 syncedCodes = new HashSet<>(); - while (hasMore && pageNo <= 40) { + // 单页 50,上限覆盖约 2.5 万家;触顶仍不 listComplete + final int maxPages = 500; + while (hasMore && pageNo <= maxPages) { Map 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 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); diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeService.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeService.java index 98e0302..c0c785a 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeService.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeService.java @@ -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 applySupplierStatusNotify(Map body); + + /** + * 主动向供应方查询并落库(待确认等中间态;回调丢失时的补偿)。 + */ + void syncStatusByChannelNo(String channelNo); + + /** + * 定时补偿:扫描中间态/已取消未退订单,主动拉供应方状态并补退款。 + * 对方维护导致回调丢失时的兜底。 + * + * @return 本轮处理条数 + */ + int syncPendingOrders(int limit); } diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeServiceImpl.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeServiceImpl.java index d48f6b7..a5004ea 100644 --- a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeServiceImpl.java +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/service/hotel/HotelZaxTradeServiceImpl.java @@ -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 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() + .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 rows = orderMapper.selectList(new LambdaQueryWrapperX() + .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 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 req = new HashMap(); + Map 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 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> parseGuests(Object raw) { List> 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 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 "预定成功";