feat:代订餐下单

This commit is contained in:
dzc committed 2026-07-22 18:56:05 +08:00
1 parent 61bb43e5c5
commit 98bbc12de6
36 files changed
+1513 -138

No files matched your search

@@ -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查询菜品
@@ -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;
}
}
@@ -17,6 +17,11 @@
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-encrypt-api</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>lock4j-redis-template-spring-boot-starter</artifactId>
<version>2.2.7</version>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
@@ -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。
*/
@@ -29,7 +29,9 @@ public interface CanteenOnlineSpuMapper extends PjcloudBaseMapper<CanteenOnlineS
// 执行更新
LambdaUpdateWrapper<CanteenOnlineSpuEntity> updateWrapper = new LambdaUpdateWrapper<CanteenOnlineSpuEntity>()
.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<CanteenOnlineS
.eq(CanteenOnlineSpuEntity::getId, id);
return update(null, updateWrapper);
}
}
}
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
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.entity.CanteenOnlineSpuEntity;
import com.bsd.pjcloud.canteen.vo.APPOnlineSpuVO;
@@ -53,6 +54,11 @@ public interface CanteenOnlineSpuService extends IService<CanteenOnlineSpuEntity
*/
R updateOnlineSpuSkuStock(CanteenOnlineSpuUpdateStockReqDTO canteenOnlineSpuUpdateStockReqDTO);
/**
* 使用业务号幂等扣减或补偿在线菜品库存。
*/
R updateOnlineSpuSkuStockIdempotent(CanteenSpuUpdateStockReqDTO dto);
/**
* 商家端手工调整库存(只改 stock,不联动 sales_count;需校验 spu 归属 merchantId)
*/
@@ -78,4 +84,4 @@ public interface CanteenOnlineSpuService extends IService<CanteenOnlineSpuEntity
* @return
*/
Object pageScope(Page page, LambdaQueryWrapper<CanteenOnlineSpuEntity> wrapper);
}
}
@@ -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<CanteenOnlineSpuMapper, CanteenOnlineSpuEntity> 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<CanteenOnlineSpuMap
}
@Override
@Transactional(rollbackFor = Exception.class)
public R updateOnlineSpuSkuStock(CanteenOnlineSpuUpdateStockReqDTO canteenOnlineSpuUpdateStockReqDTO) {
// 下单等场景:库存与销量联动(incrCount<0 视为售出)
// 先完成全部校验,再进入更新阶段,避免后一个商品失败时前面的库存已经提交。
for (CanteenOnlineSpuUpdateStockReqDTO.Item v : canteenOnlineSpuUpdateStockReqDTO.getItems()) {
CanteenOnlineSpuEntity spu = baseMapper.selectById(v.getId());
if (spu == null) {
@@ -208,8 +216,75 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
if (v.getIncrCount() < 0 && Math.abs(v.getIncrCount()) > 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<CanteenOnlineSpuMap
return pageData;
}
}
}
@@ -96,6 +96,12 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
*/
Boolean totalIsFindChildren = false;
/** App 订单可见性查询的当前访问者。 */
private Long appViewerUserId;
/** App 当前访问者的有效被监护人。 */
private List<Long> appWardUserIds;
/**
* `apptId`不为null
*/
@@ -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<Long> dinerUserIds;
private Long apptId;
private Long apptRuleId;
private String hourType;
private String apptTimeType;
private LocalDate apptDate;
}
@@ -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;
}
@@ -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<Long> orderIds;
private Integer orderCount;
private Integer totalPayPrice;
}
@@ -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;
}
@@ -56,6 +56,7 @@ public class HardAfterSaleController {
@Idempotent(expireTime = 10, info = "正在申请中,请勿重复操作")
public R<Long> 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<Boolean> 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<List<HardAfterSaleLogEntity>> 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<HardAfterSaleVo> 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 ************************************* */
@@ -59,7 +59,11 @@ public class HardCarController {
@GetMapping("/app/parent-car")
@Operation(summary = "查询父亲购物车")
public R<HardParentCarEntity> 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<List<HardCarVo>> appOrderId(@RequestParam Long orderId) {
hardOrderService.validateAppOrderView(orderId, SecurityUtils.getUser().getId());
return R.ok(hardCarService.getByOrderId(orderId));
}
@@ -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<List<HardOrderVo>> userApptList() {
public R<List<HardOrderVo>> userApptList(@RequestParam(required = false) List<Long> 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<HardOrderVo> 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<HardOrderVo> 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<MchManageVo> 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<HardOrderVo>());
}
List<Long> 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<HardOrderVo>());
}
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<HardOrderVo> 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<HardAppScanPayOrderVo> 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<HardOrderVo> 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<HardAppCreateReserveOrdersVo> 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<Boolean> appRserveCancelOrder(@PathVariable Long orderId) {
PjcloudUser user = SecurityUtils.getUser();
// 该接口是商户手动核销入口,不等同于用餐人读取或使用自己的核销码。
hardwareOrderService.validateAppMerchantReserveAccess(orderId, user.getId());
HandCancelCodeDto body = new HandCancelCodeDto();
body.setUserId(user.getId());
body.setOrderId(orderId);
@@ -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<HardOrderEntity> {
.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<HardOrderEntity> {
.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<HardOrderEntity> {
List<PanelPorductVo> shopGroupByTimeAndHourType(@Param("dto") PanelProductDto dto);
List<PanelPorductVo> shopGroupByProduct(@Param("dto") PanelProductDto dto);
}
}
@@ -59,6 +59,21 @@ public interface HardAfterSaleService extends IService<HardAfterSaleEntity> {
*/
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<HardAfterSaleEntity> {
* 涉笔退款
*/
Boolean adRefund(Long orderId, String reason);
}
}
@@ -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<HardOrderEntity> {
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<HardOrderVo> 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<HardOrderEntity> {
*/
Long appCreateOrder(@Valid HardAppCreateOrderDto dto);
/**
* 当前付款人为本人及有效被监护人批量创建独立食堂预约订单。
*/
HardAppCreateReserveOrdersVo appCreateReserveOrders(Long payerUserId, @Valid HardAppCreateReserveOrdersDto dto);
/**
* 校验并规范化当前付款人提交的用餐人。
*/
List<Long> validateReserveDinerUserIds(Long payerUserId, List<Long> dinerUserIds);
/**
* app创建食堂外卖订单
*/
@@ -198,4 +256,4 @@ public interface HardOrderService extends IService<HardOrderEntity> {
* 确认收货
*/
Boolean confirmReceipt(Long userId, Long orderId);
}
}
@@ -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<HardBroadcastMapper, H
* 添加线下食堂预约订单广播
*/
@Override
@Lock4j(keys = {"#orderdetail.id"})
public void addReserveOrderTask(HardOrderEntity orderdetail, UserInfo userInfo) {
if (ObjectUtil.isEmpty(orderdetail)) return;
HardTypeEnum hardTypeEnum = HardTypeEnum.valueOfByType(orderdetail.getSysType());
@@ -251,4 +253,4 @@ public class HardBroadcastServiceImpl extends ServiceImpl<HardBroadcastMapper, H
}
}
}
}
@@ -13,6 +13,7 @@ import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.utils.DataUtil;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
@@ -144,6 +145,67 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
return afterSale.getId();
}
/**
* 售后读取沿用订单查看权限,避免代订订单通过售后接口越权暴露。
*/
@Override
public void validateAppOrderView(Long orderId, Long viewerUserId) {
hardOrderService.validateAppOrderView(orderId, viewerUserId);
}
/**
* 代订订单只允许实际付款人发起退款。
*/
@Override
public void validateAppOrderRefund(Long orderId, Long viewerUserId) {
hardOrderService.validateAppOrderRefund(orderId, viewerUserId);
}
/**
* 通过售后单反查订单并执行统一 App 查看授权。
*/
@Override
public void validateAppAfterSaleAccess(Long afterSaleId, Long viewerUserId) {
HardAfterSaleEntity afterSale = baseMapper.selectById(afterSaleId);
if (afterSale == null) {
throw new RuntimeException("售后单不存在");
}
hardOrderService.validateAppOrderView(afterSale.getOrderId(), viewerUserId);
}
/**
* 售后取消等管理操作只允许原订单付款人执行。
*/
@Override
public void validateAppAfterSaleRefund(Long afterSaleId, Long viewerUserId) {
HardAfterSaleEntity afterSale = baseMapper.selectById(afterSaleId);
if (afterSale == null) {
throw new RuntimeException("售后单不存在");
}
hardOrderService.validateAppOrderRefund(afterSale.getOrderId(), viewerUserId);
}
/**
* 本次仅收紧食堂预约售后审核:必须属于当前商户数据范围;其他历史订单类型沿用原流程。
*/
@Override
public void validateAppMerchantAfterSaleAccess(Long afterSaleId, Long viewerUserId) {
HardAfterSaleEntity afterSale = baseMapper.selectById(afterSaleId);
if (afterSale == null) {
throw new RuntimeException("售后单不存在");
}
HardOrderEntity order = hardOrderService.getById(afterSale.getOrderId());
if (order == null) {
throw new RuntimeException("订单不存在");
}
boolean isCanteenReserveOrder = ObjectUtil.equals(order.getMerchantType(), MerchantTypeEnum.CANTEEN.getType())
&& ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())
&& ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType());
if (isCanteenReserveOrder) {
hardOrderService.validateAppMerchantReserveAccess(order.getId(), viewerUserId);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean cancelAfterSale(Long userId, Long afterSaleId) {
@@ -451,7 +513,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
saveObj.setNo(refundCode);
saveObj.setOrderId(order.getId());
saveObj.setRefundPrice(order.getPayPrice());
saveObj.setUserId(order.getUserId());
saveObj.setUserId(dto.getUserId());
// 标记订单状态
saveObj.setOrderNo(String.valueOf(order.getId()));
String type = ObjectUtil.equal(HardOrderStatusEnum.CANCEL.getType(), order.getOrderStatus()) ?
@@ -461,7 +523,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
// 添加售后进度
HardAfterSaleLogEntity saveLogObj = HardAfterSaleLogEntity.builder()
.afterSaleId(saveObj.getId())
.userId(order.getUserId())
.userId(dto.getUserId())
.operateType(HardAfterSaleOperateTypeEnum.MEMBER_CREATE.getType())
.beforeStatus(order.getOrderStatus())
.afterStatus(HardSaleStatusEnum.APPLY.getStatus())
@@ -706,4 +768,4 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
private HardAfterSaleServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}
}
@@ -13,6 +13,7 @@ import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.utils.DataUtil;
import com.bsd.pjcloud.admin.api.vo.UserVO;
@@ -22,6 +23,7 @@ import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryCartItemDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryOrderSyncDTO;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuChoiceGroupsVO;
import com.bsd.pjcloud.canteen.vo.app.CanteenOnlineSpuAppVO;
import com.bsd.pjcloud.common.core.constant.CacheConstants;
import com.bsd.pjcloud.common.core.constant.CommonConstants;
import com.bsd.pjcloud.common.core.constant.CommonRoleConstant;
@@ -52,6 +54,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.app.HardAppTakeOutApptDto;
import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto;
@@ -71,6 +74,8 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
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.hard.util.MchJobTimeUtils;
import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
@@ -95,6 +100,7 @@ import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.constant.act.ActCancelStatusEnum;
import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchRulePriceTypeEnum;
import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchSupType;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.act.ActCancelItemDto;
import com.bsd.pjcloud.wallet.api.dto.act.ActComCancelDto;
@@ -117,6 +123,8 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
@@ -127,6 +135,8 @@ import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 订单表
@@ -162,6 +172,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
private final RemoteWalletTransactionService walletTransactionService;
private final RemoteWalletSettingService walletSettingService;
private final RemoteUserService userService;
private final RemoteUserRelationService userRelationService;
private final RemoteDeptService deptService;
private final RemoteMchRuleJoinService mchRuleJoinService;
private final RemoterThirdAppPlusPayService thirdAppPlusPayService;
@@ -280,6 +291,250 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
return detail;
}
/**
* App 订单详情在加载二维码前先执行查看授权,并按当前访问者裁剪操作权限。
*
* @param id 订单ID
* @param viewerUserId 当前访问者用户ID
* @return 授权后的订单详情
*/
@Override
public HardOrderVo appJoin(Long id, Long viewerUserId) {
validateAppOrderView(id, viewerUserId);
HardOrderVo detail = getSelf().join(id);
if (detail == null) {
return null;
}
applyAppPermissions(List.of(detail), viewerUserId);
if (!Boolean.TRUE.equals(detail.getCanViewReserveCode())) {
detail.setReserveCode(null);
}
return detail;
}
/**
* 待支付设备订单尚未绑定用户,只能凭二维码中的订单和商户读取最小信息;
* 一旦绑定付款人,则恢复到仅付款人可读取,且全程不加载购物车和核销码。
*/
@Override
public HardAppScanPayOrderVo appScanPayDetail(Long id, Long merchantId, Long viewerUserId) {
HardOrderEntity order = baseMapper.selectById(id);
if (order == null) {
throw new RuntimeException("订单不存在");
}
if (!Objects.equals(order.getMerchantId(), merchantId)
|| !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_QR_CODE_SCAN.getType())) {
throw new RuntimeException("该订单不是设备扫码支付订单");
}
boolean isUnclaimedOrder = Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CREATE.getType())
&& Objects.equals(order.getUserId(), -1L)
&& order.getWalletId() == null;
if (isUnclaimedOrder) {
String redisKey = String.format("%s:%s", CacheConstants.HARDER_ORDER_NON_PAY, order.getId());
if (!Boolean.TRUE.equals(redisTemplate.hasKey(redisKey))) {
throw new RuntimeException("订单已失效,请重新扫码");
}
} else if (!Objects.equals(order.getUserId(), viewerUserId)) {
throw new RuntimeException("无权查看该订单");
}
HardAppScanPayOrderVo detail = new HardAppScanPayOrderVo();
detail.setId(order.getId());
detail.setMerchantId(order.getMerchantId());
detail.setTotalPrice(order.getTotalPrice());
detail.setExtraPrice(order.getExtraPrice());
detail.setPayPrice(order.getPayPrice());
detail.setPayType(order.getPayType());
detail.setOrderStatus(order.getOrderStatus());
R<MchManageVo> 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<HardOrderVo> 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<List<MchManageVo>> merchantResult = remoteMchService.scopeList(merchantQuery);
List<MchManageVo> 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<Long> listActiveWardUserIds(Long guardianUserId, boolean failClosed) {
R<List<Long>> 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<HardOrderMapper, HardOrder
// 查询订单
HardOrderEntity order = baseMapper.selectById(dto.getOrderId());
if (ObjectUtil.isEmpty(order)) throw new RuntimeException("订单不存在");
if (!Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_QR_CODE_SCAN.getType())) {
throw new RuntimeException("该订单不是设备扫码支付订单");
}
// 判断该订单是否为设备扫一扫订单
MchManageVo mch = remoteMchService.getById(order.getMerchantId()).getData();
if (ObjectUtil.notEqual(mch.getTypes(), MerchantTypeEnum.SELF_SUPERMARKET.getType())) {
R<MchManageVo> 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<HardOrderMapper, HardOrder
getSelf().fastReduceBalance(order);
// 在redis里存储订单id如果5分钟没有支付就删除订单(这里删除一下)
String redisKey = String.format("%s:%s", CacheConstants.HARDER_ORDER_NON_PAY, order.getId());
redisTemplate.delete(redisKey);
}
@@ -536,59 +800,84 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
// @GlobalTransactional
@Transactional(rollbackFor = Exception.class)
public Long appCreateOrder(@Valid HardAppCreateOrderDto dto) {
// 查询用户
if (!ObjectUtil.equals(dto.getHardType(), HardTypeEnum.CANTEEN)) {
return appCreateLegacyReserveOrder(dto);
}
HardAppCreateReserveOrdersDto batchDto = new HardAppCreateReserveOrdersDto();
batchDto.setCarParentId(dto.getCarParentId());
batchDto.setDinerUserIds(List.of(dto.getUserId()));
batchDto.setApptId(dto.getApptId());
batchDto.setApptRuleId(dto.getApptRuleId());
batchDto.setHourType(dto.getHourType());
batchDto.setApptTimeType(dto.getApptTimeType());
batchDto.setApptDate(dto.getApptDate());
return getSelf().appCreateReserveOrders(dto.getUserId(), batchDto).getOrderIds().get(0);
}
/**
* 保留旧客户端通过通用接口创建非食堂预约订单的原有流程和参数语义。
*
* @param dto 旧版 App 下单参数
* @return 订单ID
*/
private Long appCreateLegacyReserveOrder(HardAppCreateOrderDto dto) {
UserInfo userDetail = userService.getInnerByUserId(dto.getUserId()).getData();
if (ObjectUtil.isEmpty(userDetail)) throw new RuntimeException("用户不存在不存在");
// 先以购物车商户作为可信来源,再统一校验预约餐段、日期映射和开放时间。
if (ObjectUtil.isEmpty(userDetail)) {
throw new RuntimeException("用户不存在不存在");
}
HardParentCarEntity parentCar = hardCarService.getParentCarById(dto.getCarParentId());
if (ObjectUtil.isEmpty(parentCar)) throw new RuntimeException("购物车不存在");
if (ObjectUtil.isEmpty(parentCar)) {
throw new RuntimeException("购物车不存在");
}
HardApptReservationService.ReservationValidation reservation = null;
if (ObjectUtil.equals(dto.getOrderType(), HardOrderTypeEnum.RESERVE)) {
if (ObjectUtil.equals(dto.getOrderType(), HardOrderTypeEnum.RESERVE)
&& ObjectUtil.equals(dto.getHardType(), HardTypeEnum.CANTEEN)) {
reservation = hardApptReservationService.validateReservation(
parentCar.getMerchantId(), dto.getApptId(), dto.getApptRuleId(),
dto.getHourType(), dto.getApptDate(), dto.getApptTimeType());
}
// 判断是否有重复的预约订单
Long reserveCount = isHaveCanteenReserve(dto.getUserId());
if (reserveCount > 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<HardCarEntity> 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<HardCreateCarDto> 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<HardCreateCarDto> 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<HardOrderMapper, HardOrder
.isOfflineOrder(YesNoEnum.NO.getCode())
.productDesc(subject)
.build();
if (ObjectUtil.equals(dto.getOrderType(), HardOrderTypeEnum.RESERVE)) {
orderObj.setHourType(reservation.hourType());
orderObj.setApptId(reservation.appt() == null ? null : reservation.appt().getId());
orderObj.setApptRuleId(reservation.apptRuleId());
orderObj.setApptUseDate(reservation.apptDate());
if (reservation != null) {
order.setHourType(reservation.hourType());
order.setApptId(reservation.appt() == null ? null : reservation.appt().getId());
order.setApptRuleId(reservation.apptRuleId());
order.setApptUseDate(reservation.apptDate());
}
if (isPay) orderObj.setWalletId(wallet.getId());
baseMapper.insert(orderObj);
// 钱包扣款
if (isPay) getSelf().fastReduceBalance(orderObj);
// 是否需要创建二维码
getSelf().generateCode(orderObj);
// 发送mq消息
getSelf().createOrderAfterSendMqMsg(orderObj.getId(), userDetail);
// 扣减库存
getSelf().fastenerInventory(orderObj, YesNoEnum.YES);
return orderObj.getId();
if (isPay) {
order.setWalletId(wallet.getId());
}
baseMapper.insert(order);
if (isPay) {
getSelf().fastReduceBalance(order);
}
getSelf().generateCode(order);
getSelf().createOrderAfterSendMqMsg(order.getId(), userDetail);
getSelf().fastenerInventory(order, YesNoEnum.YES);
return order.getId();
}
/**
* 批量订餐在创建前完成整批授权、余额、预约数量与购物车校验。每位用餐人独立购物车、订单和钱包流水;
* 远程钱包或库存步骤失败时,逐笔补偿已完成的远程操作,本地订单和购物车由事务回滚。
*/
@Override
@Lock4j(keys = {"#dto.carParentId"})
@Transactional(rollbackFor = Exception.class)
public HardAppCreateReserveOrdersVo appCreateReserveOrders(Long payerUserId, @Valid HardAppCreateReserveOrdersDto dto) {
List<Long> 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<HardOrderEntity>()
.eq(HardOrderEntity::getParentCarId, dto.getCarParentId())) > 0) {
throw new RuntimeException("该购物车已创建订单,请勿重复提交");
}
List<HardCarEntity> 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<SysUser> dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds)).map(R::getData).orElseThrow(() -> new RuntimeException("查询用餐人失败"));
Map<Long, SysUser> 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<Long, Integer> inventorySnapshot = validateReserveBatchInventory(sourceParentCar.getMerchantId(), sourceCarList, dinerUserIds.size());
String subject = buildReserveOrderSubject(dto.getCarParentId(), sourceCarList);
List<HardOrderEntity> 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<HardOrderEntity> createdOrders, Map<Long, SysUser> 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<Long> validateReserveDinerUserIds(Long payerUserId, List<Long> dinerUserIds) {
if (ObjectUtil.isEmpty(dinerUserIds)) {
throw new RuntimeException("用餐人不能为空");
}
if (dinerUserIds.stream().anyMatch(Objects::isNull)) {
throw new RuntimeException("用餐人不能为空");
}
LinkedHashSet<Long> distinctIds = new LinkedHashSet<>(dinerUserIds);
if (distinctIds.size() != dinerUserIds.size()) {
throw new RuntimeException("请勿重复选择用餐人");
}
Set<Long> 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<HardCarEntity> 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<HardCarEntity> sourceCarList) {
List<HardCreateCarDto> 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<WalletTransactionEntity> result = walletTransactionService.reduceWalletBalanceIdempotent(reduceDto);
if (result == null || !result.isOk() || result.getData() == null) {
throw new RuntimeException(result == null ? "钱包扣款无响应" : result.getMsg());
}
}
/**
* 扣库存前按整批人数汇总每个商品的需求,先拒绝商品缺失、跨商户和库存不足。
*/
private Map<Long, Integer> validateReserveBatchInventory(Long merchantId, List<HardCarEntity> sourceCarList, int dinerCount) {
Map<Long, Integer> requiredStock = sourceCarList.stream().collect(Collectors.toMap(
HardCarEntity::getProductId,
car -> Math.multiplyExact(car.getProductCount(), dinerCount),
Math::addExact,
LinkedHashMap::new));
R<List<CanteenOnlineSpuAppVO>> result = remoteOnlineCanteenSpuService
.getSpuListByIds(new ArrayList<>(requiredStock.keySet()));
if (result == null || !result.isOk() || result.getData() == null) {
throw new RuntimeException(result == null ? "查询商品库存无响应" : result.getMsg());
}
Map<Long, CanteenOnlineSpuAppVO> productMap = result.getData().stream()
.collect(Collectors.toMap(CanteenOnlineSpuAppVO::getId, Function.identity(), (first, second) -> first));
for (Map.Entry<Long, Integer> 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<HardCarEntity> 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<HardCarEntity> 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<HardCarEntity> sourceCarList, int dinerCount, boolean reduce, Map<Long, Integer> inventorySnapshot) {
// DTO 始终携带原始扣减量;食堂服务根据 operation 决定应用扣减还是反向恢复。
int factor = -dinerCount;
CanteenSpuUpdateStockReqDTO stockDto = new CanteenSpuUpdateStockReqDTO();
stockDto.setBizId(buildReserveInventoryBizId(sourceCarList));
stockDto.setOperation(reduce ? "DEDUCT" : "ROLLBACK");
Map<Long, Integer> 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<HardCarEntity> sourceCarList) {
Long sourceParentCarId = sourceCarList.stream()
.map(HardCarEntity::getParentCarId)
.filter(Objects::nonNull)
.findFirst()
.orElseThrow(() -> new RuntimeException("购物车数据异常"));
return "HARD_RESERVE_CART:" + sourceParentCarId;
}
private void compensateReserveOrderBatch(List<HardOrderEntity> createdOrders, List<HardCarEntity> sourceCarList, int dinerCount, Map<Long, Integer> inventorySnapshot) {
try {
updateReserveBatchInventory(sourceCarList, dinerCount, false, inventorySnapshot);
} catch (Exception compensationException) {
log.error("|{}|多人订餐库存补偿失败", TAG, compensationException);
}
// 对所有已创建订单发起幂等补偿;钱包服务只会退回实际存在的 PAYMENT 流水。
ListIterator<HardOrderEntity> 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<Boolean> 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<HardOrderMapper, HardOrder
}
@Override
@Lock4j(keys = {"#order.id"})
@Transactional(rollbackFor = Exception.class)
public void generateCode(HardOrderEntity order) {
HardTypeEnum hardTypeEnum = HardTypeEnum.valueOfByType(order.getSysType());
@@ -1784,6 +2434,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
codeType = WalletCodeBizEnum.FARMER_RESERVE;
}
if (ObjectUtil.isEmpty(codeType)) return;
R<WalletCodeUseEntity> existingCode = remoteCodeUseService.getByBizId(order.getId());
if (existingCode != null && existingCode.isOk() && existingCode.getData() != null) {
return;
}
R<Boolean> res = remoteCodeUseService.generateCode(order.getId(), order.getUserId(), codeType, 1);
if (res.getCode() != 0 || !res.getData()) {
throw new RuntimeException("生成预约二维码错误");
@@ -51,7 +51,7 @@
<sql id="whereA">
a.del_flag = '0'
<if test="dto.apptIdNotNull != null and dto.apptIdNotNull == true ">
and a.appt_id is not null
and (a.appt_id is not null or a.appt_rule_id is not null)
</if>
<if test="dto.hardId != 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'
<where>
<include refid="whereA"/>
<if test="dto.appViewerUserId != null">
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}
<if test="dto.appWardUserIds != null and dto.appWardUserIds.size() != 0">
or a.user_id in
<foreach collection="dto.appWardUserIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
)
)
)
</if>
<if test="dto.hardTypeCode != null and dto.hardTypeCode != '' ">
and m.type = #{dto.hardTypeCode}
</if>
@@ -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<List<Long>> listActiveGuardianWardUserIds(@RequestParam("guardianUserId") Long guardianUserId);
}
@@ -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;
}
@@ -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
@@ -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<Page<SysUserFaceVo>> pageGuardianWardFaces(@ParameterObject Page page,
@RequestParam(required = false) Long wardUserId,
@RequestParam(required = false) String processStatus) {
public R<Page<SysUserFaceVo>> pageGuardianWardFaces(@ParameterObject Page page, @RequestParam(required = false) Long wardUserId, @RequestParam(required = false) String processStatus) {
return R.ok(sysUserFaceService.pageGuardianWardFaces(page, wardUserId, processStatus));
}
@@ -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<List<SysUserGuardianWardOptionVo>> listGuardianWardOptions() {
return R.ok(sysUserRelationService.listActiveGuardianWardOptions(SecurityUtils.getUser().getId()));
}
/**
* 内部查询指定监护人的有效被监护人用户ID。
*
* @param guardianUserId 监护人用户ID
* @return 被监护人用户ID
*/
@Inner
@GetMapping("/inner/guardian-ward/user-ids")
public R<List<Long>> listActiveGuardianWardUserIds(@RequestParam Long guardianUserId) {
return R.ok(sysUserRelationService.listActiveGuardianWardUserIds(guardianUserId));
}
/**
* 查询用户关系图谱
*/
@@ -21,34 +21,21 @@ public interface SysUserFaceService extends IService<SysUserFaceEntity> {
/**
* 监护人代被监护人提交人脸,并强制进入审核流程。
*
* @param dto 被监护人及人脸图片
* @return 新增的人脸记录ID
*/
List<Long> createGuardianWardFaces(SysUserFaceGuardianWardCreateDto dto);
/**
* 查询当前监护人名下可选择的有效被监护人。
*
* @return 被监护人选择项列表
*/
List<SysUserFaceGuardianWardOptionVo> listGuardianWardOptions();
/**
* 分页查询当前监护人名下全部或指定被监护人的人脸审核记录。
*
* @param page 分页参数
* @param wardUserId 被监护人用户ID,为空时查询全部被监护人
* @param processStatus 审核状态
* @return 人脸审核记录分页数据
*/
Page<SysUserFaceVo> pageGuardianWardFaces(Page page, Long wardUserId, String processStatus);
/**
* 查询当前监护人名下被监护人的人脸审核详情。
*
* @param id 人脸审核记录ID
* @return 人脸审核详情
*/
SysUserFaceVo getGuardianWardFaceDetail(Long id);
@@ -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<SysUserRelationEntity>
*/
List<Long> listActiveGuardianWardUserIds(Long guardianUserId);
/**
* 查询指定监护人当前有效的被监护人选择项。
*
* @param guardianUserId 监护人用户ID
* @return 被监护人选择项
*/
List<SysUserGuardianWardOptionVo> listActiveGuardianWardOptions(Long guardianUserId);
/**
* 创建用户并建立关系,新用户作为关系主语
*
@@ -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<SysUserFaceMapper, SysUs
*/
@Override
public List<SysUserFaceGuardianWardOptionVo> listGuardianWardOptions() {
List<Long> wardUserIds = sysUserRelationService.listActiveGuardianWardUserIds(SecurityUtils.getUser().getId());
if (wardUserIds.isEmpty()) {
return List.of();
}
Map<Long, SysUser> 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<SysUserFaceMapper, SysUs
/**
* 将被监护人用户转换为移动端选择项。
*
* @param wardUser 被监护人用户
* @param wardOption 通用被监护人选择项
* @return 被监护人选择项
*/
private SysUserFaceGuardianWardOptionVo buildGuardianWardOption(SysUser wardUser) {
private SysUserFaceGuardianWardOptionVo buildGuardianWardOption(SysUserGuardianWardOptionVo wardOption) {
SysUserFaceGuardianWardOptionVo option = new SysUserFaceGuardianWardOptionVo();
option.setWardUserId(wardUser.getUserId());
option.setWardName(StrUtil.blankToDefault(wardUser.getName(), "被监护人"));
option.setWardAvatar(wardUser.getAvatar());
option.setWardUserId(wardOption.getUserId());
option.setWardName(wardOption.getName());
option.setWardAvatar(wardOption.getAvatar());
return option;
}
/**
* 分页查询当前监护人名下全部或指定被监护人的人脸审核记录。
*
* @param page 分页参数
* @param wardUserId 被监护人用户ID,为空时查询全部被监护人
* @param processStatus 审核状态
* @return 人脸审核记录分页数据
*/
@Override
public Page<SysUserFaceVo> pageGuardianWardFaces(Page page, Long wardUserId, String processStatus) {
@@ -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 ServiceImpl<SysUserRelationMappe
if (relationType == null) {
return List.of();
}
java.time.LocalDateTime now = java.time.LocalDateTime.now();
return list(Wrappers.<SysUserRelationEntity>lambdaQuery()
.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<SysUserRelationMappe
.toList();
}
/**
* 查询指定监护人当前有效的被监护人,并按关系记录顺序返回展示信息。
*
* @param guardianUserId 监护人用户ID
* @return 被监护人选择项
*/
@Override
public List<SysUserGuardianWardOptionVo> listActiveGuardianWardOptions(Long guardianUserId) {
List<Long> wardUserIds = listActiveGuardianWardUserIds(guardianUserId);
if (wardUserIds.isEmpty()) {
return List.of();
}
Map<Long, SysUser> 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;
}
/**
* 查询当前启用的监护关系类型。
*
@@ -24,6 +24,20 @@ public interface RemoteWalletTransactionService {
@PostMapping("/wallet-transaction/reduce-balance")
R<WalletTransactionEntity> reduceWalletBalance(@RequestBody WalletBalanceTransactionDto dto);
/**
* 按钱包、业务类型和业务编号幂等扣款。
*/
@NoToken
@PostMapping("/wallet-transaction/reduce-balance-idempotent")
R<WalletTransactionEntity> reduceWalletBalanceIdempotent(@RequestBody WalletBalanceTransactionDto dto);
/**
* 仅在对应支付流水存在时执行幂等补偿退款。
*/
@NoToken
@PostMapping("/wallet-transaction/compensate-payment")
R<Boolean> compensatePayment(@RequestBody WalletBalanceTransactionDto dto);
@NoToken
@PostMapping("/wallet-transaction/sum-and-count")
R<List<WalletTransactionSumAndCountVo>> sumAndCount(@RequestBody WalletTransactionSumAndCountDto dto);
@@ -69,6 +69,24 @@ public class WalletTransactionController {
return R.ok(walletTransaction);
}
/**
* 幂等扣款用于订单重试,避免调用方在响应超时时重复扣减。
*/
@Inner
@PostMapping("/reduce-balance-idempotent")
public R<WalletTransactionEntity> 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<Boolean> compensatePayment(@Valid @RequestBody WalletBalanceTransactionDto dto) {
return R.ok(walletTransactionService.compensatePayment(dto.getWalletId(), dto.getPrice(), dto.getBizId()));
}
@Inner
@PostMapping("/sum-and-count")
public R<List<WalletTransactionSumAndCountVo>> sumAndCount(@RequestBody WalletTransactionSumAndCountDto dto) {
@@ -72,6 +72,15 @@ public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity
return update(null, lambdaUpdateWrapper);
}
/**
* 锁定钱包行,使同一钱包的业务幂等检查和余额变更处于同一事务串行执行。
*/
default WalletManageEntity selectByIdForUpdate(Long id) {
return selectOne(new LambdaQueryWrapper<WalletManageEntity>()
.eq(WalletManageEntity::getId, id)
.last("FOR UPDATE"));
}
/**
* 当消费退款时候, 更新钱包
*
@@ -180,4 +189,4 @@ public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity
List<WalletManageVo> activeBalanceList(@Param("dto") WalletTopupActionDto dto, @Param("unactiveDayNumberStr") String unactiveDayNumberStr);
Long selectTotalBalance(@Param("dto") WalletManageBalanceDto dto);
}
}
@@ -47,6 +47,16 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
*/
WalletTransactionEntity reduceWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force);
/**
* 按业务编号幂等扣减钱包余额。
*/
WalletTransactionEntity reduceWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force);
/**
* 对指定支付业务执行幂等补偿;支付流水不存在时不增加余额。
*/
Boolean compensatePayment(Long walletId, Integer price, String bizId);
/**
* 增加钱包余额
@@ -86,4 +96,4 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
* 后台充值统计按照个人(导出)
*/
void exportCzTotalPerson(HttpServletResponse response, WalletTransactionDto dto, Object o);
}
}
@@ -10,6 +10,7 @@ import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.entity.SysDept;
@@ -125,6 +126,88 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
return insetObj;
}
/**
* 锁定钱包后先查询同一业务流水;重试请求直接返回原流水,避免响应超时造成重复扣款。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public WalletTransactionEntity reduceWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force) {
if (ObjectUtil.isEmpty(force)) {
force = false;
}
WalletManageEntity wallet = walletManageMapper.selectByIdForUpdate(walletId);
if (ObjectUtil.isEmpty(wallet)) {
throw new RuntimeException("扣款失败,用户钱包不存在");
}
WalletTransactionEntity existing = findBizTransaction(walletId, bizId, bizType);
if (existing != null) {
if (!ObjectUtil.equals(existing.getPrice(), -price)) {
throw new RuntimeException("重复业务扣款金额不一致");
}
return existing;
}
if (walletManageMapper.updateWhenConsumption(walletId, price, force) == 0) {
throw new RuntimeException("钱包余额不足");
}
WalletTransactionEntity transaction = buildWalletTransaction(wallet, walletId, -price, wallet.getBalance() - price, bizId, bizType);
save(transaction);
return transaction;
}
/**
* 补偿前同时核对支付流水和退款流水,保证未知远程结果也可以安全地对整批订单逐笔补偿。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean compensatePayment(Long walletId, Integer price, String bizId) {
WalletManageEntity wallet = walletManageMapper.selectByIdForUpdate(walletId);
if (ObjectUtil.isEmpty(wallet)) {
throw new RuntimeException("退款失败,用户钱包不存在");
}
WalletTransactionEntity existingRefund = findBizTransaction(walletId, bizId, WalletBizEnum.REFUND);
if (existingRefund != null) {
if (!ObjectUtil.equals(existingRefund.getPrice(), price)) {
throw new RuntimeException("重复业务退款金额不一致");
}
return true;
}
WalletTransactionEntity payment = findBizTransaction(walletId, bizId, WalletBizEnum.PAYMENT);
if (payment == null) {
return true;
}
if (!ObjectUtil.equals(payment.getPrice(), -price)) {
throw new RuntimeException("支付流水金额与补偿金额不一致");
}
if (walletManageMapper.updateWhenConsumptionRefund(walletId, price) == 0) {
throw new RuntimeException("钱包补偿失败");
}
WalletTransactionEntity refund = buildWalletTransaction(wallet, walletId, price, wallet.getBalance() + price, bizId, WalletBizEnum.REFUND);
save(refund);
return true;
}
private WalletTransactionEntity findBizTransaction(Long walletId, String bizId, WalletBizEnum bizType) {
return baseMapper.selectOne(new LambdaQueryWrapper<WalletTransactionEntity>()
.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<WalletTransactionM
}
}
}
}