diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/enums/MerchantTypeEnum.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/enums/MerchantTypeEnum.java index e9d2bc88..eda15f18 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/enums/MerchantTypeEnum.java +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/enums/MerchantTypeEnum.java @@ -16,6 +16,8 @@ public enum MerchantTypeEnum { SELF_SUPERMARKET("3", "自营商家"), THIRD_APP("4", "第三方应用"), FARMER("5", "共富商城(助农)"), + /** 酒店预订收款商户,与食堂/商超隔离 */ + HOTEL("6", "酒店"), ; /** diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/api/feign/RemoteSmHotelOrderService.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/api/feign/RemoteSmHotelOrderService.java new file mode 100644 index 00000000..7eca164d --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/api/feign/RemoteSmHotelOrderService.java @@ -0,0 +1,31 @@ +package com.bsd.pjcloud.supermarket.api.feign; + +import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.feign.annotation.NoToken; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * 酒店订单内部 Feign:支付发起校验与支付成功履约。 + */ +@FeignClient(contextId = "remoteSmHotelOrderService", value = ServiceNameConstants.SUPERMARKET_SERVICE) +public interface RemoteSmHotelOrderService { + + /** + * 读取待支付酒店订单的可信金额与归属用户。 + */ + @NoToken + @GetMapping("/inner/sm-hotel-order/pay-info") + R getPayInfo(@RequestParam("orderId") Long orderId); + + /** + * 第三方支付成功后履约:标记已付;本地酒店扣库存确认,上游酒店再调上游下单。 + */ + @NoToken + @PostMapping("/inner/sm-hotel-order/pay-success") + R paySuccess(@RequestParam("orderId") Long orderId); +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelOrderPayInfoVo.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelOrderPayInfoVo.java new file mode 100644 index 00000000..d9d678db --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/vo/SmHotelOrderPayInfoVo.java @@ -0,0 +1,33 @@ +package com.bsd.pjcloud.supermarket.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 酒店订单支付可信信息,供 thirdparty 创建支付单时校验金额与归属。 + */ +@Data +@Schema(description = "酒店订单支付信息") +public class SmHotelOrderPayInfoVo { + + @Schema(description = "酒店订单主键") + private Long orderId; + + @Schema(description = "下单 App 用户 ID(从 channelNo 解析)") + private Long userId; + + @Schema(description = "酒店所属部门(收款商户按此解析,非下单用户部门)") + private Long hotelDeptId; + + @Schema(description = "应付金额(分)") + private Integer amountFen; + + @Schema(description = "本地支付状态 UNPAID/PAID") + private String payStatus; + + @Schema(description = "订单状态") + private String orderStatus; + + @Schema(description = "是否允许发起支付") + private Boolean payable; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java index f3471682..51ae3c6b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelAppController.java @@ -10,7 +10,6 @@ import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; -import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource; import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity; @@ -63,7 +62,6 @@ public class SmHotelAppController { @GetMapping("/filter-meta") public R> filterMeta() { var online = Wrappers.lambdaQuery() - .eq(SmHotelEntity::getSheetName, SmHotelDataSource.LOCAL) .and(w -> w.isNull(SmHotelEntity::getUpstreamStatus) .or() .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")) @@ -131,8 +129,6 @@ public class SmHotelAppController { List starList = parseIntCsv(starLevels); List facilityList = parseStrCsv(facilityTags); var wrapper = Wrappers.lambdaQuery() - // 移动端 TOC 仅展示本地酒店,不展示上游同步酒店 - .eq(SmHotelEntity::getSheetName, SmHotelDataSource.LOCAL) .and(w -> w.isNull(SmHotelEntity::getUpstreamStatus) .or() .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")) @@ -191,11 +187,7 @@ public class SmHotelAppController { if (r.getCode() != 0 || r.getData() == null || r.getData().getHotel() == null) { return r; } - SmHotelEntity hotel = r.getData().getHotel(); - if (!SmHotelDataSource.isLocal(hotel.getSheetName())) { - return R.failed("仅支持预订本地酒店"); - } - if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) { + if ("OFFLINE".equalsIgnoreCase(r.getData().getHotel().getUpstreamStatus())) { return R.failed("酒店已下架"); } return r; @@ -210,9 +202,6 @@ public class SmHotelAppController { if (hotel == null) { return R.failed("酒店不存在"); } - if (!SmHotelDataSource.isLocal(hotel.getSheetName())) { - return R.failed("仅支持预订本地酒店"); - } if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) { return R.failed("酒店已下架"); } @@ -227,20 +216,6 @@ public class SmHotelAppController { return R.failed("请先登录"); } Map req = body == null ? new HashMap<>() : new HashMap<>(body); - Long hotelId = null; - Object hotelIdObj = req.get("hotelId"); - if (hotelIdObj != null && StrUtil.isNotBlank(String.valueOf(hotelIdObj))) { - hotelId = Long.valueOf(String.valueOf(hotelIdObj)); - } - if (hotelId != null) { - SmHotelEntity hotel = hotelService.getById(hotelId); - if (hotel == null) { - return R.failed("酒店不存在"); - } - if (!SmHotelDataSource.isLocal(hotel.getSheetName())) { - return R.failed("仅支持预订本地酒店"); - } - } // 渠道单号绑定用户 ID:APP{userId}{ts},列表/详情/取消均按此前缀认主(与用户名无关) req.put("channelNo", "APP" + user.getId() + System.currentTimeMillis()); if (StrUtil.isBlank(str(req.get("contactName")))) { @@ -249,7 +224,8 @@ public class SmHotelAppController { if (StrUtil.isBlank(str(req.get("contactPhone")))) { req.put("contactPhone", user.getPhone()); } - return orderService.createByAdmin(req); + // App 下单先落待支付订单,支付成功后再履约(本地扣库存 / 上游下单) + return orderService.createAppPendingOrder(req); } @Operation(summary = "我的酒店订单分页") diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderInnerController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderInnerController.java new file mode 100644 index 00000000..2af7f665 --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelOrderInnerController.java @@ -0,0 +1,40 @@ +package com.bsd.pjcloud.supermarket.controller; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.supermarket.service.SmHotelOrderService; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 酒店订单内部接口:供 thirdparty 支付校验与支付成功回调。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/inner/sm-hotel-order") +@Tag(name = "酒店订单内部接口") +public class SmHotelOrderInnerController { + + private final SmHotelOrderService orderService; + + @Inner + @Operation(summary = "支付前读取订单可信金额") + @GetMapping("/pay-info") + public R payInfo(@RequestParam("orderId") Long orderId) { + return R.ok(orderService.getPayInfo(orderId)); + } + + @Inner + @Operation(summary = "支付成功履约") + @PostMapping("/pay-success") + public R paySuccess(@RequestParam("orderId") Long orderId) { + return R.ok(orderService.onAppPaySuccess(orderId)); + } +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java index 0a81bdc4..b07e5766 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelOrderService.java @@ -4,6 +4,7 @@ import cn.hutool.json.JSONObject; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.supermarket.entity.SmHotelOrderEntity; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo; import com.bsd.pjcloud.common.core.util.R; import java.util.Map; @@ -29,6 +30,19 @@ public interface SmHotelOrderService { */ R createByAdmin(Map body); + /** + * App 待支付创单:只落业务订单,不扣本地库存、不调上游;支付成功后再履约。 + */ + R createAppPendingOrder(Map body); + + /** 供 thirdparty 拉起支付前校验 */ + SmHotelOrderPayInfoVo getPayInfo(Long orderId); + + /** + * App 支付成功履约。本地酒店只确认库存;上游酒店再调上游下单。 + */ + Boolean onAppPaySuccess(Long orderId); + /** 下游回传取消 → 调上游取消 */ R cancelFromDownstream(String appKey, Map body); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java index eeaa1436..b10faafe 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java @@ -16,6 +16,7 @@ import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouOrderCallResult; import com.bsd.pjcloud.supermarket.mapper.*; import com.bsd.pjcloud.supermarket.service.SmHotelOrderService; import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.tenant.TenantBroker; import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; @@ -26,6 +27,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; @@ -55,6 +57,12 @@ public class SmHotelOrderServiceImpl extends ServiceImpl createAppPendingOrder(Map body) { + if (body == null) { + return R.failed("请求体不能为空"); + } + Long hotelId = null; + Object hotelIdObj = body.get("hotelId"); + if (hotelIdObj != null && StrUtil.isNotBlank(String.valueOf(hotelIdObj))) { + hotelId = Long.valueOf(String.valueOf(hotelIdObj)); + } + SmHotelEntity hotel = null; + if (hotelId != null) { + hotel = hotelMapper.selectById(hotelId); + } + if (hotel == null) { + String hotelCode = str(body.get("hotelCode")); + if (StrUtil.isNotBlank(hotelCode)) { + hotel = hotelMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelEntity::getHotelCode, hotelCode) + .last("limit 1")); + } + } + if (hotel == null) { + return R.failed("酒店不存在,请传 hotelId 或 hotelCode"); + } + if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) { + return R.failed("酒店已下架,无法下单"); + } + String productCode = str(body.get("productCode")); + if (StrUtil.isBlank(productCode)) { + return R.failed("productCode 不能为空"); + } + SmHotelProductEntity product = productMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelProductEntity::getHotelId, hotel.getId()) + .eq(SmHotelProductEntity::getProductCode, productCode) + .last("limit 1")); + if (product == null) { + return R.failed("产品不存在,请先拉取酒店详情"); + } + boolean localHotel = SmHotelDataSource.isLocal(hotel.getSheetName()); + if (StrUtil.isBlank(product.getRoomTypeCode())) { + return R.failed("产品缺少房型码"); + } + if (!localHotel && StrUtil.isBlank(product.getPriceCode())) { + return R.failed("产品缺少房价码,请重新拉取上游详情"); + } + LocalDate checkIn = parseDate(body.get("checkInDate")); + LocalDate checkOut = parseDate(body.get("checkOutDate")); + if (checkIn == null || checkOut == null || !checkIn.isBefore(checkOut)) { + return R.failed("入住/离店日期无效"); + } + String contactName = str(body.get("contactName")); + String contactPhone = str(body.get("contactPhone")); + if (StrUtil.hasBlank(contactName, contactPhone)) { + return R.failed("联系人姓名和手机号不能为空"); + } + + String channelNo = str(body.get("channelNo")); + if (StrUtil.isBlank(channelNo)) { + channelNo = "APP" + System.currentTimeMillis() + (int) (Math.random() * 900 + 100); + } + SmHotelOrderEntity exist = baseMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelOrderEntity::getChannelNo, channelNo) + .last("limit 1")); + if (exist != null) { + JSONObject existOut = buildOut(exist); + existOut.set("hotelName", hotel.getHotelName()); + existOut.set("productName", product.getProductName()); + existOut.set("sellAmount", exist.getSellAmount()); + existOut.set("amountFen", toAmountFen(exist.getSellAmount())); + return R.ok(existOut, "订单已存在(幂等)"); + } + + int count = body.get("count") == null ? 1 : Integer.parseInt(String.valueOf(body.get("count"))); + if (count < 1) { + count = 1; + } + + SmHotelOrderEntity order = new SmHotelOrderEntity(); + order.setDownstreamAppId(null); + order.setHotelId(hotel.getId()); + order.setChannelNo(channelNo); + order.setGroupCode(hotel.getGroupCode()); + order.setHotelCode(hotel.getHotelCode()); + order.setRoomTypeCode(StrUtil.blankToDefault(str(body.get("roomTypeCode")), product.getRoomTypeCode())); + order.setProductCode(productCode); + order.setPriceCode(StrUtil.blankToDefault(str(body.get("priceCode")), product.getPriceCode())); + order.setCheckInDate(checkIn); + order.setCheckOutDate(checkOut); + order.setCount(count); + order.setContactName(contactName); + order.setContactPhone(contactPhone); + if (body.get("guestList") != null) { + order.setGuestJson(JSONUtil.toJsonStr(body.get("guestList"))); + } + else { + Map guest = new HashMap<>(2); + guest.put("name", contactName); + guest.put("mobile", contactPhone); + order.setGuestJson(JSONUtil.toJsonStr(List.of(guest))); + } + order.setMarkupPercent(localHotel ? BigDecimal.ZERO : priceConfigService.currentMarkupPercent()); + if (product.getCostPrice() != null) { + order.setCostAmount(product.getCostPrice().multiply(BigDecimal.valueOf(count))); + } + order.setOrderStatus("CREATED"); + order.setPayStatus(PAY_UNPAID); + order.setCompensateStatus(COMP_NONE); + order.setInventoryStatus(SmHotelInventoryStatus.NONE); + order.setCompensateRound(0); + order.setReqJson(JSONUtil.toJsonStr(body)); + + if (localHotel) { + String precheck = precheckLocalInventory(product.getId(), checkIn, checkOut, count); + if (precheck != null) { + return R.failed(precheck); + } + // 待支付阶段按日历卖价锁定应付金额,支付成功履约时再占用库存 + BigDecimal sellSum = sumLocalSellAmount(product.getId(), checkIn, checkOut, count); + if (sellSum != null) { + order.setSellAmount(sellSum); + } + } + else { + // 上游:优先用前端合计(含间夜),否则按产品单价×间数×间夜 + if (body.get("totalAmount") != null) { + order.setSellAmount(toDecimal(body.get("totalAmount"))); + } + else if (product.getSellPrice() != null) { + long nights = java.time.temporal.ChronoUnit.DAYS.between(checkIn, checkOut); + if (nights < 1) { + nights = 1; + } + order.setSellAmount(product.getSellPrice() + .multiply(BigDecimal.valueOf(count)) + .multiply(BigDecimal.valueOf(nights))); + } + } + if (order.getSellAmount() == null || order.getSellAmount().compareTo(BigDecimal.ZERO) <= 0) { + return R.failed("应付金额无效,无法发起支付"); + } + + baseMapper.insert(order); + JSONObject out = buildOut(order); + out.set("hotelName", hotel.getHotelName()); + out.set("productName", product.getProductName()); + out.set("sellAmount", order.getSellAmount()); + out.set("amountFen", toAmountFen(order.getSellAmount())); + out.set("hotelDeptId", hotel.getDeptId()); + return R.ok(out, "待支付订单已创建"); + } + + @Override + public SmHotelOrderPayInfoVo getPayInfo(Long orderId) { + SmHotelOrderPayInfoVo vo = new SmHotelOrderPayInfoVo(); + if (orderId == null) { + vo.setPayable(Boolean.FALSE); + return vo; + } + SmHotelOrderEntity order = baseMapper.selectById(orderId); + if (order == null) { + vo.setPayable(Boolean.FALSE); + return vo; + } + vo.setOrderId(order.getId()); + vo.setUserId(parseAppUserId(order.getChannelNo())); + vo.setAmountFen(toAmountFen(order.getSellAmount())); + vo.setPayStatus(order.getPayStatus()); + vo.setOrderStatus(order.getOrderStatus()); + // 收款商户跟酒店部门,不跟下单用户部门 + if (order.getHotelId() != null) { + SmHotelEntity hotel = hotelMapper.selectById(order.getHotelId()); + if (hotel != null) { + vo.setHotelDeptId(hotel.getDeptId()); + } + } + boolean payable = PAY_UNPAID.equals(order.getPayStatus()) + && "CREATED".equals(order.getOrderStatus()) + && vo.getUserId() != null + && vo.getHotelDeptId() != null + && vo.getAmountFen() != null + && vo.getAmountFen() > 0; + vo.setPayable(payable); + return vo; + } + + /** + * 支付成功履约:本地酒店扣日历库存确认;上游酒店再调上游下单。幂等可重入。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean onAppPaySuccess(Long orderId) { + if (orderId == null) { + return false; + } + SmHotelOrderEntity order = baseMapper.selectById(orderId); + if (order == null) { + log.error("|酒店支付成功|订单不存在|orderId={}|", orderId); + return false; + } + // 已履约成功:幂等返回 + if (PAY_PAID.equals(order.getPayStatus()) + && ("UPSTREAM_OK".equals(order.getOrderStatus()) + || "PENDING".equals(order.getOrderStatus()) + || "PENDING_CONFIRM".equals(order.getOrderStatus()) + || "CHECKED_IN".equals(order.getOrderStatus()))) { + return true; + } + if (!PAY_UNPAID.equals(order.getPayStatus()) || !"CREATED".equals(order.getOrderStatus())) { + log.warn("|酒店支付成功|状态不可履约|orderId={}|payStatus={}|orderStatus={}|", orderId, + order.getPayStatus(), order.getOrderStatus()); + return false; + } + + SmHotelEntity hotel = hotelMapper.selectById(order.getHotelId()); + if (hotel == null) { + log.error("|酒店支付成功|酒店不存在|orderId={}|hotelId={}|", orderId, order.getHotelId()); + return false; + } + SmHotelProductEntity product = productMapper.selectOne(Wrappers.lambdaQuery() + .eq(SmHotelProductEntity::getHotelId, order.getHotelId()) + .eq(SmHotelProductEntity::getProductCode, order.getProductCode()) + .last("limit 1")); + + boolean localHotel = SmHotelDataSource.isLocal(hotel.getSheetName()); + if (localHotel) { + if (product == null) { + log.error("|酒店支付成功|本地履约缺产品|orderId={}|", orderId); + return false; + } + // 本地数据源:只确认本地库存,不回调上游 + R local = finishLocalOrder(order, product); + order = baseMapper.selectById(orderId); + if (order == null) { + return false; + } + order.setPayStatus(PAY_PAID); + baseMapper.updateById(order); + log.info("|酒店支付成功|本地履约完成|orderId={}|ok={}|", orderId, local != null && local.isOk()); + return local != null && local.isOk(); + } + + if (product == null) { + order.setOrderStatus("UPSTREAM_FAIL"); + order.setPayStatus(PAY_PAID); + order.setFailMsg("支付成功但产品不存在,无法提交上游"); + baseMapper.updateById(order); + return false; + } + + Map reqBody = StrUtil.isBlank(order.getReqJson()) ? Map.of() + : JSONUtil.parseObj(order.getReqJson()); + com.alibaba.fastjson.JSONObject upstreamBody = buildUpstreamOrderBody(order, product, reqBody); + try { + String availMsg = doubleCheckAvailability(upstreamBody, order.getCount()); + if (availMsg != null) { + order.setOrderStatus("UPSTREAM_FAIL"); + order.setPayStatus(PAY_PAID); + order.setFailMsg(StrUtil.sub(availMsg, 0, 500)); + baseMapper.updateById(order); + return false; + } + } + catch (Exception e) { + log.warn("|酒店支付成功|可定校验异常,继续下单|orderId={}|msg={}|", orderId, e.getMessage()); + order.setLastCompensateMsg("可定校验异常已忽略: " + StrUtil.sub(e.getMessage(), 0, 200)); + } + + ShangyouOrderCallResult call = upstreamClient.createOrderRaw(upstreamBody); + applyCreateResult(order, call, upstreamBody); + order.setPayStatus(PAY_PAID); + baseMapper.updateById(order); + boolean hardFail = "UPSTREAM_FAIL".equals(order.getOrderStatus()) + && !COMP_WAITING.equals(order.getCompensateStatus()); + log.info("|酒店支付成功|上游履约|orderId={}|status={}|hardFail={}|", orderId, order.getOrderStatus(), + hardFail); + // 支付已成功:即使上游暂时失败也返回 true,避免支付回调无限重试;补偿任务会继续处理 + return true; + } + + /** channelNo 形如 APP{userId}{13位毫秒时间戳} */ + private Long parseAppUserId(String channelNo) { + if (StrUtil.isBlank(channelNo) || !channelNo.startsWith("APP") || channelNo.length() <= 16) { + return null; + } + String rest = channelNo.substring(3); + if (rest.length() <= 13) { + return null; + } + String userIdStr = rest.substring(0, rest.length() - 13); + try { + return Long.valueOf(userIdStr); + } + catch (Exception e) { + return null; + } + } + + private Integer toAmountFen(BigDecimal yuan) { + if (yuan == null || yuan.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + return yuan.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP).intValue(); + } + /** * 本地下单前只读校验日历库存(不占用 sold)。 * @@ -1021,6 +1339,18 @@ public class SmHotelOrderServiceImpl extends ServiceImpl5.7.0 compile + + + com.bsd + pjcloud-supermarket-api + 5.7.0 + com.bsd pjcloud-integrated-api diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java index cf454dfa..c0eed75b 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImpl.java @@ -29,6 +29,8 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; +import com.bsd.pjcloud.supermarket.api.feign.RemoteSmHotelOrderService; +import com.bsd.pjcloud.supermarket.vo.SmHotelOrderPayInfoVo; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformLoginErrorEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; @@ -241,6 +243,11 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { */ private final RemoteHardOrderService remoteHardOrderService; + /** + * 酒店订单远程服务,仅供 App 酒店预订支付发起校验与成功履约。 + */ + private final RemoteSmHotelOrderService remoteSmHotelOrderService; + /** * 支付创建前验证并固定商户平台分佣关系。 */ @@ -413,6 +420,8 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { throw new I18nBizException(ErrorCodes.THIRD_PAY_AMOUNT_GT_ZERO); } mchManage = resolveShopPayMerchant(userId, dto); + } else if (ObjectUtil.equals(dto.getBizType(), ThirdPayOrderBizTypeEnum.HOTEL_ORDER_PAY)) { + mchManage = resolveHotelPayMerchant(userId, dto); } else { throw new I18nBizException(ErrorCodes.COMMON_UNSUPPORTED_PAY_TYPE); } @@ -540,6 +549,39 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { * @param dto 小程序支付请求 * @return 已校验的商城收款商户 */ + /** + * 酒店订单支付:校验订单归属与金额后,按【酒店所属部门】解析收款商户(非下单用户部门)。 + */ + private MchManageVo resolveHotelPayMerchant(Long userId, ThirdMiniAppPayDto dto) { + Long orderId; + try { + orderId = Long.valueOf(dto.getBizId()); + } catch (Exception exception) { + throw new I18nBizException(ErrorCodes.THIRD_SHOP_ORDER_BIZ_ID_INVALID); + } + R payInfoResult = remoteSmHotelOrderService.getPayInfo(orderId); + SmHotelOrderPayInfoVo payInfo = payInfoResult == null || !payInfoResult.isOk() ? null : payInfoResult.getData(); + if (payInfo == null || !Boolean.TRUE.equals(payInfo.getPayable()) + || !ObjectUtil.equals(payInfo.getUserId(), userId) + || payInfo.getHotelDeptId() == null + || payInfo.getAmountFen() == null || payInfo.getAmountFen() <= 0) { + throw new I18nBizException(ErrorCodes.THIRD_SHOP_ORDER_NO_PAY); + } + if (dto.getAmount() != null && ObjectUtil.notEqual(dto.getAmount(), payInfo.getAmountFen())) { + throw new I18nBizException(ErrorCodes.THIRD_SHOP_ORDER_AMOUNT_MISMATCH); + } + // 金额以业务订单为准,禁止客户端伪造 + dto.setAmount(payInfo.getAmountFen()); + // 仅查酒店类型商户,与食堂/商超隔离 + R merchantResult = remoteMchService.getViewsMchByDeptId(payInfo.getHotelDeptId(), + List.of(MerchantTypeEnum.HOTEL)); + MchManageVo merchant = merchantResult == null || !merchantResult.isOk() ? null : merchantResult.getData(); + if (ObjectUtil.isEmpty(merchant)) { + throw new I18nBizException(ErrorCodes.THIRD_USER_NO_MCH); + } + return merchant; + } + private MchManageVo resolveShopPayMerchant(Long userId, ThirdMiniAppPayDto dto) { if (!(dto instanceof ThirdMiniRemotePayDto remotePayDto) || remotePayDto.getMchId() == null) { throw new I18nBizException(ErrorCodes.THIRD_SHOP_PAY_MCH_MISSING); @@ -1665,6 +1707,14 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService { log.error("|APP支付-购买商品(有订单)|处理小程序支付成功回调异常", e); return false; } + } else if (ObjectUtil.equals(thirdPayOrder.getBizType(), ThirdPayOrderBizTypeEnum.HOTEL_ORDER_PAY.getType())) { + try { + R result = remoteSmHotelOrderService.paySuccess(Long.valueOf(thirdPayOrder.getBizId())); + return result != null && result.isOk() && Boolean.TRUE.equals(result.getData()); + } catch (Exception e) { + log.error("|酒店订单支付|处理支付成功回调异常", e); + return false; + } } else { return true; } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java index 1dc46651..db4d4184 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java @@ -131,6 +131,9 @@ class ThirdAppPlusServiceImplOrderCodePayTest { @Mock private RemoteHardOrderService remoteHardOrderService; + @Mock + private com.bsd.pjcloud.supermarket.api.feign.RemoteSmHotelOrderService remoteSmHotelOrderService; + @InjectMocks private ThirdAppPlusServiceImpl service; diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java index 943b812c..c6d628b1 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java @@ -279,23 +279,28 @@ public class MchManageController { @GetMapping("/select") public R getSelect(@ParameterObject Page page, @ParameterObject MchManageDto dto) { DataScope dataScope = null; - PjcloudUser user = SecurityUtils.getUser(); - List typeList = new ArrayList<>(); - if (ObjectUtil.isNotEmpty(dto.getTypes())) { - MerchantTypeEnum targeTypes = MerchantTypeEnum.valueOfByType(dto.getTypes()); - if (ObjectUtil.isNotEmpty(targeTypes)) { - typeList.add(targeTypes); + // 按主键精确回显时不要叠加「当前用户食堂商户」ids,否则酒店等非食堂类型无法回显名称 + if (dto.getId() == null) { + PjcloudUser user = SecurityUtils.getUser(); + List typeList = new ArrayList<>(); + if (ObjectUtil.isNotEmpty(dto.getTypes())) { + MerchantTypeEnum targeTypes = MerchantTypeEnum.valueOfByType(dto.getTypes()); + if (ObjectUtil.isNotEmpty(targeTypes)) { + typeList.add(targeTypes); + } + } + if (typeList.isEmpty()) { + typeList = List.of(MerchantTypeEnum.CANTEEN); + } + List selfMchIdList = mchManageService.getSelfMchIdList(user.getId(), typeList); + if (ObjectUtil.isNotEmpty(selfMchIdList)) { + List ids = dto.getIds(); + if (ObjectUtil.isEmpty(ids)) { + ids = new ArrayList<>(); + } + ids.addAll(selfMchIdList); + dto.setIds(ids); } - } - if (typeList.isEmpty()) { - typeList = List.of(MerchantTypeEnum.CANTEEN); - } - List selfMchIdList = mchManageService.getSelfMchIdList(user.getId(), typeList); - if (ObjectUtil.isNotEmpty(selfMchIdList)) { - List ids = dto.getIds(); - if (ObjectUtil.isEmpty(ids)) ids = new ArrayList<>(); - ids.addAll(selfMchIdList); - dto.setIds(ids); } return R.ok(mchManageService.scopePageList(page, dto, dataScope)); } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java index fa21af85..136667f5 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java @@ -1,7 +1,9 @@ package com.bsd.pjcloud.wallet.controller.mch; import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; +import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.log.annotation.SysLog; @@ -19,6 +21,7 @@ import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -38,16 +41,40 @@ public class MchPaySettingController { /* ************************************* start app start ************************************* */ /** - * 用户可以支付的方式(目前微信、支付宝) + * 可用支付方式: + * 不传 deptId → 登录用户部门下食堂/助农商户; + * 传 deptId + mchTypes → 指定部门下指定类型商户(酒店传 mchTypes=6)。 */ - @Operation(summary = "用户可以支付的方式(目前微信、支付宝)", description = "用户可以支付的方式(目前微信、支付宝)") + @Operation(summary = "用户可以支付的方式(目前微信、支付宝)", + description = "不传 deptId 按登录用户;传 deptId/mchTypes 按指定部门与商户类型") @ApiCryptoAes @GetMapping("/app/pay-type-list") - public R> getUserPaySettingList() { + public R> getUserPaySettingList( + @RequestParam(required = false) Long deptId, + @RequestParam(required = false) List mchTypes) { + if (deptId != null) { + List typeEnums = resolveMchTypes(mchTypes); + return R.ok(mchPaySettingService.getPaySettingListByDeptId(deptId, typeEnums)); + } PjcloudUser user = SecurityUtils.getUser(); return R.ok(mchPaySettingService.getUserPaySettingList(user)); } + /** 将前端传入的 types 编码转为枚举;空则由 Service 默认食堂+助农 */ + private List resolveMchTypes(List mchTypes) { + if (ObjectUtil.isEmpty(mchTypes)) { + return List.of(); + } + List result = new ArrayList<>(); + for (String type : mchTypes) { + MerchantTypeEnum e = MerchantTypeEnum.valueOfByType(type); + if (e != null) { + result.add(e); + } + } + return result; + } + /* ************************************* end app end ************************************* */ /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java index b8339780..9cfbe0ec 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java @@ -3,6 +3,7 @@ package com.bsd.pjcloud.wallet.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.bsd.pjcloud.common.core.constant.CacheConstants; +import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto; @@ -86,4 +87,14 @@ public interface MchPaySettingService extends IService { * @return 已脱敏的可用支付配置 */ List getUserPaySettingList(PjcloudUser user); + + /** + * 按指定部门 + 商户类型解析所属商户后返回可用支付方式。 + * + * @param deptId 业务侧指定的部门主键 + * @param mchTypeList 商户类型;空则默认食堂+助农(兼容旧调用) + * @return 已脱敏的可用支付配置;部门无商户或未配置时返回空列表 + */ + List getPaySettingListByDeptId(Long deptId, List mchTypeList); } + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java index dcdbf679..92ca5227 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java @@ -149,7 +149,23 @@ public class MchPaySettingServiceImpl extends ServiceImpl getPaySettingListByDeptId(Long deptId, List mchTypeList) { + if (deptId == null) { + return List.of(); + } + List types = ObjectUtil.isEmpty(mchTypeList) + ? List.of(MerchantTypeEnum.CANTEEN, MerchantTypeEnum.FARMER) + : mchTypeList; + MchManageVo targetMch = mchManageService.getViewsCanteenMch(deptId, types); if (ObjectUtil.isEmpty(targetMch) || ObjectUtil.isEmpty(targetMch.getPaySettingList())) { return List.of(); }