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