From 98bbc12de69939e5ec52cf2f4ca0a4f47ed9719b Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 22 Jul 2026 18:56:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BB=A3=E8=AE=A2=E9=A4=90=E4=B8=8B?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feign/RemoteOnlineCanteenSpuService.java | 6 + .../dto/CanteenSpuUpdateStockReqDTO.java | 17 + pjcloud-canteen/pjcloud-canteen-biz/pom.xml | 5 + .../CanteenOnlineSpuController.java | 11 + .../mapper/CanteenOnlineSpuMapper.java | 6 +- .../service/CanteenOnlineSpuService.java | 8 +- .../impl/CanteenOnlineSpuServiceImpl.java | 81 +- .../hard/api/dto/order/HardOrderDto.java | 6 + .../app/HardAppCreateReserveOrdersDto.java | 37 + .../hard/api/vo/order/HardOrderVo.java | 20 + .../app/HardAppCreateReserveOrdersVo.java | 18 + .../vo/order/app/HardAppScanPayOrderVo.java | 36 + .../order/HardAfterSaleController.java | 6 + .../controller/order/HardCarController.java | 7 +- .../controller/order/HardOrderController.java | 114 ++- .../hard/mapper/order/HardOrderMapper.java | 7 +- .../hard/service/HardAfterSaleService.java | 17 +- .../hard/service/HardOrderService.java | 60 +- .../impl/HardBroadcastServiceImpl.java | 4 +- .../impl/order/HardAfterSaleServiceImpl.java | 68 +- .../impl/order/HardOrderServiceImpl.java | 762 ++++++++++++++++-- .../mapper/order/HardOrderMapper.xml | 25 +- .../api/feign/RemoteUserRelationService.java | 27 + .../vo/user/SysUserGuardianWardOptionVo.java | 34 + ...mework.cloud.openfeign.FeignClient.imports | 1 + .../controller/SysUserFaceController.java | 5 +- .../user/SysUserRelationController.java | 30 + .../admin/service/SysUserFaceService.java | 13 - .../admin/service/SysUserRelationService.java | 9 + .../service/impl/SysUserFaceServiceImpl.java | 29 +- .../impl/user/SysUserRelationServiceImpl.java | 42 +- .../feign/RemoteWalletTransactionService.java | 14 + .../WalletTransactionController.java | 18 + .../wallet/mapper/WalletManageMapper.java | 11 +- .../service/WalletTransactionService.java | 12 +- .../impl/WalletTransactionServiceImpl.java | 85 +- 36 files changed, 1513 insertions(+), 138 deletions(-) create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppCreateReserveOrdersVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppScanPayOrderVo.java create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserRelationService.java create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteOnlineCanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteOnlineCanteenSpuService.java index 8a6707436..a61e83de9 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteOnlineCanteenSpuService.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteOnlineCanteenSpuService.java @@ -78,6 +78,12 @@ public interface RemoteOnlineCanteenSpuService { @PostMapping("/canteenOnlineSpu/app/updateSkuStock") R updateSkuStock(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO); + /** + * 使用业务号幂等扣减或补偿在线菜品库存。 + */ + @PostMapping("/canteenOnlineSpu/app/updateSkuStockIdempotent") + R updateSkuStockIdempotent(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO); + /** * 根据ids查询菜品 diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java index 328dafaf9..ca6829741 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.canteen.dto; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -18,6 +19,17 @@ import java.util.List; @AllArgsConstructor public class CanteenSpuUpdateStockReqDTO { + /** + * 可选业务幂等号;旧调用方不传时保持原有库存接口行为。 + */ + private String bizId; + + /** + * DEDUCT 扣减,ROLLBACK 恢复;仅在 bizId 非空时参与幂等控制。 + */ + @Pattern(regexp = "DEDUCT|ROLLBACK", message = "库存操作类型错误") + private String operation; + /** * 商品 SKU */ @@ -42,6 +54,11 @@ public class CanteenSpuUpdateStockReqDTO { @NotNull(message = "库存变化数量不能为空") private Integer incrCount; + /** + * 本次业务开始前的库存快照,仅幂等库存接口使用。 + */ + private Integer beforeStock; + } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/pom.xml b/pjcloud-canteen/pjcloud-canteen-biz/pom.xml index 4e8f3fdc5..f49d4366d 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/pom.xml +++ b/pjcloud-canteen/pjcloud-canteen-biz/pom.xml @@ -17,6 +17,11 @@ com.bsd pjcloud-common-encrypt-api + + com.baomidou + lock4j-redis-template-spring-boot-starter + 2.2.7 + org.springframework.retry diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenOnlineSpuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenOnlineSpuController.java index 4c9ff7f37..036a02fc2 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenOnlineSpuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenOnlineSpuController.java @@ -1,4 +1,5 @@ package com.bsd.pjcloud.canteen.controller; +import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO; import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; import cn.hutool.core.util.StrUtil; @@ -383,6 +384,16 @@ public class CanteenOnlineSpuController { return canteenOnlineSpuService.updateOnlineSpuSkuStock(canteenOnlineSpuUpdateStockReqDTO); } + /** + * 多人订餐库存接口使用业务号串行执行扣减和补偿,解决调用超时后的未知结果。 + */ + @ApiCryptoAes + @PostMapping("/app/updateSkuStockIdempotent") + @Transactional + public R updateSkuStockIdempotent(@RequestBody CanteenSpuUpdateStockReqDTO dto) { + return canteenOnlineSpuService.updateOnlineSpuSkuStockIdempotent(dto); + } + /** * 商家端手工增减库存(只更新 stock,不修改销量);仅允许操作当前登录单位对应食堂商户下的 SPU。 */ diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenOnlineSpuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenOnlineSpuMapper.java index 6257d0e3a..98fd53700 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenOnlineSpuMapper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenOnlineSpuMapper.java @@ -29,7 +29,9 @@ public interface CanteenOnlineSpuMapper extends PjcloudBaseMapper updateWrapper = new LambdaUpdateWrapper() .setSql(sql) - .eq(CanteenOnlineSpuEntity::getId, id); + .eq(CanteenOnlineSpuEntity::getId, id) + // 扣减时由 SQL 再做一次余额式校验,防止并发下单把库存扣成负数。 + .ge(incrCount < 0, CanteenOnlineSpuEntity::getStock, Math.abs(incrCount)); return update(null, updateWrapper); } @@ -50,4 +52,4 @@ public interface CanteenOnlineSpuMapper extends PjcloudBaseMapper wrapper); -} \ No newline at end of file +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java index 1f82c7fb0..67f55ed0a 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java @@ -1,11 +1,13 @@ package com.bsd.pjcloud.canteen.service.impl; +import com.baomidou.lock.annotation.Lock4j; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.canteen.dto.CanteenOnlineSpuShelfReqDTO; import com.bsd.pjcloud.canteen.dto.CanteenOnlineSpuUpdateStockReqDTO; +import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO; import com.bsd.pjcloud.canteen.dto.canteenonline.CanteenOnlineSpuSaveDto; import com.bsd.pjcloud.canteen.dto.canteenonline.CanteenOnlineSpuSaveItemDto; import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity; @@ -30,6 +32,7 @@ import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.util.SecurityUtils; import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -54,6 +57,10 @@ import cn.hutool.core.util.StrUtil; @AllArgsConstructor public class CanteenOnlineSpuServiceImpl extends ServiceImpl implements CanteenOnlineSpuService { + private static final String ORDER_STOCK_BIZ_KEY_PREFIX = "canteen:online-spu:order-stock:"; + + private final StringRedisTemplate stringRedisTemplate; + private final CanteenCategoryMapper categoryMapper; private final CanteenOnlineCategoryMapper canteenOnlineCategoryMapper; @@ -198,8 +205,9 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl spu.getStock()) { return R.failed(spu.getName() + "库存不足,无法完成操作"); } - baseMapper.updateStock(v.getId(), v.getIncrCount()); } + // 下单等场景:库存与销量联动(incrCount<0 视为售出)。并发条件更新失败时抛异常回滚整批。 + for (CanteenOnlineSpuUpdateStockReqDTO.Item v : canteenOnlineSpuUpdateStockReqDTO.getItems()) { + if (baseMapper.updateStock(v.getId(), v.getIncrCount()) == 0) { + throw new RuntimeException("库存不足,无法完成操作"); + } + } + return R.ok(); + } + + /** + * 同一业务号的扣减和补偿在食堂服务内串行执行;补偿通过当前库存与原库存快照判断扣减是否已提交。 + */ + @Override + @Lock4j(keys = {"#dto.bizId"}) + @Transactional(rollbackFor = Exception.class) + public R updateOnlineSpuSkuStockIdempotent(CanteenSpuUpdateStockReqDTO dto) { + String operation = StrUtil.trim(dto.getOperation()); + boolean deductOperation = "DEDUCT".equals(operation); + boolean rollbackOperation = "ROLLBACK".equals(operation); + if (StrUtil.isBlank(dto.getBizId()) || CollUtil.isEmpty(dto.getItems()) + || (!deductOperation && !rollbackOperation) + || dto.getItems().stream().anyMatch(item -> item.getBeforeStock() == null)) { + return R.failed("库存业务参数错误"); + } + String stateKey = ORDER_STOCK_BIZ_KEY_PREFIX + dto.getBizId(); + String state = stringRedisTemplate.opsForValue().get(stateKey); + if (deductOperation && ("DEDUCTED".equals(state) || "ROLLED_BACK".equals(state))) { + return R.ok(); + } + if (rollbackOperation && "ROLLED_BACK".equals(state)) { + return R.ok(); + } + for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) { + CanteenOnlineSpuEntity spu = baseMapper.selectById(item.getId()); + if (spu == null) { + return R.failed("商品不存在"); + } + int expectedStock = item.getBeforeStock(); + int targetStock = Math.addExact(expectedStock, item.getIncrCount()); + if (deductOperation) { + if (spu.getStock() == targetStock) { + continue; + } + if (spu.getStock() != expectedStock) { + return R.failed(spu.getName() + "库存已变化,请重新下单"); + } + } else { + if (spu.getStock() == expectedStock) { + continue; + } + if (spu.getStock() != targetStock) { + return R.failed(spu.getName() + "库存状态异常,无法自动补偿"); + } + } + } + for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) { + CanteenOnlineSpuEntity spu = baseMapper.selectById(item.getId()); + int expectedStock = item.getBeforeStock(); + int targetStock = Math.addExact(expectedStock, item.getIncrCount()); + int delta = deductOperation ? item.getIncrCount() : -item.getIncrCount(); + boolean needUpdate = deductOperation + ? spu.getStock() == expectedStock + : spu.getStock() == targetStock; + if (needUpdate && baseMapper.updateStock(item.getId(), delta) == 0) { + throw new RuntimeException("库存更新失败,请重试"); + } + } + stringRedisTemplate.opsForValue().set(stateKey, deductOperation ? "DEDUCTED" : "ROLLED_BACK"); return R.ok(); } @@ -369,4 +444,4 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl appWardUserIds; + /** * `apptId`不为null */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java new file mode 100644 index 000000000..27b7d0666 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java @@ -0,0 +1,37 @@ +package com.bsd.pjcloud.hard.api.dto.order.app; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +/** + * App 多人食堂订餐请求。 + */ +@Data +public class HardAppCreateReserveOrdersDto { + + /** + * 原始购物车父ID,必须属于当前付款人。 + */ + @NotNull(message = "购物车不能为空") + private Long carParentId; + + /** + * 实际用餐人,只允许当前用户本人和其有效被监护人。 + */ + @NotEmpty(message = "用餐人不能为空") + private List dinerUserIds; + + private Long apptId; + + private Long apptRuleId; + + private String hourType; + + private String apptTimeType; + + private LocalDate apptDate; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java index 49500dd64..95cf42a03 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java @@ -82,4 +82,24 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac private LocalTime apptStartTime; private LocalTime apptEndTime; + + /** + * 付款人用户ID,来源于订单关联的父购物车。 + */ + private Long payerUserId; + + /** + * 是否由他人代订。 + */ + private Boolean isProxyOrder; + + /** + * 当前访问者是否可以申请退款。 + */ + private Boolean canRefund; + + /** + * 当前访问者是否可以查看预约核销码。 + */ + private Boolean canViewReserveCode; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppCreateReserveOrdersVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppCreateReserveOrdersVo.java new file mode 100644 index 000000000..389496d42 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppCreateReserveOrdersVo.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.hard.api.vo.order.app; + +import lombok.Data; + +import java.util.List; + +/** + * App 多人食堂订餐结果。 + */ +@Data +public class HardAppCreateReserveOrdersVo { + + private List orderIds; + + private Integer orderCount; + + private Integer totalPayPrice; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppScanPayOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppScanPayOrderVo.java new file mode 100644 index 000000000..40e6cdec8 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/app/HardAppScanPayOrderVo.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.hard.api.vo.order.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * App 设备扫码支付所需的最小订单信息。 + */ +@Data +@Schema(description = "App 设备扫码支付订单信息") +public class HardAppScanPayOrderVo { + + @Schema(description = "订单ID") + private Long id; + + @Schema(description = "商户ID") + private Long merchantId; + + @Schema(description = "商户名称") + private String merchantName; + + @Schema(description = "商品总价,单位分") + private Integer totalPrice; + + @Schema(description = "额外费用,单位分") + private Integer extraPrice; + + @Schema(description = "实付金额,单位分") + private Integer payPrice; + + @Schema(description = "支付方式") + private String payType; + + @Schema(description = "订单状态") + private String orderStatus; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java index 7766444be..63d882bc2 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java @@ -56,6 +56,7 @@ public class HardAfterSaleController { @Idempotent(expireTime = 10, info = "正在申请中,请勿重复操作") public R appCreate(@RequestBody HardAfterSaleDto dto) { PjcloudUser user = SecurityUtils.getUser(); + hardAfterSaleService.validateAppOrderRefund(dto.getOrderId(), user.getId()); dto.setUserId(user.getId()); return R.ok(hardAfterSaleService.create(dto)); } @@ -66,6 +67,7 @@ public class HardAfterSaleController { @Idempotent(expireTime = 10, info = "正在取消中,请勿重复操作") public R appCancel(@RequestParam Long id) { PjcloudUser user = SecurityUtils.getUser(); + hardAfterSaleService.validateAppAfterSaleRefund(id, user.getId()); return R.ok(hardAfterSaleService.cancelAfterSale(user.getId(), id)); } @@ -73,6 +75,7 @@ public class HardAfterSaleController { @GetMapping(value = "/app/history") @Operation(summary = "app查询售后进度") public R> appHistory(@RequestParam Long id) { + hardAfterSaleService.validateAppAfterSaleAccess(id, SecurityUtils.getUser().getId()); return R.ok(hardAfterSaleService.getHistory(id)); } @@ -80,6 +83,7 @@ public class HardAfterSaleController { @GetMapping(value = "/app/detail-by-order-id") @Operation(summary = "app查询退款详情") public R appDetail(@RequestParam Long id) { + hardAfterSaleService.validateAppOrderView(id, SecurityUtils.getUser().getId()); HardAfterSaleDto dto = new HardAfterSaleDto(); dto.setOrderId(id); return R.ok(hardAfterSaleService.join(dto)); @@ -91,6 +95,7 @@ public class HardAfterSaleController { @Idempotent(expireTime = 5, info = "正在处理中,请勿重复操作") public R appMchRefund(@RequestParam Long id) { PjcloudUser user = SecurityUtils.getUser(); + hardAfterSaleService.validateAppMerchantAfterSaleAccess(id, user.getId()); return R.ok(hardAfterSaleService.agreeAfterSale(user.getId(), id)); } @@ -100,6 +105,7 @@ public class HardAfterSaleController { @Idempotent(expireTime = 5, info = "正在处理中,请勿重复操作") public R appMchDisagreeRefund(@RequestBody HardDisagreeDto dto) { PjcloudUser user = SecurityUtils.getUser(); + hardAfterSaleService.validateAppMerchantAfterSaleAccess(dto.getId(), user.getId()); return R.ok(hardAfterSaleService.disagreeRefund(user.getId(), dto)); } /* ************************************* end app end ************************************* */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java index ffe3f83d0..809398be7 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java @@ -59,7 +59,11 @@ public class HardCarController { @GetMapping("/app/parent-car") @Operation(summary = "查询父亲购物车") public R appGetParentCar(@RequestParam Long id) { - return R.ok(hardParentCarService.getById(id)); + HardParentCarEntity parentCar = hardParentCarService.getById(id); + if (parentCar == null || !ObjectUtil.equals(parentCar.getUserId(), SecurityUtils.getUser().getId())) { + throw new RuntimeException("购物车不存在或无权查看"); + } + return R.ok(parentCar); } @ApiCryptoAes @@ -74,6 +78,7 @@ public class HardCarController { @GetMapping("/app/order-id") @Operation(summary = "根据订单id查询购物车") public R> appOrderId(@RequestParam Long orderId) { + hardOrderService.validateAppOrderView(orderId, SecurityUtils.getUser().getId()); return R.ok(hardCarService.getByOrderId(orderId)); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index 151c5003b..c840084b2 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -40,12 +40,15 @@ import com.bsd.pjcloud.hard.api.dto.order.HardShipmentDto; import com.bsd.pjcloud.hard.api.dto.order.act.HardCreateActOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto; +import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateReserveOrdersDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateShopOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateTakeOutOrderDto; import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto; import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateTakeOutStatusDto; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo; +import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardTakeOutDeliveryTimeOptionVO; import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExcelVo; @@ -139,18 +142,22 @@ public class HardOrderController { /* ************************************* start app start ************************************* */ @Operation(summary = "app查询当前用户的预约订单", description = "app查询当前用户的预约订单") @GetMapping("/app/user-appt-list") - public R> userApptList() { + public R> userApptList(@RequestParam(required = false) List dinerUserIds) { HardOrderDto dto = new HardOrderDto(); LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai")); PjcloudUser user = SecurityUtils.getUser(); - dto.setUserId(user.getId()); + dto.setUserIds(ObjectUtil.isEmpty(dinerUserIds) + ? List.of(user.getId()) + : hardwareOrderService.validateReserveDinerUserIds(user.getId(), dinerUserIds)); dto.setIsFindCarList(true); dto.setIsFindApptList(true); dto.setApptUseDateStart(today.atStartOfDay()); dto.setOrderType(HardOrderTypeEnum.RESERVE.getType()); dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType())); - return R.ok(hardwareOrderService.scopeList(dto, null)); + List orders = hardwareOrderService.scopeList(dto, null); + hardwareOrderService.applyAppPermissions(orders, user.getId()); + return R.ok(orders); } @@ -159,7 +166,7 @@ public class HardOrderController { @GetMapping("/app/page-join") public R appPageJoin(@ParameterObject Page page, @ParameterObject HardOrderDto dto) { PjcloudUser user = SecurityUtils.getUser(); - dto.setUserId(user.getId()); + hardwareOrderService.configureAppVisibility(dto, user.getId()); dto.setIsFindCarList(true); dto.setIsFindApptList(true); dto.setMchTypeCodes( @@ -168,7 +175,9 @@ public class HardOrderController { MerchantTypeEnum.FARMER.getType() ) ); - return R.ok(hardwareOrderService.pagejoin(page, dto)); + Page result = hardwareOrderService.scopePageList(page, dto, null); + hardwareOrderService.applyAppPermissions(result.getRecords(), user.getId()); + return R.ok(result); } @Operation(summary = "app查询超市订单列表", description = "app查询订单列表") @@ -220,8 +229,15 @@ public class HardOrderController { MchManageDto mchManageDto = new MchManageDto(); mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); List mchList = mchService.scopeList(mchManageDto).getData(); - if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) { - dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); + if (ObjectUtil.isEmpty(mchList)) { + return R.ok(new Page()); + } + List allowedMchIds = mchList.stream().map(MchManageVo::getId).toList(); + dto.setMchIds(ObjectUtil.isEmpty(dto.getMchIds()) + ? allowedMchIds + : dto.getMchIds().stream().filter(allowedMchIds::contains).toList()); + if (ObjectUtil.isEmpty(dto.getMchIds())) { + return R.ok(new Page()); } return R.ok(hardwareOrderService.scopePageList(page, dto, dataScope)); @@ -232,11 +248,13 @@ public class HardOrderController { @GetMapping("/app/caneen-page-join") public R appCaneenPageJoin(@ParameterObject Page page, @ParameterObject HardOrderDto dto) { PjcloudUser user = SecurityUtils.getUser(); - dto.setUserId(user.getId()); + hardwareOrderService.configureAppVisibility(dto, user.getId()); dto.setIsFindCarList(true); dto.setIsFindApptList(true); dto.setMchTypeCodes(List.of(MerchantTypeEnum.CANTEEN.getType())); - return R.ok(hardwareOrderService.pagejoin(page, dto)); + Page result = hardwareOrderService.scopePageList(page, dto, null); + hardwareOrderService.applyAppPermissions(result.getRecords(), user.getId()); + return R.ok(result); } @Operation(summary = "app查询共富商城列表", description = "app查询共富商城列表") @@ -277,7 +295,41 @@ public class HardOrderController { @ApiCryptoAes @GetMapping("/app/detail") public R appDetail(@RequestParam Long id) { - return R.ok(hardwareOrderService.join(id)); + return R.ok(hardwareOrderService.appJoin(id, SecurityUtils.getUser().getId())); + } + + /** + * 设备二维码本身承担待支付订单的访问凭证,因此只返回完成支付所需字段; + * 支付后的订单仍会校验为当前付款用户,避免该接口成为通用订单详情旁路。 + */ + @Operation(summary = "app查询设备扫码支付订单", description = "仅返回设备扫码支付所需的最小订单信息") + @ApiCryptoAes + @GetMapping("/app/scan-pay-detail") + public R appScanPayDetail(@RequestParam Long id, @RequestParam Long merchantId) { + return R.ok(hardwareOrderService.appScanPayDetail(id, merchantId, SecurityUtils.getUser().getId())); + } + + /** + * 商户核销端按当前用户的数据范围读取预约订单,且始终不返回用户核销码。 + */ + @Operation(summary = "app商户查询食堂预约订单详情", description = "仅用于商户手动核销确认") + @ApiCryptoAes + @GetMapping("/app/mch-reserve-detail") + public R appMerchantReserveDetail(@RequestParam Long id) { + return R.ok(hardwareOrderService.appMerchantReserveJoin(id, SecurityUtils.getUser().getId())); + } + + /** + * 当前用户使用自己的餐饮钱包,为本人和有效被监护人分别创建独立预约订单。 + */ + @Operation(summary = "app批量创建食堂预定订单", description = "每位用餐人创建一笔独立订单,统一从当前用户钱包扣款") + @ApiCryptoAes + @PostMapping("/app/create-reserve-orders") + @Idempotent(expireTime = 30, + key = "'/app/hard-order/app/create-reserve-orders:' + #body.getCarParentId() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()", + info = "正在创建订单中,请勿重复操作") + public R appCreateReserveOrders(@Validated @RequestBody HardAppCreateReserveOrdersDto body) { + return R.ok(hardwareOrderService.appCreateReserveOrders(SecurityUtils.getUser().getId(), body)); } @Operation(summary = "app创建食堂预定订单", description = "app创建食堂预定订单") @@ -292,22 +344,32 @@ public class HardOrderController { @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate apptDate, @RequestParam HardTypeEnum hardType) { PjcloudUser user = SecurityUtils.getUser(); - HardAppCreateOrderDto dto = HardAppCreateOrderDto.builder() - .carParentId(carParentId) - .walletType(WalletTypeEnum.EAT) - .hardType(hardType) - .userId(user.getId()) - .orderType(HardOrderTypeEnum.RESERVE) - .payType(HardPayTypeEnum.WALLET_DEFAULT) - .apptId(apptId) - .apptRuleId(apptRuleId) - .hourType(hourType) - .apptDate(apptDate) - .build(); - if (ObjectUtil.isNotEmpty(apptTimeType)) { - dto.setApptTimeType(apptTimeType); + // 旧客户端曾通过该接口创建超市预约订单,只有食堂订餐才切换到多人批量逻辑。 + if (!ObjectUtil.equals(hardType, HardTypeEnum.CANTEEN)) { + HardAppCreateOrderDto legacyDto = HardAppCreateOrderDto.builder() + .carParentId(carParentId) + .walletType(WalletTypeEnum.EAT) + .hardType(hardType) + .userId(user.getId()) + .orderType(HardOrderTypeEnum.RESERVE) + .payType(HardPayTypeEnum.WALLET_DEFAULT) + .apptId(apptId) + .apptRuleId(apptRuleId) + .hourType(hourType) + .apptDate(apptDate) + .build(); + legacyDto.setApptTimeType(apptTimeType); + return R.ok(hardwareOrderService.appCreateOrder(legacyDto)); } - return R.ok(hardwareOrderService.appCreateOrder(dto)); + HardAppCreateReserveOrdersDto batchDto = new HardAppCreateReserveOrdersDto(); + batchDto.setCarParentId(carParentId); + batchDto.setDinerUserIds(List.of(user.getId())); + batchDto.setApptId(apptId); + batchDto.setApptRuleId(apptRuleId); + batchDto.setHourType(hourType); + batchDto.setApptTimeType(apptTimeType); + batchDto.setApptDate(apptDate); + return R.ok(hardwareOrderService.appCreateReserveOrders(user.getId(), batchDto).getOrderIds().get(0)); } @Operation(summary = "外卖-查询可预约配送时段(支持连续多天)", @@ -421,6 +483,8 @@ public class HardOrderController { @Idempotent(expireTime = 1, key = "'/hard-order/app/rserve-cancel-order' + #orderId ", info = "正在核销中,请勿重复操作") public R appRserveCancelOrder(@PathVariable Long orderId) { PjcloudUser user = SecurityUtils.getUser(); + // 该接口是商户手动核销入口,不等同于用餐人读取或使用自己的核销码。 + hardwareOrderService.validateAppMerchantReserveAccess(orderId, user.getId()); HandCancelCodeDto body = new HandCancelCodeDto(); body.setUserId(user.getId()); body.setOrderId(orderId); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java index e68d985cd..26d102f66 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java @@ -13,6 +13,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderSumAndCountDto; import com.bsd.pjcloud.hard.api.dto.panel.PanelProductDto; import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardParentCarEntity; import com.bsd.pjcloud.hard.api.vo.hard.PanelPorductVo; import com.bsd.pjcloud.hard.api.vo.order.*; import com.github.yulichang.wrapper.MPJLambdaWrapper; @@ -60,7 +61,9 @@ public interface HardOrderMapper extends PjcloudBaseMapper { .selectAll(HardOrderEntity.class) .selectAs(HardManagerEntity::getName, HardOrderVo::getHardName) .selectAs(HardManagerEntity::getType, HardOrderVo::getHardType) + .selectAs(HardParentCarEntity::getUserId, HardOrderVo::getPayerUserId) .leftJoin(HardManagerEntity.class, HardManagerEntity::getId, HardOrderEntity::getHardId) + .leftJoin(HardParentCarEntity.class, HardParentCarEntity::getId, HardOrderEntity::getParentCarId) .eqIfExists(HardOrderEntity::getMerchantId, dto.getMerchantId()) .eqIfExists(HardOrderEntity::getHardId, dto.getHardId()) .eqIfExists(HardOrderEntity::getUserId, dto.getUserId()) @@ -95,7 +98,9 @@ public interface HardOrderMapper extends PjcloudBaseMapper { .selectAll(HardOrderEntity.class) .selectAs(HardManagerEntity::getName, HardOrderVo::getHardName) .selectAs(HardManagerEntity::getType, HardOrderVo::getHardType) + .selectAs(HardParentCarEntity::getUserId, HardOrderVo::getPayerUserId) .leftJoin(HardManagerEntity.class, HardManagerEntity::getId, HardOrderEntity::getHardId) + .leftJoin(HardParentCarEntity.class, HardParentCarEntity::getId, HardOrderEntity::getParentCarId) .eqIfExists(HardOrderEntity::getId, dto.getId()); return selectJoinOne(HardOrderVo.class, wrapper); } @@ -183,4 +188,4 @@ public interface HardOrderMapper extends PjcloudBaseMapper { List shopGroupByTimeAndHourType(@Param("dto") PanelProductDto dto); List shopGroupByProduct(@Param("dto") PanelProductDto dto); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java index c385c1551..5a5cf740a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java @@ -59,6 +59,21 @@ public interface HardAfterSaleService extends IService { */ HardOrderVo validateOrderCanRefund(Long orderId); + /** 校验 App 用户是否可以查看订单对应的售后信息。 */ + void validateAppOrderView(Long orderId, Long viewerUserId); + + /** 校验 App 用户是否为订单付款人。 */ + void validateAppOrderRefund(Long orderId, Long viewerUserId); + + /** 校验 App 用户是否可以访问售后单。 */ + void validateAppAfterSaleAccess(Long afterSaleId, Long viewerUserId); + + /** 校验 App 用户是否可以管理售后单。 */ + void validateAppAfterSaleRefund(Long afterSaleId, Long viewerUserId); + + /** 校验 App 商户是否可以审核食堂预约订单的售后单。 */ + void validateAppMerchantAfterSaleAccess(Long afterSaleId, Long viewerUserId); + /** * 后台退款 */ @@ -78,4 +93,4 @@ public interface HardAfterSaleService extends IService { * 涉笔退款 */ Boolean adRefund(Long orderId, String reason); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java index 06fa9bc95..95b58cc1f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java @@ -20,6 +20,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardShipmentDto; import com.bsd.pjcloud.hard.api.dto.order.act.HardCreateActOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto; +import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateReserveOrdersDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateShopOrderDto; import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto; import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateTakeOutStatusDto; @@ -29,6 +30,8 @@ import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo; +import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo; import com.bsd.pjcloud.thirdparty.dto.ks.KsRecognitionCallbackDto; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import jakarta.validation.Valid; @@ -55,6 +58,51 @@ public interface HardOrderService extends IService { HardOrderVo join(Long id); + /** + * 查询并校验 App 用户可查看的订单详情。 + */ + HardOrderVo appJoin(Long id, Long viewerUserId); + + /** + * 查询设备扫码支付所需的最小订单信息。 + */ + HardAppScanPayOrderVo appScanPayDetail(Long id, Long merchantId, Long viewerUserId); + + /** + * 查询并校验移动端商户可核销的食堂预约订单详情。 + */ + HardOrderVo appMerchantReserveJoin(Long id, Long viewerUserId); + + /** + * 为 App 订单列表填充代订及操作权限。 + */ + void applyAppPermissions(List orders, Long viewerUserId); + + /** + * 配置只扩展食堂预约订单的 App 可见范围。 + */ + void configureAppVisibility(HardOrderDto dto, Long viewerUserId); + + /** + * 校验当前用户是否可以查看订单。 + */ + void validateAppOrderView(Long orderId, Long viewerUserId); + + /** + * 校验当前用户是否为订单付款人并允许退款。 + */ + void validateAppOrderRefund(Long orderId, Long viewerUserId); + + /** + * 校验当前用户是否为订单实际用餐人。 + */ + void validateAppReserveCodeAccess(Long orderId, Long viewerUserId); + + /** + * 校验当前移动端商户是否可以管理指定食堂预约订单。 + */ + void validateAppMerchantReserveAccess(Long orderId, Long viewerUserId); + /** * 创建订单 */ @@ -75,6 +123,16 @@ public interface HardOrderService extends IService { */ Long appCreateOrder(@Valid HardAppCreateOrderDto dto); + /** + * 当前付款人为本人及有效被监护人批量创建独立食堂预约订单。 + */ + HardAppCreateReserveOrdersVo appCreateReserveOrders(Long payerUserId, @Valid HardAppCreateReserveOrdersDto dto); + + /** + * 校验并规范化当前付款人提交的用餐人。 + */ + List validateReserveDinerUserIds(Long payerUserId, List dinerUserIds); + /** * app创建食堂外卖订单 */ @@ -198,4 +256,4 @@ public interface HardOrderService extends IService { * 确认收货 */ Boolean confirmReceipt(Long userId, Long orderId); -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java index 1c1008282..f7cfbfe0a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.hard.service.impl; import cn.hutool.core.util.ObjectUtil; +import com.baomidou.lock.annotation.Lock4j; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; @@ -67,6 +68,7 @@ public class HardBroadcastServiceImpl extends ServiceImpl mchResult = remoteMchService.getById(order.getMerchantId()); + if (mchResult != null && mchResult.isOk() && mchResult.getData() != null) { + detail.setMerchantName(mchResult.getData().getName()); + } + return detail; + } + + /** + * 商户详情沿用商户数据范围鉴权,但不向商户返回实际用餐人的核销码。 + */ + @Override + public HardOrderVo appMerchantReserveJoin(Long id, Long viewerUserId) { + validateAppMerchantReserveAccess(id, viewerUserId); + HardOrderVo detail = getSelf().join(id); + if (detail == null) { + return null; + } + Long payerUserId = resolvePayerUserId(detail); + detail.setPayerUserId(payerUserId); + detail.setIsProxyOrder(payerUserId != null && !Objects.equals(payerUserId, detail.getUserId())); + detail.setCanRefund(false); + detail.setCanViewReserveCode(false); + detail.setReserveCode(null); + return detail; + } + + /** + * 只有食堂预约订单扩展到付款人和当前有效被监护人,其他订单继续只按本人查询。 + * + * @param dto 订单查询参数 + * @param viewerUserId 当前访问者用户ID + */ + @Override + public void configureAppVisibility(HardOrderDto dto, Long viewerUserId) { + dto.setUserId(null); + dto.setAppViewerUserId(viewerUserId); + dto.setAppWardUserIds(listActiveWardUserIds(viewerUserId, false)); + } + + /** + * 根据父购物车付款人和实际用餐人生成 App 操作权限。 + * + * @param orders 订单列表 + * @param viewerUserId 当前访问者用户ID + */ + @Override + public void applyAppPermissions(List orders, Long viewerUserId) { + if (ObjectUtil.isEmpty(orders)) { + return; + } + for (HardOrderVo order : orders) { + Long payerUserId = resolvePayerUserId(order); + boolean isDiner = Objects.equals(order.getUserId(), viewerUserId); + boolean isPayer = Objects.equals(payerUserId, viewerUserId); + order.setPayerUserId(payerUserId); + boolean isCanteenReserve = isCanteenReserveOrder(order); + order.setIsProxyOrder(isCanteenReserve && payerUserId != null + && !Objects.equals(payerUserId, order.getUserId())); + // These flags are meaningful only for the newly supported canteen reservation flow. + // Preserve the old App behavior for supermarket, farmer, and delivery orders. + order.setCanRefund(isCanteenReserve ? isPayer : null); + order.setCanViewReserveCode(isCanteenReserve ? isDiner : null); + if (!isDiner) { + order.setReserveCode(null); + } + } + } + + /** + * 校验 App 订单读取权限。付款人始终可查看历史代订订单;监护关系只扩展当前食堂预约订单。 + * + * @param orderId 订单ID + * @param viewerUserId 当前访问者用户ID + */ + @Override + public void validateAppOrderView(Long orderId, Long viewerUserId) { + HardOrderEntity order = baseMapper.selectById(orderId); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + if (Objects.equals(order.getUserId(), viewerUserId) + || Objects.equals(resolvePayerUserId(order), viewerUserId)) { + return; + } + if (isCanteenReserveOrder(order) + && listActiveWardUserIds(viewerUserId, false).contains(order.getUserId())) { + return; + } + throw new RuntimeException("无权查看该订单"); + } + + /** + * 退款只允许订单记录的钱包付款人发起,避免被监护人把款项退回非本人钱包。 + * + * @param orderId 订单ID + * @param viewerUserId 当前访问者用户ID + */ + @Override + public void validateAppOrderRefund(Long orderId, Long viewerUserId) { + HardOrderEntity order = baseMapper.selectById(orderId); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + if (!Objects.equals(resolvePayerUserId(order), viewerUserId)) { + throw new RuntimeException("仅订单付款人可以申请退款"); + } + } + + /** + * 核销码只属于订单记录的实际用餐人,付款监护人不能代为获取或核销。 + */ + @Override + public void validateAppReserveCodeAccess(Long orderId, Long viewerUserId) { + HardOrderEntity order = baseMapper.selectById(orderId); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + if (!Objects.equals(order.getUserId(), viewerUserId)) { + throw new RuntimeException("仅实际用餐人可以使用预约核销码"); + } + } + + /** + * 商户核销必须同时满足移动端商户角色、食堂预约订单和当前商户数据范围。 + */ + @Override + public void validateAppMerchantReserveAccess(Long orderId, Long viewerUserId) { + HardOrderEntity order = baseMapper.selectById(orderId); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + if (!isCanteenReserveOrder(order)) { + throw new RuntimeException("该订单不是食堂预约订单"); + } + UserInfo merchantUser = userService.getInnerByUserId(viewerUserId).getData(); + if (merchantUser == null || ObjectUtil.isEmpty(merchantUser.getRoleCodes()) + || !Arrays.asList(merchantUser.getRoleCodes()).contains(CommonRoleConstant.MCH_USER)) { + throw new RuntimeException("当前用户没有商户核销权限"); + } + MchManageDto merchantQuery = MchManageDto.builder() + .typesList(List.of(MerchantTypeEnum.CANTEEN.getType())) + .build(); + R> merchantResult = remoteMchService.scopeList(merchantQuery); + List permittedMerchants = merchantResult == null ? null : merchantResult.getData(); + boolean permitted = ObjectUtil.isNotEmpty(permittedMerchants) + && permittedMerchants.stream().anyMatch(merchant -> Objects.equals(merchant.getId(), order.getMerchantId())); + if (!permitted) { + throw new RuntimeException("无权管理该商户订单"); + } + } + + private Long resolvePayerUserId(HardOrderEntity order) { + if (order == null || order.getParentCarId() == null) { + return order == null ? null : order.getUserId(); + } + HardParentCarEntity parentCar = hardParentCarService.getById(order.getParentCarId()); + // Legacy orders may not have a parent cart. For those records wallet ownership + // remains the only reliable payer signal and must not be changed by this feature. + return parentCar == null ? order.getUserId() : parentCar.getUserId(); + } + + private List listActiveWardUserIds(Long guardianUserId, boolean failClosed) { + R> result; + try { + result = userRelationService.listActiveGuardianWardUserIds(guardianUserId); + } catch (Exception exception) { + if (failClosed) { + throw new RuntimeException("查询监护关系失败", exception); + } + log.warn("|{}|查询监护关系异常,仅保留本人订单权限,guardianUserId:|{}|,error:|{}|", TAG, guardianUserId, exception.getMessage()); + return List.of(); + } + if (result == null || !result.isOk() || result.getData() == null) { + if (failClosed) { + throw new RuntimeException("查询监护关系失败"); + } + log.warn("|{}|查询监护关系失败,仅保留本人订单权限,guardianUserId:|{}|", TAG, guardianUserId); + return List.of(); + } + return result.getData(); + } + + private boolean isCanteenReserveOrder(HardOrderEntity order) { + return Objects.equals(order.getMerchantType(), MerchantTypeEnum.CANTEEN.getType()) + && Objects.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType()) + && Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType()); + } + /* ************************************* start 支付相关 start ************************************* */ @Override @@ -491,15 +746,25 @@ public class HardOrderServiceImpl extends ServiceImpl mchResult = remoteMchService.getById(order.getMerchantId()); + MchManageVo mch = mchResult == null ? null : mchResult.getData(); + if (mch == null || ObjectUtil.notEqual(mch.getTypes(), MerchantTypeEnum.SELF_SUPERMARKET.getType())) { throw new RuntimeException("该订单不是设备扫一扫订单"); } - // 判断是否已支付 - if (HardOrderStatusEnum.getPaySuccessCodes().contains(order.getOrderStatus())) { - throw new RuntimeException("该订单已支付"); + // 待支付设备订单必须仍处于二维码有效期内,且尚未绑定任何用户和钱包。 + String redisKey = String.format("%s:%s", CacheConstants.HARDER_ORDER_NON_PAY, order.getId()); + if (!Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CREATE.getType()) + || !Objects.equals(order.getUserId(), -1L) + || order.getWalletId() != null) { + throw new RuntimeException("该订单已支付或状态已变更"); + } + if (!Boolean.TRUE.equals(redisTemplate.hasKey(redisKey))) { + throw new RuntimeException("订单已失效,请重新扫码"); } // 钱包查询 @@ -528,7 +793,6 @@ public class HardOrderServiceImpl extends ServiceImpl 3) throw new RuntimeException("您有" + reserveCount + "笔未核销的预约订单,请先核销"); - // 钱包 + if (reserveCount > 3) { + throw new RuntimeException("您有" + reserveCount + "笔未核销的预约订单,请先核销"); + } WalletManageEntity wallet = remoteWalletService.getWallet(dto.getUserId(), dto.getWalletType()).getData(); - if (ObjectUtil.isEmpty(wallet)) throw new RuntimeException("用户钱包不存在"); - // 查询商户 - MchManageVo hardManager = remoteMchService.getById(parentCar.getMerchantId()).getData(); - if (ObjectUtil.isEmpty(hardManager)) throw new RuntimeException("商户不存在"); - // 查询父购物车 + if (ObjectUtil.isEmpty(wallet)) { + throw new RuntimeException("用户钱包不存在"); + } + MchManageVo merchant = remoteMchService.getById(parentCar.getMerchantId()).getData(); + if (ObjectUtil.isEmpty(merchant)) { + throw new RuntimeException("商户不存在"); + } List carList = hardCarService.getByParentCarId(dto.getCarParentId()); - if (ObjectUtil.isEmpty(carList)) throw new RuntimeException("购物车不存在"); - Integer balance = wallet.getBalance(); - int totalPrice = (int) carList.stream().mapToLong(HardCarEntity::getTotalPrice).sum(); - if (balance < totalPrice) { + if (ObjectUtil.isEmpty(carList)) { + throw new RuntimeException("购物车不存在"); + } + int totalPrice = Math.toIntExact(carList.stream().mapToLong(HardCarEntity::getTotalPrice).sum()); + if (wallet.getBalance() < totalPrice) { throw new RuntimeException("钱包余额不足"); } - // 是否需要支付 boolean isPay = isPay(dto.getOrderType(), dto.getPayType()); - // 获取商品描述 - List carListDto = carList.stream().map(v -> { - HardCreateCarDto entity = new HardCreateCarDto(); - entity.setParentCarId(dto.getCarParentId()); - entity.setProductId(v.getProductId()); - entity.setProductWeight(v.getProductWeight()); - entity.setProductCount(v.getProductCount()); - return entity; + List carListDto = carList.stream().map(item -> { + HardCreateCarDto carDto = new HardCreateCarDto(); + carDto.setParentCarId(dto.getCarParentId()); + carDto.setProductId(item.getProductId()); + carDto.setProductWeight(item.getProductWeight()); + carDto.setProductCount(item.getProductCount()); + return carDto; }).toList(); - HardTypeEnum hardType = ObjectUtil.equals(dto.getWalletType(), WalletTypeEnum.EAT) ? HardTypeEnum.CANTEEN : HardTypeEnum.SUPERMARKET; - String subject = hardCarService.computerCarSubject(hardType, dto.getOrderType(), carListDto); + HardTypeEnum subjectHardType = ObjectUtil.equals(dto.getWalletType(), WalletTypeEnum.EAT) + ? HardTypeEnum.CANTEEN : HardTypeEnum.SUPERMARKET; + String subject = hardCarService.computerCarSubject(subjectHardType, dto.getOrderType(), carListDto); - // 添加硬件订单 - HardOrderEntity orderObj = HardOrderEntity.builder() + HardOrderEntity order = HardOrderEntity.builder() .userId(userDetail.getSysUser().getUserId()) .userDeptId(userDetail.getSysUser().getDeptId()) .parentCarId(dto.getCarParentId()) .sysType(dto.getHardType().getType()) .orderStatus(HardOrderStatusEnum.CREATE.getType()) .merchantId(parentCar.getMerchantId()) - .merchantType(hardManager.getTypes()) - .merchantDeptId(hardManager.getDeptId()) + .merchantType(merchant.getTypes()) + .merchantDeptId(merchant.getDeptId()) .orderType(dto.getOrderType().getType()) .payType(dto.getPayType().getType()) .orderSourceTypeId(dto.getOrderSourceTypeId()) @@ -598,23 +887,383 @@ public class HardOrderServiceImpl extends ServiceImpl dinerUserIds = validateReserveDinerUserIds(payerUserId, dto.getDinerUserIds()); + HardParentCarEntity sourceParentCar = hardCarService.getParentCarById(dto.getCarParentId()); + if (sourceParentCar == null || !Objects.equals(sourceParentCar.getUserId(), payerUserId)) { + throw new RuntimeException("购物车不存在或不属于当前用户"); + } + if (!Objects.equals(sourceParentCar.getHardType(), HardTypeEnum.CANTEEN.getType())) { + throw new RuntimeException("仅食堂购物车可以多人订餐"); + } + if (!Objects.equals(sourceParentCar.getOrderStatus(), HardOrderStatusEnum.CREATE.getType())) { + throw new RuntimeException("购物车状态已变更,请重新选择商品"); + } + if (baseMapper.selectCount(new LambdaQueryWrapper() + .eq(HardOrderEntity::getParentCarId, dto.getCarParentId())) > 0) { + throw new RuntimeException("该购物车已创建订单,请勿重复提交"); + } + List sourceCarList = hardCarService.getByParentCarId(dto.getCarParentId()); + if (ObjectUtil.isEmpty(sourceCarList)) { + throw new RuntimeException("购物车不存在"); + } + validateReserveCartItems(sourceCarList); + HardApptReservationService.ReservationValidation reservation = hardApptReservationService.validateReservation(sourceParentCar.getMerchantId(), dto.getApptId(), dto.getApptRuleId(), dto.getHourType(), dto.getApptDate(), dto.getApptTimeType()); + MchManageVo merchant = Optional.ofNullable(remoteMchService.getById(sourceParentCar.getMerchantId())).map(R::getData).orElseThrow(() -> new RuntimeException("商户不存在")); + if (!Objects.equals(merchant.getTypes(), MerchantTypeEnum.CANTEEN.getType())) { + throw new RuntimeException("购物车商户不是食堂商户"); + } + WalletManageEntity wallet = Optional.ofNullable(remoteWalletService.getWallet(payerUserId, WalletTypeEnum.EAT)).map(R::getData).orElseThrow(() -> new RuntimeException("用户钱包不存在")); + if (!Objects.equals(wallet.getUserId(), payerUserId)) { + throw new RuntimeException("钱包不属于当前付款人"); + } + List dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds)).map(R::getData).orElseThrow(() -> new RuntimeException("查询用餐人失败")); + Map dinerUserMap = dinerUsers.stream().collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first)); + if (dinerUserMap.size() != dinerUserIds.size()) { + throw new RuntimeException("用餐人不存在"); + } + for (Long dinerUserId : dinerUserIds) { + Long reserveCount = isHaveCanteenReserve(dinerUserId); + if (reserveCount > 3) { + throw new RuntimeException("用餐人已有" + reserveCount + "笔未核销的预约订单,请先核销"); + } + } + int singlePrice = Math.toIntExact(sourceCarList.stream().mapToLong(HardCarEntity::getTotalPrice).sum()); + if (singlePrice <= 0) { + throw new RuntimeException("购物车金额异常"); + } + int totalPayPrice = Math.multiplyExact(singlePrice, dinerUserIds.size()); + if (wallet.getBalance() == null || wallet.getBalance() < totalPayPrice) { + throw new RuntimeException("钱包余额不足"); + } + Map inventorySnapshot = validateReserveBatchInventory(sourceParentCar.getMerchantId(), sourceCarList, dinerUserIds.size()); + String subject = buildReserveOrderSubject(dto.getCarParentId(), sourceCarList); + + List createdOrders = new ArrayList<>(); + try { + for (int index = 0; index < dinerUserIds.size(); index++) { + Long dinerUserId = dinerUserIds.get(index); + Long parentCarId = index == 0 ? sourceParentCar.getId() : cloneReserveCart(sourceParentCar, sourceCarList, payerUserId); + HardOrderEntity order = buildReserveOrder(dinerUserMap.get(dinerUserId), parentCarId, merchant, wallet, reservation, singlePrice, subject); + if (baseMapper.insert(order) != 1) { + throw new RuntimeException("创建订餐订单失败"); + } + createdOrders.add(order); + } + // 一次性按用餐人数扣减汇总库存,避免逐单扣减中途失败产生临时部分占用。 + updateReserveBatchInventory(sourceCarList, dinerUserIds.size(), true, inventorySnapshot); + for (HardOrderEntity order : createdOrders) { + reduceReserveOrderBalance(order); + markReserveOrderPaid(order); + } + } catch (Exception exception) { + // 库存接口以整批原子事务执行,但远程响应超时后调用方无法判断是否已经提交; + // 因此无论扣减调用在哪一步失败,都按同一批订单号发起一次幂等恢复。 + compensateReserveOrderBatch(createdOrders, sourceCarList, dinerUserIds.size(), inventorySnapshot); + throw exception; + } + + // 二维码和广播必须等本地事务提交后再执行,避免其异常把订单事务标记为回滚。 + registerReserveOrderAfterCommitTasks(createdOrders, dinerUserMap); + HardAppCreateReserveOrdersVo result = new HardAppCreateReserveOrdersVo(); + result.setOrderIds(createdOrders.stream().map(HardOrderEntity::getId).toList()); + result.setOrderCount(createdOrders.size()); + result.setTotalPayPrice(totalPayPrice); + return result; + } + + /** + * 核心订单提交后再触发可幂等重试的下游任务;无事务调用时直接执行,便于复用和测试。 + */ + private void registerReserveOrderAfterCommitTasks(List createdOrders, Map dinerUserMap) { + Runnable task = () -> createdOrders.forEach(order -> { + try { + getSelf().generateCode(order); + UserInfo dinerInfo = new UserInfo(); + dinerInfo.setSysUser(dinerUserMap.get(order.getUserId())); + getSelf().createOrderAfterSendMqMsg(order.getId(), dinerInfo); + } catch (Exception exception) { + log.error("|{}|多人订餐下游任务执行失败,orderId:|{}|", TAG, order.getId(), exception); + } + }); + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + } + + /** + * 当前付款人只能为本人或其当前有效被监护人订餐,重复和越权数据整批拒绝。 + */ + @Override + public List validateReserveDinerUserIds(Long payerUserId, List dinerUserIds) { + if (ObjectUtil.isEmpty(dinerUserIds)) { + throw new RuntimeException("用餐人不能为空"); + } + if (dinerUserIds.stream().anyMatch(Objects::isNull)) { + throw new RuntimeException("用餐人不能为空"); + } + LinkedHashSet distinctIds = new LinkedHashSet<>(dinerUserIds); + if (distinctIds.size() != dinerUserIds.size()) { + throw new RuntimeException("请勿重复选择用餐人"); + } + Set allowedUserIds = new HashSet<>(); + allowedUserIds.add(payerUserId); + boolean containsProxyDiner = distinctIds.stream().anyMatch(id -> !Objects.equals(id, payerUserId)); + if (containsProxyDiner) { + allowedUserIds.addAll(listActiveWardUserIds(payerUserId, true)); + } + if (!allowedUserIds.containsAll(distinctIds)) { + throw new RuntimeException("存在无效或无权代订的用餐人"); + } + return new ArrayList<>(distinctIds); + } + + /** + * 多人复制购物车前先拒绝缺少商品、数量或金额的异常明细,避免在库存汇总阶段出现半途失败。 + */ + private void validateReserveCartItems(List sourceCarList) { + boolean invalid = sourceCarList.stream().anyMatch(car -> car.getProductId() == null + || car.getProductCount() == null || car.getProductCount() <= 0 + || car.getTotalPrice() == null || car.getTotalPrice() < 0); + if (invalid) { + throw new RuntimeException("购物车商品数据异常,请重新选择商品"); + } + } + + private String buildReserveOrderSubject(Long sourceParentCarId, List sourceCarList) { + List carListDto = sourceCarList.stream().map(item -> { + HardCreateCarDto carDto = new HardCreateCarDto(); + carDto.setParentCarId(sourceParentCarId); + carDto.setProductId(item.getProductId()); + carDto.setProductWeight(item.getProductWeight()); + carDto.setProductCount(item.getProductCount()); + return carDto; + }).toList(); + return hardCarService.computerCarSubject(HardTypeEnum.CANTEEN, HardOrderTypeEnum.RESERVE, carListDto); + } + + /** + * 多人订餐扣款不允许透支,余额预检后仍通过钱包原子条件更新防止跨业务并发超扣。 + */ + private void reduceReserveOrderBalance(HardOrderEntity order) { + WalletBalanceTransactionDto reduceDto = new WalletBalanceTransactionDto(); + reduceDto.setWalletId(order.getWalletId()); + reduceDto.setPrice(order.getPayPrice()); + reduceDto.setBizId(String.valueOf(order.getId())); + reduceDto.setBizType(WalletBizEnum.PAYMENT); + reduceDto.setForce(false); + R result = walletTransactionService.reduceWalletBalanceIdempotent(reduceDto); + if (result == null || !result.isOk() || result.getData() == null) { + throw new RuntimeException(result == null ? "钱包扣款无响应" : result.getMsg()); + } + } + + /** + * 扣库存前按整批人数汇总每个商品的需求,先拒绝商品缺失、跨商户和库存不足。 + */ + private Map validateReserveBatchInventory(Long merchantId, List sourceCarList, int dinerCount) { + Map requiredStock = sourceCarList.stream().collect(Collectors.toMap( + HardCarEntity::getProductId, + car -> Math.multiplyExact(car.getProductCount(), dinerCount), + Math::addExact, + LinkedHashMap::new)); + R> result = remoteOnlineCanteenSpuService + .getSpuListByIds(new ArrayList<>(requiredStock.keySet())); + if (result == null || !result.isOk() || result.getData() == null) { + throw new RuntimeException(result == null ? "查询商品库存无响应" : result.getMsg()); + } + Map productMap = result.getData().stream() + .collect(Collectors.toMap(CanteenOnlineSpuAppVO::getId, Function.identity(), (first, second) -> first)); + for (Map.Entry entry : requiredStock.entrySet()) { + CanteenOnlineSpuAppVO product = productMap.get(entry.getKey()); + if (product == null || !Objects.equals(product.getMerchantId(), merchantId)) { + throw new RuntimeException("购物车商品不存在或不属于当前商户"); + } + if (product.getStock() == null || product.getStock() < entry.getValue()) { + throw new RuntimeException(product.getName() + "库存不足"); + } + } + return productMap.values().stream().collect(Collectors.toMap(CanteenOnlineSpuAppVO::getId, CanteenOnlineSpuAppVO::getStock)); + } + + /** + * 精确复制原始父购物车和明细,保证各订单的状态、退款数量和商品关联互不影响。 + */ + private Long cloneReserveCart(HardParentCarEntity sourceParentCar, List sourceCarList, Long payerUserId) { + HardParentCarEntity targetParentCar = HardParentCarEntity.builder() + .userId(payerUserId) + .merchantId(sourceParentCar.getMerchantId()) + .hardType(sourceParentCar.getHardType()) + .orderStatus(HardOrderStatusEnum.CREATE.getType()) + .takeOutSettingJson(sourceParentCar.getTakeOutSettingJson()) + .build(); + if (!hardParentCarService.save(targetParentCar)) { + throw new RuntimeException("复制订餐购物车失败"); + } + List clonedCars = sourceCarList.stream().map(source -> HardCarEntity.builder() + .parentCarId(targetParentCar.getId()) + .productId(source.getProductId()) + .productName(source.getProductName()) + .productPic(source.getProductPic()) + .productPrice(source.getProductPrice()) + .productNature(source.getProductNature()) + .productCount(source.getProductCount()) + .productWeight(source.getProductWeight()) + .totalPrice(source.getTotalPrice()) + .build()).toList(); + if (!hardCarService.saveBatch(clonedCars)) { + throw new RuntimeException("复制订餐商品失败"); + } + return targetParentCar.getId(); + } + + private HardOrderEntity buildReserveOrder(SysUser dinerUser, Long parentCarId, MchManageVo merchant, WalletManageEntity wallet, HardApptReservationService.ReservationValidation reservation, int singlePrice, String subject) { + HardOrderEntity order = HardOrderEntity.builder() + .userId(dinerUser.getUserId()) + .userDeptId(dinerUser.getDeptId()) + .parentCarId(parentCarId) + .sysType(HardTypeEnum.CANTEEN.getType()) + .orderStatus(HardOrderStatusEnum.CREATE.getType()) + .merchantId(merchant.getId()) + .merchantType(merchant.getTypes()) + .merchantDeptId(merchant.getDeptId()) + .orderType(HardOrderTypeEnum.RESERVE.getType()) + .payType(HardPayTypeEnum.WALLET_DEFAULT.getType()) + .totalPrice(singlePrice) + .extraPrice(0) + .payPrice(singlePrice) + .walletId(wallet.getId()) + .isOfflineOrder(YesNoEnum.NO.getCode()) + .productDesc(subject) + .build(); + order.setHourType(reservation.hourType()); + order.setApptId(reservation.appt() == null ? null : reservation.appt().getId()); + order.setApptRuleId(reservation.apptRuleId()); + order.setApptUseDate(reservation.apptDate()); + return order; + } + + /** + * 扣款完成后必须同时更新订单和对应父购物车;任一更新失败都进入整批补偿并回滚本地事务。 + */ + private void markReserveOrderPaid(HardOrderEntity order) { + HardOrderEntity orderUpdate = HardOrderEntity.builder() + .id(order.getId()) + .payTime(LocalDateTime.now()) + .orderStatus(HardOrderStatusEnum.PAY.getType()) + .payPrice(order.getTotalPrice() + order.getExtraPrice()) + .build(); + if (baseMapper.updateById(orderUpdate) != 1) { + throw new RuntimeException("更新订餐订单支付状态失败"); + } + HardParentCarEntity parentCarUpdate = HardParentCarEntity.builder() + .id(order.getParentCarId()) + .orderStatus(HardOrderStatusEnum.PAY.getType()) + .build(); + if (!hardParentCarService.updateById(parentCarUpdate)) { + throw new RuntimeException("更新订餐购物车状态失败"); + } + } + + /** + * 本地事务不能回滚跨服务扣款和库存,失败时按完成顺序的逆序逐笔补偿。 + */ + private void updateReserveBatchInventory(List sourceCarList, int dinerCount, boolean reduce, Map inventorySnapshot) { + // DTO 始终携带原始扣减量;食堂服务根据 operation 决定应用扣减还是反向恢复。 + int factor = -dinerCount; + CanteenSpuUpdateStockReqDTO stockDto = new CanteenSpuUpdateStockReqDTO(); + stockDto.setBizId(buildReserveInventoryBizId(sourceCarList)); + stockDto.setOperation(reduce ? "DEDUCT" : "ROLLBACK"); + Map stockChanges = sourceCarList.stream().collect(Collectors.toMap( + HardCarEntity::getProductId, + car -> Math.multiplyExact(car.getProductCount(), factor), + Math::addExact, + LinkedHashMap::new)); + stockDto.setItems(stockChanges.entrySet().stream().map(entry -> { + CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item(); + item.setId(entry.getKey()); + item.setIncrCount(entry.getValue()); + item.setBeforeStock(inventorySnapshot.get(entry.getKey())); + return item; + }).toList()); + R result = onlineCanteenSpuService.updateSkuStockIdempotent(stockDto); + if (result == null || !result.isOk()) { + throw new RuntimeException(reduce ? "库存不足或扣减失败" : "恢复库存失败"); + } + } + + /** + * 同一批订单的库存扣减和恢复共用业务号,供食堂服务处理远程超时后的重复请求。 + */ + private String buildReserveInventoryBizId(List sourceCarList) { + Long sourceParentCarId = sourceCarList.stream() + .map(HardCarEntity::getParentCarId) + .filter(Objects::nonNull) + .findFirst() + .orElseThrow(() -> new RuntimeException("购物车数据异常")); + return "HARD_RESERVE_CART:" + sourceParentCarId; + } + + private void compensateReserveOrderBatch(List createdOrders, List sourceCarList, int dinerCount, Map inventorySnapshot) { + try { + updateReserveBatchInventory(sourceCarList, dinerCount, false, inventorySnapshot); + } catch (Exception compensationException) { + log.error("|{}|多人订餐库存补偿失败", TAG, compensationException); + } + // 对所有已创建订单发起幂等补偿;钱包服务只会退回实际存在的 PAYMENT 流水。 + ListIterator walletIterator = createdOrders.listIterator(createdOrders.size()); + while (walletIterator.hasPrevious()) { + HardOrderEntity order = walletIterator.previous(); + try { + WalletBalanceTransactionDto refundDto = new WalletBalanceTransactionDto(); + refundDto.setWalletId(order.getWalletId()); + refundDto.setPrice(order.getPayPrice()); + refundDto.setBizId(String.valueOf(order.getId())); + refundDto.setBizType(WalletBizEnum.PAYMENT); + refundDto.setForce(false); + R result = walletTransactionService.compensatePayment(refundDto); + if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { + throw new RuntimeException(result == null ? "钱包补偿无响应" : result.getMsg()); + } + } catch (Exception compensationException) { + log.error("|{}|多人订餐钱包补偿失败,orderId:|{}|", TAG, order.getId(), compensationException); + } + } + // Any cloned carts are in the same local transaction and are rolled back with the + // orders. The original cart is intentionally not deleted so a failed batch can be retried. } /** @@ -1765,6 +2414,7 @@ public class HardOrderServiceImpl extends ServiceImpl existingCode = remoteCodeUseService.getByBizId(order.getId()); + if (existingCode != null && existingCode.isOk() && existingCode.getData() != null) { + return; + } R res = remoteCodeUseService.generateCode(order.getId(), order.getUserId(), codeType, 1); if (res.getCode() != 0 || !res.getData()) { throw new RuntimeException("生成预约二维码错误"); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index 9aa6c3eee..031b557c1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -51,7 +51,7 @@ a.del_flag = '0' - and a.appt_id is not null + and (a.appt_id is not null or a.appt_rule_id is not null) and a.hard_id = #{dto.hardId} @@ -193,12 +193,33 @@ m.type as hardType, m.dept_id as hardTypeDeptId, s.id as afterSaleId, - s.no as afterSaleNo + s.no as afterSaleNo, + p.user_id as payerUserId from hard_order a left join hard_manager m on m.id = a.hard_id left join hard_after_sale s on s.order_id = a.id + left join hard_parent_car p on p.id = a.parent_car_id and p.del_flag = '0' + + and ( + a.user_id = #{dto.appViewerUserId} + or ( + a.merchant_type = '1' + and a.sys_type = '2' + and a.order_type = '2' + and ( + p.user_id = #{dto.appViewerUserId} + + or a.user_id in + + #{v} + + + ) + ) + ) + and m.type = #{dto.hardTypeCode} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserRelationService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserRelationService.java new file mode 100644 index 000000000..063c72bb5 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserRelationService.java @@ -0,0 +1,27 @@ +package com.bsd.pjcloud.admin.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 org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +/** + * 用户关系内部服务。 + */ +@FeignClient(contextId = "remoteUserRelationService", value = ServiceNameConstants.UPMS_SERVICE) +public interface RemoteUserRelationService { + + /** + * 查询指定监护人当前有效的被监护人用户ID。 + * + * @param guardianUserId 监护人用户ID + * @return 被监护人用户ID + */ + @NoToken + @GetMapping("/sysUserRelation/inner/guardian-ward/user-ids") + R> listActiveGuardianWardUserIds(@RequestParam("guardianUserId") Long guardianUserId); +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java new file mode 100644 index 000000000..509c32653 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java @@ -0,0 +1,34 @@ +package com.bsd.pjcloud.admin.api.vo.user; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 监护人的有效被监护人选择项。 + */ +@Data +@Schema(description = "监护人的有效被监护人选择项") +public class SysUserGuardianWardOptionVo implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 被监护人用户ID。 + */ + @Schema(description = "被监护人用户ID") + private Long userId; + + /** + * 被监护人姓名。 + */ + @Schema(description = "被监护人姓名") + private String name; + + /** + * 被监护人头像。 + */ + @Schema(description = "被监护人头像") + private String avatar; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports b/pjcloud-upms/pjcloud-upms-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports index 0e33e48f4..d806144e6 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports +++ b/pjcloud-upms/pjcloud-upms-api/src/main/resources/META-INF/spring/org.springframework.cloud.openfeign.FeignClient.imports @@ -14,3 +14,4 @@ com.bsd.pjcloud.admin.api.feign.RemoteMenuService com.bsd.pjcloud.admin.api.feign.RemoteRouteConfService com.bsd.pjcloud.admin.api.feign.RemoteUserTransaction com.bsd.pjcloud.admin.api.feign.RemoteMessageService +com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java index c316a0e5c..8703b80cd 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java @@ -1,4 +1,5 @@ package com.bsd.pjcloud.admin.controller; + import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; import cn.hutool.core.util.ArrayUtil; @@ -120,9 +121,7 @@ public class SysUserFaceController { @Operation(summary = "查询被监护人人脸审核记录", description = "查询当前用户全部有效被监护人的审核记录,并返回被监护人姓名") @ApiCryptoAes @GetMapping("/app/guardian-ward/page") - public R> pageGuardianWardFaces(@ParameterObject Page page, - @RequestParam(required = false) Long wardUserId, - @RequestParam(required = false) String processStatus) { + public R> pageGuardianWardFaces(@ParameterObject Page page, @RequestParam(required = false) Long wardUserId, @RequestParam(required = false) String processStatus) { return R.ok(sysUserFaceService.pageGuardianWardFaces(page, wardUserId, processStatus)); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java index 10f5e93ce..ee5e8a819 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/user/SysUserRelationController.java @@ -7,10 +7,14 @@ import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateUserDto; import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateDto; import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationUpdateDto; import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationEntity; +import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo; import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationGraphVo; +import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; import com.bsd.pjcloud.admin.service.SysUserRelationService; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.common.security.annotation.Inner; +import com.bsd.pjcloud.common.security.util.SecurityUtils; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -21,6 +25,8 @@ import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import java.util.List; + /** * 用户多对多关系表 */ @@ -33,6 +39,30 @@ public class SysUserRelationController { private final SysUserRelationService sysUserRelationService; + /** + * 查询当前用户可代办业务的有效被监护人。 + * + * @return 被监护人选择项 + */ + @Operation(summary = "查询有效被监护人选择项", description = "返回当前用户有效被监护人的姓名和头像") + @ApiCryptoAes + @GetMapping("/app/guardian-ward/options") + public R> listGuardianWardOptions() { + return R.ok(sysUserRelationService.listActiveGuardianWardOptions(SecurityUtils.getUser().getId())); + } + + /** + * 内部查询指定监护人的有效被监护人用户ID。 + * + * @param guardianUserId 监护人用户ID + * @return 被监护人用户ID + */ + @Inner + @GetMapping("/inner/guardian-ward/user-ids") + public R> listActiveGuardianWardUserIds(@RequestParam Long guardianUserId) { + return R.ok(sysUserRelationService.listActiveGuardianWardUserIds(guardianUserId)); + } + /** * 查询用户关系图谱 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java index 9b04797c8..41212f675 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java @@ -21,34 +21,21 @@ public interface SysUserFaceService extends IService { /** * 监护人代被监护人提交人脸,并强制进入审核流程。 - * - * @param dto 被监护人及人脸图片 - * @return 新增的人脸记录ID */ List createGuardianWardFaces(SysUserFaceGuardianWardCreateDto dto); /** * 查询当前监护人名下可选择的有效被监护人。 - * - * @return 被监护人选择项列表 */ List listGuardianWardOptions(); /** * 分页查询当前监护人名下全部或指定被监护人的人脸审核记录。 - * - * @param page 分页参数 - * @param wardUserId 被监护人用户ID,为空时查询全部被监护人 - * @param processStatus 审核状态 - * @return 人脸审核记录分页数据 */ Page pageGuardianWardFaces(Page page, Long wardUserId, String processStatus); /** * 查询当前监护人名下被监护人的人脸审核详情。 - * - * @param id 人脸审核记录ID - * @return 人脸审核详情 */ SysUserFaceVo getGuardianWardFaceDetail(Long id); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java index 5bec02586..1a62081b2 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserRelationService.java @@ -5,6 +5,7 @@ import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateUserDto; import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationCreateDto; import com.bsd.pjcloud.admin.api.dto.user.SysUserRelationUpdateDto; import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationEntity; +import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo; import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationGraphVo; import java.util.List; @@ -36,6 +37,14 @@ public interface SysUserRelationService extends IService */ List listActiveGuardianWardUserIds(Long guardianUserId); + /** + * 查询指定监护人当前有效的被监护人选择项。 + * + * @param guardianUserId 监护人用户ID + * @return 被监护人选择项 + */ + List listActiveGuardianWardOptions(Long guardianUserId); + /** * 创建用户并建立关系,新用户作为关系主语 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java index 2c5a16efb..6c16f60c5 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java @@ -16,6 +16,7 @@ import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity; import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardOptionVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceGuardianWardWithdrawVo; import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo; +import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo; import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper; import com.bsd.pjcloud.admin.service.SysDeptService; import com.bsd.pjcloud.admin.service.SysRoleService; @@ -50,10 +51,8 @@ import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.function.Function; import java.util.stream.Collectors; /** @@ -170,16 +169,7 @@ public class SysUserFaceServiceImpl extends ServiceImpl listGuardianWardOptions() { - List wardUserIds = sysUserRelationService.listActiveGuardianWardUserIds(SecurityUtils.getUser().getId()); - if (wardUserIds.isEmpty()) { - return List.of(); - } - Map wardUserMap = sysUserService.listByIds(wardUserIds).stream() - .collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first, - LinkedHashMap::new)); - return wardUserIds.stream() - .map(wardUserMap::get) - .filter(ObjectUtil::isNotEmpty) + return sysUserRelationService.listActiveGuardianWardOptions(SecurityUtils.getUser().getId()).stream() .map(this::buildGuardianWardOption) .toList(); } @@ -187,24 +177,19 @@ public class SysUserFaceServiceImpl extends ServiceImpl pageGuardianWardFaces(Page page, Long wardUserId, String processStatus) { diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java index c8dd46e47..1f633ac97 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java @@ -11,6 +11,7 @@ import com.bsd.pjcloud.admin.api.constant.user.UserRelationTypeEnum; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationEntity; import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationTypeEntity; +import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo; import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationGraphVo; import com.bsd.pjcloud.admin.mapper.user.SysUserRelationMapper; import com.bsd.pjcloud.admin.service.SysUserRelationService; @@ -31,6 +32,7 @@ import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -118,13 +120,14 @@ public class SysUserRelationServiceImpl extends ServiceImpllambdaQuery() .eq(SysUserRelationEntity::getSubjectUserId, guardianUserId) .eq(SysUserRelationEntity::getRelationTypeId, relationType.getRelationTypeId()) .and(wrapper -> wrapper.isNull(SysUserRelationEntity::getEffectiveTime) - .or().le(SysUserRelationEntity::getEffectiveTime, java.time.LocalDateTime.now())) + .or().le(SysUserRelationEntity::getEffectiveTime, now)) .and(wrapper -> wrapper.isNull(SysUserRelationEntity::getExpireTime) - .or().ge(SysUserRelationEntity::getExpireTime, java.time.LocalDateTime.now()))) + .or().ge(SysUserRelationEntity::getExpireTime, now))) .stream() .map(SysUserRelationEntity::getObjectUserId) .filter(Objects::nonNull) @@ -132,6 +135,41 @@ public class SysUserRelationServiceImpl extends ServiceImpl listActiveGuardianWardOptions(Long guardianUserId) { + List wardUserIds = listActiveGuardianWardUserIds(guardianUserId); + if (wardUserIds.isEmpty()) { + return List.of(); + } + Map wardUserMap = sysUserService.listByIds(wardUserIds).stream() + .collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first, LinkedHashMap::new)); + return wardUserIds.stream() + .map(wardUserMap::get) + .filter(Objects::nonNull) + .map(this::buildGuardianWardOption) + .toList(); + } + + /** + * 将用户转换为不包含敏感字段的被监护人选择项。 + * + * @param wardUser 被监护人用户 + * @return 被监护人选择项 + */ + private SysUserGuardianWardOptionVo buildGuardianWardOption(SysUser wardUser) { + SysUserGuardianWardOptionVo option = new SysUserGuardianWardOptionVo(); + option.setUserId(wardUser.getUserId()); + option.setName(StrUtil.blankToDefault(wardUser.getName(), "被监护人")); + option.setAvatar(wardUser.getAvatar()); + return option; + } + /** * 查询当前启用的监护关系类型。 * diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java index cfc56d68f..f92a49758 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java @@ -24,6 +24,20 @@ public interface RemoteWalletTransactionService { @PostMapping("/wallet-transaction/reduce-balance") R reduceWalletBalance(@RequestBody WalletBalanceTransactionDto dto); + /** + * 按钱包、业务类型和业务编号幂等扣款。 + */ + @NoToken + @PostMapping("/wallet-transaction/reduce-balance-idempotent") + R reduceWalletBalanceIdempotent(@RequestBody WalletBalanceTransactionDto dto); + + /** + * 仅在对应支付流水存在时执行幂等补偿退款。 + */ + @NoToken + @PostMapping("/wallet-transaction/compensate-payment") + R compensatePayment(@RequestBody WalletBalanceTransactionDto dto); + @NoToken @PostMapping("/wallet-transaction/sum-and-count") R> sumAndCount(@RequestBody WalletTransactionSumAndCountDto dto); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java index 6b9686802..14e46d4b7 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java @@ -69,6 +69,24 @@ public class WalletTransactionController { return R.ok(walletTransaction); } + /** + * 幂等扣款用于订单重试,避免调用方在响应超时时重复扣减。 + */ + @Inner + @PostMapping("/reduce-balance-idempotent") + public R reduceWalletBalanceIdempotent(@Valid @RequestBody WalletBalanceTransactionDto dto) { + return R.ok(walletTransactionService.reduceWalletBalanceIdempotent(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getForce())); + } + + /** + * 仅补偿已经存在的支付流水,未实际扣款或重复补偿均按成功处理。 + */ + @Inner + @PostMapping("/compensate-payment") + public R compensatePayment(@Valid @RequestBody WalletBalanceTransactionDto dto) { + return R.ok(walletTransactionService.compensatePayment(dto.getWalletId(), dto.getPrice(), dto.getBizId())); + } + @Inner @PostMapping("/sum-and-count") public R> sumAndCount(@RequestBody WalletTransactionSumAndCountDto dto) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java index a331969cc..01aa4f9ed 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java @@ -72,6 +72,15 @@ public interface WalletManageMapper extends PjcloudBaseMapper() + .eq(WalletManageEntity::getId, id) + .last("FOR UPDATE")); + } + /** * 当消费退款时候, 更新钱包 * @@ -180,4 +189,4 @@ public interface WalletManageMapper extends PjcloudBaseMapper activeBalanceList(@Param("dto") WalletTopupActionDto dto, @Param("unactiveDayNumberStr") String unactiveDayNumberStr); Long selectTotalBalance(@Param("dto") WalletManageBalanceDto dto); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java index e5c1656c2..3f6543476 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java @@ -47,6 +47,16 @@ public interface WalletTransactionService extends IService() + .eq(WalletTransactionEntity::getWalletId, walletId) + .eq(WalletTransactionEntity::getBizId, bizId) + .eq(WalletTransactionEntity::getBizType, bizType.getCode()) + .orderByDesc(WalletTransactionEntity::getId) + .last("limit 1")); + } + + private WalletTransactionEntity buildWalletTransaction(WalletManageEntity wallet, Long walletId, Integer price, Integer balance, String bizId, WalletBizEnum bizType) { + WalletTransactionEntity transaction = new WalletTransactionEntity(); + transaction.setUserId(wallet.getUserId()); + transaction.setDeptId(wallet.getDeptId()); + transaction.setWalletId(walletId); + transaction.setPrice(price); + transaction.setBalance(balance); + transaction.setBizId(bizId); + transaction.setBizType(bizType.getCode()); + transaction.setTitle(bizType.getDesc()); + return transaction; + } + @Override @Transactional(rollbackFor = Exception.class) // @CacheEvict(value = CacheConstants.WALLET_DETAIL, key = "#walletId") @@ -530,4 +613,4 @@ public class WalletTransactionServiceImpl extends ServiceImpl