This commit is contained in:
zjf committed 2026-10-12 14:17:35 +08:00
commit 074340e5c6
22 files changed
+1071 -72

No files matched your search

@@ -0,0 +1,81 @@
-- 钱包流水页设备类型筛选(/wallet/wallet-transaction):wallet_transaction 固化设备类型快照列。
-- 幂等迁移脚本:可反复执行。列与索引均先查 information_schema,已存在则跳过(执行 SELECT 1 占位);
-- 回填带 hard_type IS NULL 条件,重跑只处理增量。
-- 背景:设备类型筛选原走「wallet Feign 中转订单 id 集合」方案,大类型(咖啡机等)订单量大,
-- 触发上限拦截导致筛选不可用;改为流水表固化 hard_type 快照列(与 hard_order.sys_type 同值域),
-- 筛选变为普通索引列过滤,新流水由订单支付/退款调用链路实时携带(见 HardOrderServiceImpl 两个
-- WalletBalanceTransactionDto 构造点 + WalletTransactionServiceImpl.save 的 fillHardTypeFromContext)。
-- 注意:
-- 1) biz_type='5' 退款流水 biz_id 多源:订单售后退款主路径=售后单主键(hard_after_sale.id);
-- 补偿退款=订单号;充值/套餐退款=售后单主键或 refund_ 前缀退款单号(与设备无关,hard_type
-- 保持为空是正确语义)。因此退款回填分两段(4a 补偿路径按支付流水、4b 售后路径经
-- hard_after_sale.order_id 关联原订单支付流水),充值售后 order_id 无效天然跳过。
-- 2) 回填 JOIN 跨 schema(pjcloudx_wallet × pjcloudx_hardware,同实例),需用同实例账号执行。
-- 3) 严格 SQL 模式下 CAST 非数字 biz_id 会以 1292 报错中断,REGEXP 前置过滤保证只对纯数字主键 CAST。
-- 【可选诊断】确认当前列与索引状态。
SELECT COLUMN_NAME FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_transaction' AND COLUMN_NAME = 'hard_type';
-- 1) 固化列:缺失才创建,与 hard_order.sys_type 同值域(HardTypeEnum 编码,'-1' 为无设备)。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`wallet_transaction` ADD COLUMN `hard_type` varchar(10) DEFAULT NULL COMMENT ''订单设备类型快照编码(HardTypeEnum.type),仅设备订单支付/退款流水有值'' AFTER `biz_id`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_transaction' AND COLUMN_NAME = 'hard_type');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2) 筛选索引:缺失才创建(租户+删除标记前缀与既有 idx_wallet_tx_* 风格一致)。
-- 设备类型筛选 = tenant+del_flag+hard_type IN(...),其余条件(用户/时间)回表过滤,命中分区远小于全表。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`wallet_transaction` ADD KEY `idx_wallet_tx_tenant_del_hard_type` (`tenant_id`, `del_flag`, `hard_type`)',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_transaction' AND INDEX_NAME = 'idx_wallet_tx_tenant_del_hard_type');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 3) 支付流水回填:biz_id 为纯数字订单号才 JOIN 订单表(REGEXP 防严格模式下 CAST 报错,
-- 也排除个别非订单号形态的杂音)。CAST 后按订单主键点查。
-- 大事务限制提示:若实例限制单事务行数,可追加 AND w.create_time >= '<分段日期>' 分批执行,脚本幂等可重跑。
UPDATE pjcloudx_wallet.wallet_transaction w
JOIN pjcloudx_hardware.hard_order o ON o.id = CAST(w.biz_id AS UNSIGNED)
SET w.hard_type = o.sys_type
WHERE w.biz_type = '4' AND w.hard_type IS NULL
AND w.biz_id REGEXP '^[0-9]+$';
-- 4a) 退款流水回填·补偿路径:biz_id 与支付流水一致(订单号形态,compensatePayment 补偿退款)。
UPDATE pjcloudx_wallet.wallet_transaction w
JOIN pjcloudx_wallet.wallet_transaction pay
ON pay.wallet_id = w.wallet_id
AND pay.biz_id = w.biz_id
AND pay.biz_type = '4'
AND pay.del_flag = '0'
AND pay.hard_type IS NOT NULL
SET w.hard_type = pay.hard_type
WHERE w.biz_type = '5' AND w.hard_type IS NULL AND w.del_flag = '0';
-- 4b) 退款流水回填·售后主路径:订单售后退款 biz_id 存售后单主键(hard_after_sale.id,
-- 经 WalletRechargeServiceImpl.refund 落库),需经售后单 order_id 关联原订单支付流水;
-- 充值售后 order_id 无效不命中,hard_type 保持为空是正确语义。
UPDATE pjcloudx_wallet.wallet_transaction w
JOIN pjcloudx_hardware.hard_after_sale s ON s.id = CAST(w.biz_id AS UNSIGNED)
JOIN pjcloudx_wallet.wallet_transaction pay
ON pay.wallet_id = w.wallet_id
AND pay.biz_id = CAST(s.order_id AS CHAR)
AND pay.biz_type = '4'
AND pay.del_flag = '0'
AND pay.hard_type IS NOT NULL
SET w.hard_type = pay.hard_type
WHERE w.biz_type = '5' AND w.hard_type IS NULL AND w.del_flag = '0'
AND w.biz_id REGEXP '^[0-9]+$';
-- 【可选诊断】回填结果分布:设备类型 × 收支业务类型行数。
SELECT w.biz_type, w.hard_type, COUNT(*) AS cnt
FROM pjcloudx_wallet.wallet_transaction w
WHERE w.hard_type IS NOT NULL
GROUP BY w.biz_type, w.hard_type
ORDER BY w.biz_type, w.hard_type;
@@ -24,6 +24,18 @@ public interface RemoteHardOrderService {
@GetMapping("/hard-order/get-by-ids")
R<List<HardOrderEntity>> getByIds(@RequestParam List<Long> ids);
/**
* 按售后单主键批量查询售后单,供钱包退款流水(biz_id 为售后单主键)装配消费场景:
* wallet 侧经售后单 orderId 再反查原订单。
*
* @param ids 售后单主键集合
* @return 售后单集合;不存在的主键不返回
*/
@NoToken
@GetMapping("/hard-after-sale/inner/get-by-ids")
R<List<com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity>> listAfterSalesByIds(
@RequestParam List<Long> ids);
@PostMapping("/hard-order/page")
R<Page<List<HardOrderEntity>>> getPage(@RequestParam Page page, @RequestBody HardOrderPreferencesDto hardOrderPreferencesDto);
@@ -29,6 +29,8 @@ import com.bsd.pjcloud.hard.service.HardBatchRefundService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
import com.github.yulichang.query.MPJLambdaQueryWrapper;
import org.springframework.security.access.prepost.PreAuthorize;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
@@ -55,6 +57,9 @@ public class HardAfterSaleController {
private final HardAfterSaleService hardAfterSaleService;
/** 售后单 Mapper:钱包退款流水 biz_id 存售后单主键,供跨服务按主键批量反查售后单。 */
private final HardAfterSaleMapper hardAfterSaleMapper;
/** 批量退款服务,负责权限预览和最多 1000 笔订单的异步逐笔退款。 */
private final HardBatchRefundService hardBatchRefundService;
@@ -73,6 +78,24 @@ public class HardAfterSaleController {
return R.ok(hardAfterSaleService.refreshOrderRefundState(orderId));
}
/**
* 钱包退款流水的 biz_id 存售后单主键(订单售后退款经 WalletRechargeServiceImpl.refund 落库),
* 消费场景装配按售后单主键批量反查售后单再经 orderId 取原订单;
* Inner 调用无用户上下文,按主键批量查询本身就是精确范围,走普通条件查询。
*
* @param ids 售后单主键集合
* @return 售后单集合;不存在的主键不返回
*/
@Inner
@GetMapping("/inner/get-by-ids")
@Operation(summary = "按主键批量查询售后单", description = "供钱包退款流水(biz_id 为售后单主键)装配消费场景")
public R<List<HardAfterSaleEntity>> getByIds(@RequestParam List<Long> ids) {
if (ObjectUtil.isEmpty(ids)) {
return R.ok(List.of());
}
return R.ok(hardAfterSaleMapper.selectBatchIds(ids));
}
/* ************************************* start app start ************************************* */
@ApiCryptoAes
@PostMapping(value = "/app/create")
@@ -75,6 +75,7 @@ import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
/**
* 订单表
@@ -108,9 +109,12 @@ public class HardOrderController {
@Operation(summary = "查询订单列表", description = "查询订单列表")
@GetMapping("/get-by-ids")
public R<List<HardOrderEntity>> getByIds(@RequestParam List<Long> ids) {
HardOrderDto dto = new HardOrderDto();
dto.setOrderIds(ids);
return R.ok(hardOrderMapper.list(dto));
// Inner 调用没有后台用户上下文,走 selectListByScope 会被数据权限拦截器按匿名角色解析炸掉;
// 按主键集合批量查询本身就是精确范围,直接走普通条件查询(租户与逻辑删除拦截器照常生效)
if (ObjectUtil.isEmpty(ids)) {
return R.ok(List.of());
}
return R.ok(hardOrderMapper.selectBatchIds(ids));
}
@Inner
@@ -682,7 +682,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
.orderId(order.getId()).afterSaleId(afterSale.getId()).refundCode(refundCode).reason("订单退款")
.price(refundPrice).autoRefundConfigId(autoRefundConfigId).autoRefundTaskType(autoRefundTaskType)
// 售后退款的系统备注("订单退款退回X元"),落库到钱包流水 sys_remark
.sysRemark(HardOrderSysRemarkHelper.renderAfterSaleRefund(refundPrice)).build();
.sysRemark(HardOrderSysRemarkHelper.renderAfterSaleRefund(refundPrice))
// 设备类型快照:随退款流水固化为 hard_type,供钱包流水按设备类型筛选
.sysType(order.getSysType()).build();
R<WalletRechargeVo> walletRefundResult = walletRechargeService.refund(hardRefundDto);
if (walletRefundResult == null || !walletRefundResult.isOk() || ObjectUtil.isEmpty(walletRefundResult.getData())) {
throw new I18nBizException(ErrorCodes.AFTER_SALE_WALLET_REFUND_FAIL);
@@ -48,6 +48,7 @@ import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum;
@@ -2750,6 +2751,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
transaction.setBizId(String.valueOf(order.getId()));
transaction.setBizType(WalletBizEnum.PAYMENT);
transaction.setForce(false);
// 设备类型快照:随支付/补偿流水固化为 hard_type,供钱包流水页按设备类型筛选
transaction.setSysType(order.getSysType());
// 钱包底层流水不保存商户,订单调用必须显式传实际商户快照供同事务Outbox使用。
transaction.setSourceMchId(order.getMerchantId());
transaction.setSourceScene("ORDER");
@@ -4051,6 +4054,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
reduceBuild.setBizId(String.valueOf(order.getId()));
reduceBuild.setBizType(WalletBizEnum.PAYMENT);
reduceBuild.setForce(true);
// 设备类型快照:随支付流水固化为 hard_type,供钱包流水页按设备类型筛选
reduceBuild.setSysType(order.getSysType());
// 快速支付同样使用订单实际商户,禁止退化为钱包所有人的默认食堂。
reduceBuild.setSourceMchId(order.getMerchantId());
reduceBuild.setSourceScene("ORDER");
@@ -4953,6 +4958,15 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
car -> Math.multiplyExact(car.getProductCount(), factor),
Math::addExact,
LinkedHashMap::new));
// 部分退款时后台会把未选中的商品以数量 0 回传,售后快照原样保存;
// 远程幂等库存协议拒绝 incrCount=0 的行(报“库存业务参数错误”),发送前统一剔除零增量。
stockChanges.values().removeIf(delta -> delta == 0);
if (stockChanges.isEmpty()) {
// 纯称重等份数为 0 的退款没有份数库存可变更,跳过远程调用,避免被远程参数校验误拒。
log.info("|{}|库存变更增量全为0,跳过远程库存调用,orderId={} isReduce={} afterSaleId={}",
TAG, order.getId(), isReduce, afterSaleId);
return;
}
HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId());
// 带菜谱标记的购物车(App 预约每日菜谱)整单走分池;设备购物车(createCard 不写 cartScene)
@@ -94,8 +94,7 @@ public class HardXfjServiceImpl implements HardXfjService {
* 保持未配置商户的原有查询行为。
*/
private static String resolveQueryCardNumber(String cardNo, Long merchantId) {
if (ObjectUtil.isEmpty(merchantId) || !DECIMAL_UID_MERCHANT_IDS.contains(merchantId)
|| ObjectUtil.isEmpty(cardNo) || !cardNo.matches("\\d+")) {
if (ObjectUtil.isEmpty(merchantId) || !DECIMAL_UID_MERCHANT_IDS.contains(merchantId) || ObjectUtil.isEmpty(cardNo) || !cardNo.matches("\\d+")) {
return cardNo;
}
long value = Long.parseLong(cardNo);
@@ -482,11 +482,16 @@ public class SysUserController {
return R.ok(userService.getExportUsersPage(page, userDTO, DataScope.of()));
}
/**
* 分页查询用户下拉选项
*
* <p>供管理端、移动端用户选择器远程搜索使用,只返回用户基础字段,
* 不回填角色、岗位、身份、人脸和审批信息,也避免下拉场景触发远程流程服务调用。</p>
*/
@ApiCryptoAes
@PostMapping("/select")
public R userPage(@ParameterObject Page page, @RequestBody UserDTO userDTO) {
userDTO.setIsFindCheckUser(true);
return R.ok(userService.getUsersWithRolePage(page, userDTO, null));
return R.ok(userService.getSelectUsersPage(page, userDTO));
}
@@ -68,6 +68,18 @@ public interface SysUserService extends IService<SysUser> {
*/
IPage<UserVO> getBasicUsersPage(Page page, UserDTO userDTO, DataScope dataScope);
/**
* 分页查询用户下拉选项,仅返回用户、账号、手机号等基础字段。
*
* <p>下拉组件不展示角色、身份、人脸和审批信息,也不使用分页总条数,
* 因此该方法只执行一次索引分页查询,不回填任何扩展资料。</p>
*
* @param page 分页参数
* @param userDTO 姓名、手机号、账号关键词及指定用户筛选条件
* @return 仅含基础字段的用户分页结果,total 固定为 0
*/
IPage<UserVO> getSelectUsersPage(Page page, UserDTO userDTO);
/**
* 分页查询导出所需的用户主资料、角色岗位和身份资料,不加载人脸及审批流程。
*
@@ -547,6 +547,24 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
return list;
}
/**
* 分页查询用户下拉选项。
*
* <p>下拉组件只消费用户 ID、姓名、账号和手机号,因此复用轻量用户分页 SQL,
* 不回填角色、岗位、身份、人脸和审批标记;原先的八次串行数据库往返
* (含分页 COUNT 全量扫描当前租户用户索引)和一次远程流程服务调用压缩为一次索引分页查询。</p>
*
* @param page 分页参数
* @param userDTO 姓名、手机号、账号关键词及指定用户筛选条件
* @return 仅含基础字段的用户分页结果,total 固定为 0
*/
@Override
public IPage<UserVO> getSelectUsersPage(Page page, UserDTO userDTO) {
// 下拉组件按关键词远程搜索,不展示总条数,关闭 COUNT 查询。
page.setSearchCount(false);
return baseMapper.getBasicUserPage(page, userDTO, null);
}
/**
* 分页查询导出所需的完整用户主资料并批量补齐身份资料。
*
@@ -357,6 +357,10 @@
LEFT JOIN sys_dept d ON d.dept_id = u.dept_id
<where>
u.del_flag = '0'
<!-- 用户选择器回显已选用户时按单个用户 ID 精确查询。 -->
<if test="query.userId != null">
AND u.user_id = #{query.userId}
</if>
<if test="query.nameOrPhone != null and query.nameOrPhone != ''">
<bind name="basicNameOrPhone" value="'%'+query.nameOrPhone+'%'"/>
AND (u.phone LIKE #{basicNameOrPhone}
@@ -51,6 +51,12 @@ public class WalletBalanceTransactionDto {
*/
private String sysRemark;
/**
* 订单设备类型快照编码(HardTypeEnum.type);订单支付/退款调用传订单 sys_type,
* 随流水固化为 hard_type 供设备类型筛选,非订单调用不传。
*/
private String sysType;
/** 订单实际所属商户ID;账户级操作允许为空并由钱包服务解析当前食堂。 */
private Long sourceMchId;
@@ -43,6 +43,12 @@ public class WalletTransactionDto extends WalletTransactionEntity implements Bas
private List<String> bizTypes;
/**
* 订单设备类型快照编码多选(HardTypeEnum.type),直接按流水固化列 hard_type 过滤。
* 仅支付/退款流水有设备类型快照,其余流水该列为空、天然不参与匹配。
*/
private List<String> sysTypes;
/**
* 财务统计标签筛选值;传入多个字典值时匹配包含任一标签的流水。
*/
@@ -78,4 +78,10 @@ public class WalletRechargeRefundDto {
*/
@Length(max = 256, message = "系统备注长度不能超过 256")
private String sysRemark;
/**
* 原订单设备类型快照编码(HardTypeEnum.type);订单售后退款传订单 sys_type,
* 随退款流水固化为 hard_type 供设备类型筛选,充值退款不传。
*/
private String sysType;
}
@@ -62,6 +62,13 @@ public class WalletTransactionEntity extends Model<WalletTransactionEntity> {
@Schema(description = "关联业务编号")
private String bizId;
/**
* 订单设备类型快照(HardTypeEnum.type);
* 仅支付/退款流水(biz_id 存订单主键)由订单调用方携带,其余流水为空。
*/
@Schema(description = "订单设备类型快照编码")
private String hardType;
/**
* 流水标题
*/
@@ -41,32 +41,36 @@ public class WalletTransactionExportExcelVo extends WalletUserExportExcelVo {
@ExcelProperty(value = {"流水", "标题"}, index = 13)
private String title;
/** 订单设备类型中文名称;充值、转账等非订单流水为空。 */
@ExcelProperty(value = {"流水", "设备类型"}, index = 14)
private String sysTypeName;
/** 本次交易金额,单位为元并保留两位小数,支出保留负数。 */
@ExcelProperty(value = {"流水", "交易金额(元)"}, index = 14)
@ExcelProperty(value = {"流水", "交易金额(元)"}, index = 15)
@NumberFormat("#,##0.00")
private BigDecimal price;
/** 交易完成后的钱包余额,单位为元并保留两位小数。 */
@ExcelProperty(value = {"流水", "交易后余额(元)"}, index = 15)
@ExcelProperty(value = {"流水", "交易后余额(元)"}, index = 16)
@NumberFormat("#,##0.00")
private BigDecimal balance;
/** 财务统计标签原始字典值,多个值使用英文逗号分隔。 */
@ExcelProperty(value = {"流水", "财务标签"}, index = 16)
@ExcelProperty(value = {"流水", "财务标签"}, index = 17)
private String tags;
/** 后台补充的流水业务备注;历史记录允许为空。 */
@ExcelProperty(value = {"流水", "备注"}, index = 17)
@ExcelProperty(value = {"流水", "备注"}, index = 18)
@ColumnWidth(24)
private String remark;
/** 服务端按场景模板自动生成的流水简介(如"在食堂线下消费10元");历史未回填记录允许为空。 */
@ExcelProperty(value = {"流水", "系统备注"}, index = 18)
@ExcelProperty(value = {"流水", "系统备注"}, index = 19)
@ColumnWidth(24)
private String sysRemark;
/** 流水创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */
@ExcelProperty(value = {"流水", "交易时间"}, index = 19)
@ExcelProperty(value = {"流水", "交易时间"}, index = 20)
@ColumnWidth(22)
private String createTime;
}
@@ -10,6 +10,7 @@ import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletInterface;
import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@@ -148,6 +149,43 @@ public class WalletTransactionVo extends WalletTransactionEntity implements Base
private LocalDateTime orderPayTime;
/**
* 订单设备类型快照编码(HardTypeEnum.type);按 biz_id 反查订单回填,
* 充值、转账等非订单流水为空。
*/
private String sysType;
/** 订单设备类型中文名称;编码不在枚举内时回退展示原编码。 */
private String sysTypeName;
/**
* 消费场景摘要(查询时按页批量回填,历史流水全量有效)。
* 订单支付/退款按 biz_id 反查订单:商户名、商品摘要(productDesc 原文);
* 预约单(hourType 非空)才有预约日期与餐次,非预约单 apptUseDate 为下单当天不可展示。
*/
private String merchantName;
/** 商品摘要原文("商品名x数量;商品名x数量;");由客户端截断展示、展开区显示完整。 */
private String productName;
/** 预约用餐日期;仅 hourType 非空的预约单有意义。 */
private LocalDate apptUseDate;
/** 预约餐次编码(HardHourTypeEnum:1早餐/2午餐/3晚餐/4夜间餐);非预约单为空。 */
private String hourType;
/** 充值套餐名;仅充值流水(biz_type=3)回填。 */
private String rechargePackageName;
/** 充值实付金额(分);仅充值流水回填。 */
private Integer rechargePayPrice;
/** 充值赠送金额(分);仅充值流水回填。 */
private Integer rechargeBonusPrice;
/** 转账方向描述(如"饭卡 → 消费钱包");仅转账/转账退款流水回填。 */
private String transferDesc;
/**
* 导出相关
*/
@@ -45,6 +45,8 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper<WalletTransac
.eq(ObjectUtil.isNotEmpty(dto.getDeptId()), WalletTransactionEntity::getDeptId, dto.getDeptId())
.in(ObjectUtil.isNotEmpty(dto.getUserIds()), WalletTransactionEntity::getUserId, dto.getUserIds())
.in(ObjectUtil.isNotEmpty(dto.getBizTypes()), WalletTransactionEntity::getBizType, dto.getBizTypes())
// 设备类型多选:流水固化列,非订单流水该列为空不参与匹配;空集合表示不筛选
.in(ObjectUtil.isNotEmpty(dto.getSysTypes()), WalletTransactionEntity::getHardType, dto.getSysTypes())
.between(
ObjectUtil.isNotEmpty(dto.getCreateTimeList()),
WalletTransactionEntity::getCreateTime,
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.wallet.service.impl;
import com.bsd.pjcloud.common.core.exception.ErrorCodes;
import com.bsd.pjcloud.common.core.exception.I18nBizException;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.io.resource.ResourceUtil;
@@ -32,6 +33,10 @@ import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.PjcloudDefaultDataScopeHandle;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService;
import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageEventEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletSysRemarkTemplate;
@@ -51,7 +56,11 @@ import com.bsd.pjcloud.wallet.api.entity.WalletMessageOutboxEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity;
import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargePackageEntity;
import com.bsd.pjcloud.wallet.api.entity.rule.WalletRuleTransactionEntity;
import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.*;
import com.bsd.pjcloud.wallet.api.vo.mch.WalletMchDeptViewVo;
import com.bsd.pjcloud.wallet.api.vo.transaction.WalletTransactionVo;
@@ -60,6 +69,9 @@ import com.bsd.pjcloud.wallet.mapper.WalletMessageOutboxMapper;
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper;
import com.bsd.pjcloud.wallet.mapper.mch.view.MchDeptViewMapper;
import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeMapper;
import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargePackageMapper;
import com.bsd.pjcloud.wallet.mapper.rule.WalletRuleTransactionMapper;
import com.bsd.pjcloud.wallet.service.MchManageService;
import com.bsd.pjcloud.wallet.service.WalletManageService;
import com.bsd.pjcloud.wallet.service.WalletThirdZyService;
@@ -80,7 +92,9 @@ import org.springframework.validation.annotation.Validated;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -138,7 +152,9 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
private final WalletThirdZyService walletThirdZyService;
private final WalletManageMapper walletManageMapper;
/** 钱包业务消息Outbox Mapper,与钱包流水使用同一数据源和本地事务。 */
/**
* 钱包业务消息Outbox Mapper,与钱包流水使用同一数据源和本地事务。
*/
private final WalletMessageOutboxMapper walletMessageOutboxMapper;
private final WalletTypeMapper walletTypeMapper;
private final MchDeptViewMapper mchDeptViewMapper;
@@ -148,7 +164,9 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
private final RemoteUserService userService;
private final RemoteDeptService deptService;
/** 钱包流水用户身份筛选和展示资料装配器,统一复用 UPMS 权限用户查询。 */
/**
* 钱包流水用户身份筛选和展示资料装配器,统一复用 UPMS 权限用户查询。
*/
private final WalletUserIdentitySupport userIdentitySupport;
/**
@@ -156,6 +174,20 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
*/
private final RemoteUserRelationService remoteUserRelationService;
/**
* 硬件订单远程服务:按 biz_id 批量反查订单回填消费场景摘要(商品/预约/商户)。
*/
private final RemoteHardOrderService remoteHardOrderService;
/** 充值记录 Mapper:充值流水按 biz_id 反查套餐与实付/赠送金额。 */
private final WalletRechargeMapper walletRechargeMapper;
/** 充值套餐 Mapper:按套餐主键批量取套餐名。 */
private final WalletRechargePackageMapper walletRechargePackageMapper;
/** 转账记录 Mapper:转账/转账退款流水按 biz_id 反查源与目标钱包。 */
private final WalletRuleTransactionMapper walletRuleTransactionMapper;
/**
* 项目统一 Redisson 客户端,用于协调并发扣款、补偿和记录迟到扣款禁止标记。
*/
@@ -199,6 +231,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
if (StrUtil.isBlank(entity.getSysRemark())) {
entity.setSysRemark(WalletSysRemarkTemplate.defaultRemark(entity.getBizType(), entity.getPrice()));
}
fillHardTypeFromContext(entity);
boolean saved = baseMapper.insert(entity) > 0;
if (saved) {
createMessageOutbox(entity);
@@ -206,6 +239,26 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
return saved;
}
/**
* 从当前调用的订单上下文固化设备类型快照到流水 hard_type 列。
*
* <p>订单支付/退款调用在 Controller 层经 {@link WalletMessageContextHolder#supplyWith}
* 绑定明细(含 sysType),这里按钱包+业务号+业务类型取回落库;非订单调用
* (充值、转账等)没有上下文,hard_type 保持为空。</p>
*
* @param entity 待持久化钱包流水
*/
private void fillHardTypeFromContext(WalletTransactionEntity entity) {
if (ObjectUtil.isNotEmpty(entity.getHardType())) {
return;
}
WalletBalanceTransactionDto context = WalletMessageContextHolder.find(
entity.getWalletId(), entity.getBizId(), entity.getBizType());
if (context != null) {
entity.setHardType(context.getSysType());
}
}
/**
* 根据新钱包流水和当前调用上下文创建不可变消息快照;来源商户无法解析时保留空值,由消息中心仅入本人收件箱。
*
@@ -263,7 +316,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
/**
* 解析钱包事件来源商户:订单优先使用调用方实际商户,账户级事件按钱包所有人部门取当前食堂。
*
* @param context 当前远程调用的可选订单上下文
* @param context 当前远程调用的可选订单上下文
* @param walletOwnerDeptId 钱包所有人部门ID
* @return 来源商户,无法可靠解析时为空
*/
@@ -602,7 +655,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 按稳定业务键顺序获取整批订单交易锁,并把释放动作绑定到当前数据库事务完成之后。
* 这样补偿返回时可以确定同一订单的扣款请求不在并发执行,数据库行锁也不会在 Redis 锁之前释放。
*
* @param payments 已完成基础校验的支付或补偿明细
* @param payments 已完成基础校验的支付或补偿明细
* @param rollbackMarkerKeys 当前补偿事务写入、需要在事务回滚时删除的取消标记键;扣款场景传空列表
*/
private void acquirePaymentOperationLocks(List<WalletBalanceTransactionDto> payments, List<String> rollbackMarkerKeys) {
@@ -629,7 +682,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
/**
* 将分布式锁释放绑定到事务完成阶段,确保本地 PAYMENT/REFUND 流水提交前同一业务号不会被另一请求处理。
*
* @param locks 当前线程已经获取的订单交易锁
* @param locks 当前线程已经获取的订单交易锁
* @param rollbackMarkerKeys 当前补偿事务写入的取消标记键,事务回滚时必须在释放锁前删除
*/
private void registerPaymentOperationLockRelease(List<RLock> locks, List<String> rollbackMarkerKeys) {
@@ -701,7 +754,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
/**
* 记录有期限的订单支付取消终态。补偿接口只在存在 PAYMENT 时写 REFUND,标记本身不参与财务流水统计。
*
* @param payment 单笔已进入补偿流程的订单支付明细
* @param payment 单笔已进入补偿流程的订单支付明细
* @param rollbackMarkerKeys 当前事务回滚时需要删除的标记键集合
*/
private void markPaymentCancelled(WalletBalanceTransactionDto payment, List<String> rollbackMarkerKeys) {
@@ -756,9 +809,9 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 锁定钱包后按钱包、业务号和业务类型查询已有流水,解决远程调用成功但调用方未落状态时的重复入账。
*
* @param walletId 目标钱包 ID
* @param price 增加金额,单位分
* @param bizId 稳定业务号
* @param bizType 钱包业务类型
* @param price 增加金额,单位分
* @param bizId 稳定业务号
* @param bizType 钱包业务类型
* @return 首次创建或已经存在且金额一致的流水
*/
@Override
@@ -772,11 +825,11 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 新建流水时同步保存财务标签和流水备注。
*
* @param walletId 目标钱包 ID
* @param price 增加金额,单位分
* @param bizId 稳定业务号
* @param bizType 钱包业务类型
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @param price 增加金额,单位分
* @param bizId 稳定业务号
* @param bizType 钱包业务类型
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @return 首次创建或已经存在且金额一致的流水
*/
@Override
@@ -789,12 +842,12 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 锁定钱包后按钱包、业务号和业务类型查询已有流水,解决远程调用成功但调用方未落状态时的重复入账;
* 新建流水时同步保存财务标签、流水备注和系统备注。
*
* @param walletId 目标钱包 ID
* @param price 增加金额,单位分
* @param bizId 稳定业务号
* @param bizType 钱包业务类型
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @param walletId 目标钱包 ID
* @param price 增加金额,单位分
* @param bizId 稳定业务号
* @param bizType 钱包业务类型
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
* @return 首次创建或已经存在且金额一致的流水
*/
@@ -814,7 +867,8 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
}
switch (bizType) {
case UPDATE, RECHARGE, BATCH, TRANSFER_ACCOUNTS -> walletManageMapper.updateWhenRecharge(walletId, price);
case REFUND, TRANSFER_ACCOUNTS_REFUND, RETURN_CANTEEN -> walletManageMapper.updateWhenConsumptionRefund(walletId, price);
case REFUND, TRANSFER_ACCOUNTS_REFUND, RETURN_CANTEEN ->
walletManageMapper.updateWhenConsumptionRefund(walletId, price);
default -> throw new UnsupportedOperationException("待实现");
}
WalletTransactionEntity transaction = buildWalletTransaction(wallet, walletId, price,
@@ -831,10 +885,10 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 增加钱包余额并将来源业务的财务标签同步到最终流水,供后续列表筛选和金额统计。
*
* @param walletId 钱包 id
* @param price 增加金额,单位分
* @param bizId 业务关联 id
* @param bizType 业务关联分类
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param price 增加金额,单位分
* @param bizId 业务关联 id
* @param bizType 业务关联分类
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @return 已保存的钱包流水
*/
@Override
@@ -847,11 +901,11 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 增加钱包余额,并在同一笔最终流水中保存财务标签和人工备注。
*
* @param walletId 钱包 id
* @param price 增加金额,单位分
* @param bizId 业务关联 id
* @param bizType 业务关联分类
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @param price 增加金额,单位分
* @param bizId 业务关联 id
* @param bizType 业务关联分类
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @return 已保存的钱包流水
*/
@Override
@@ -863,12 +917,12 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
/**
* 增加钱包余额,并在同一笔最终流水中保存财务标签、人工备注和系统备注。
*
* @param walletId 钱包 id
* @param price 增加金额,单位分
* @param bizId 业务关联 id
* @param bizType 业务关联分类
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @param walletId 钱包 id
* @param price 增加金额,单位分
* @param bizId 业务关联 id
* @param bizType 业务关联分类
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
* @return 已保存的钱包流水
*/
@@ -1001,8 +1055,8 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 重置钱包余额并将来源导入记录的财务标签同步到重置流水。
*
* @param walletId 钱包 id
* @param bizId 业务关联 id
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param bizId 业务关联 id
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @return 重置成功返回 {@code true}
*/
@Override
@@ -1039,8 +1093,8 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 锁定钱包行后按业务号查重,再重置余额;预约执行等可重试场景重复调用不会产生第二笔重置流水。
*
* @param walletId 钱包 id
* @param bizId 业务关联 id,与重置业务类型共同确定幂等范围
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param bizId 业务关联 id,与重置业务类型共同确定幂等范围
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @return 重置成功或此前已经重置过返回 {@code true}
*/
@Override
@@ -1092,8 +1146,8 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
/**
* 分页查询统一导出所需的流水业务资料,并跳过普通列表的 UPMS 用户回填。
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份和业务场景的流水查询条件
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份和业务场景的流水查询条件
* @param dataScope 当前操作者的数据权限范围
* @return 已装配部门、钱包、钱包类型及订单号的流水分页
*/
@@ -1108,15 +1162,15 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
/**
* 分页查询个人充值统计导出流水,并复用导出分页的部门、钱包和钱包类型装配逻辑。
*
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份和充值统计筛选条件
* @param dataScope 当前操作者的数据权限范围
* @param page 分页参数;仅第一页执行总数查询
* @param dto 已冻结身份和充值统计筛选条件
* @param dataScope 当前操作者的数据权限范围
* @param excludedDeptIds 统计场景固定排除的部门主键
* @return 不重复查询 UPMS 用户资料的个人充值统计导出分页
*/
@Override
public Page<WalletTransactionVo> scopeRechargePersonExportPage(Page page, WalletTransactionDto dto,
DataScope dataScope, List<Long> excludedDeptIds) {
DataScope dataScope, List<Long> excludedDeptIds) {
Page<WalletTransactionVo> list = baseMapper.scopeRechargePersonExportPage(page, dto, dataScope,
excludedDeptIds);
disposeExportList(list.getRecords(), dto);
@@ -1127,16 +1181,16 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 分页查询本人或有效被监护人的 EAT 饭卡流水。目标用户与钱包均由服务端重新解析,
* 避免客户端通过篡改用户 ID、用户集合或钱包 ID 越权读取其他账户流水。
*
* @param loginUserId 当前登录用户 ID
* @param loginUserId 当前登录用户 ID
* @param targetUserId 目标用户 ID,仅允许本人或当前有效被监护人
* @param page 分页参数
* @param dto 收支类型等可选筛选条件
* @param page 分页参数
* @param dto 收支类型等可选筛选条件
* @return 目标饭卡流水分页;未开通 EAT 饭卡时返回空分页
*/
@Override
public Page<WalletTransactionVo> pageSelfOrGuardianWardTransactions(Long loginUserId, Long targetUserId,
Page<WalletTransactionVo> page,
WalletTransactionDto dto) {
Page<WalletTransactionVo> page,
WalletTransactionDto dto) {
validateSelfOrGuardianWardUser(loginUserId, targetUserId);
WalletManageEntity wallet = walletManageService.getWallet(targetUserId, WalletTypeEnum.EAT);
if (wallet == null) {
@@ -1154,7 +1208,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 校验目标用户是否为登录用户本人或其当前有效被监护人。关系服务异常时必须拒绝访问,
* 不能在授权状态未知时降级放行敏感资金流水。
*
* @param loginUserId 当前登录用户 ID
* @param loginUserId 当前登录用户 ID
* @param targetUserId 目标用户 ID
*/
private void validateSelfOrGuardianWardUser(Long loginUserId, Long targetUserId) {
@@ -1438,7 +1492,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 装配普通钱包流水列表的用户、身份及业务展示资料。
*
* @param list 当前列表分页记录
* @param dto 当前流水查询条件,用于判断是否解析订单号
* @param dto 当前流水查询条件,用于判断是否解析订单号
*/
private void disposeList(List<WalletTransactionVo> list, WalletTransactionDto dto) {
if (ObjectUtil.isEmpty(list)) {
@@ -1453,7 +1507,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
* 装配钱包流水导出与普通列表共用的部门、钱包、钱包类型及订单号资料。
*
* @param list 当前分页记录;为空时不执行远程或数据库查询
* @param dto 当前流水查询条件,用于判断是否解析订单号
* @param dto 当前流水查询条件,用于判断是否解析订单号
*/
private void disposeExportList(List<WalletTransactionVo> list, WalletTransactionDto dto) {
if (ObjectUtil.isNotEmpty(list)) {
@@ -1495,13 +1549,17 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
}
});
}
// 设备类型展示列:纯内存名称映射,非订单流水该列为空由前端按占位符展示
fillSysTypeColumns(list);
// 消费场景摘要:商品/预约/商户/充值套餐/转账方向,让用户知道这笔钱花在哪
fillSceneSummary(list);
}
}
/**
* 将身份条件预筛选为用户主键,避免钱包流水 SQL 因空身份集合而放宽结果。
*
* @param dto 钱包流水查询条件
* @param dto 钱包流水筛选条件
*/
@Override
public void prepareIdentityQuery(WalletTransactionDto dto) {
@@ -1516,4 +1574,346 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
dto.setHasFace(null);
}
/**
* 回填设备类型展示列:hard_type 非空行做纯内存中文名称映射,无远程查询;
* 充值、转账等非订单流水该列为空,由前端按占位符展示。
*
* @param list 当前分页记录
*/
private void fillSysTypeColumns(List<WalletTransactionVo> list) {
if (ObjectUtil.isEmpty(list)) {
return;
}
list.stream()
.filter(row -> ObjectUtil.isNotEmpty(row.getHardType()))
.forEach(row -> {
row.setSysType(row.getHardType());
row.setSysTypeName(resolveHardSysTypeName(row.getHardType()));
});
}
/**
* 设备类型编码转中文名称;编码不在枚举内(历史脏数据)时回退展示原编码。
*/
private static String resolveHardSysTypeName(String sysType) {
return Arrays.stream(HardTypeEnum.values())
.filter(type -> ObjectUtil.equals(type.getType(), sysType))
.findFirst()
.map(HardTypeEnum::getDescription)
.orElse(sysType);
}
/** 消费场景装配的单批远程/数据库查询上限,与系统备注回填的 500 一批口径一致。 */
private static final int SCENE_BACKFILL_BATCH_SIZE = 500;
/**
* 按页回填消费场景摘要,让用户明确知道每笔钱的去向。
*
* <p>按业务类型分流批量装配:订单支付/退款(hard_type 非空)反查硬件订单取商品摘要、
* 预约日期餐次和商户名;充值反查充值记录取套餐与实付/赠送;转账反查转账记录拼
* 「源钱包 → 目标钱包」。充值退款(hard_type 为空)沿用系统备注文案不额外装配。
* 各查询失败均降级留空,不阻断列表返回。</p>
*
* @param list 当前分页记录
*/
private void fillSceneSummary(List<WalletTransactionVo> list) {
if (ObjectUtil.isEmpty(list)) {
return;
}
fillOrderSceneSummary(list.stream().filter(this::isOrderSceneRow).toList());
fillRechargeSceneSummary(list.stream()
.filter(row -> WalletBizEnum.RECHARGE.getCode().equals(row.getBizType())).toList());
fillTransferSceneSummary(list.stream()
.filter(row -> WalletBizEnum.TRANSFER_ACCOUNTS.getCode().equals(row.getBizType())
|| WalletBizEnum.TRANSFER_ACCOUNTS_REFUND.getCode().equals(row.getBizType()))
.toList());
}
/**
* 订单场景行:支付/退款流水且固化过设备类型(区分于充值退款,后者的 biz_id 是售后单号)。
*/
private boolean isOrderSceneRow(WalletTransactionVo row) {
return ObjectUtil.isNotEmpty(row.getHardType())
&& (WalletBizEnum.PAYMENT.getCode().equals(row.getBizType())
|| WalletBizEnum.REFUND.getCode().equals(row.getBizType()));
}
/**
* 订单场景:支付流水 biz_id 即订单主键直接反查;退款流水 biz_id 是售后单主键
* (订单售后退款经 WalletRechargeServiceImpl.refund 落库),需先反查售后单再经
* orderId 取原订单。查到订单后回填商品摘要、预约信息、商户名与 orderId(供 App 跳转订单详情)。
*/
private void fillOrderSceneSummary(List<WalletTransactionVo> rows) {
if (rows.isEmpty()) {
return;
}
// 支付行:biz_id(订单主键) -> 订单
Map<Long, HardOrderEntity> paymentOrderMap = fetchOrdersByBizIds(rows.stream()
.filter(row -> WalletBizEnum.PAYMENT.getCode().equals(row.getBizType())).toList());
// 退款行:biz_id(售后单主键) -> 售后单 orderId -> 订单
Map<Long, HardOrderEntity> refundOrderMap = fetchOrdersByAfterSaleIds(rows.stream()
.filter(row -> WalletBizEnum.REFUND.getCode().equals(row.getBizType())).toList());
if (paymentOrderMap.isEmpty() && refundOrderMap.isEmpty()) {
return;
}
Map<Long, HardOrderEntity> allOrderMap = new HashMap<>(paymentOrderMap);
allOrderMap.putAll(refundOrderMap);
List<Long> merchantIds = allOrderMap.values().stream()
.map(HardOrderEntity::getMerchantId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, String> merchantNameMap = merchantIds.isEmpty() ? Map.of() : fetchMerchantNames(merchantIds);
rows.forEach(row -> {
HardOrderEntity order = resolveRowOrder(row, paymentOrderMap, refundOrderMap);
if (order == null) {
return;
}
row.setOrderId(order.getId());
row.setProductName(order.getProductDesc());
row.setApptUseDate(order.getApptUseDate());
row.setHourType(order.getHourType());
row.setMerchantName(order.getMerchantId() == null ? null : merchantNameMap.get(order.getMerchantId()));
});
}
/**
* 解析流水行关联的原订单:支付行按 biz_id(订单主键) 命中支付订单集合;
* 退款行按 biz_id(售后单主键) 命中经售后反查的订单集合。
*/
private HardOrderEntity resolveRowOrder(WalletTransactionVo row, Map<Long, HardOrderEntity> paymentOrderMap,
Map<Long, HardOrderEntity> refundOrderMap) {
Long bizId = parseNumericBizId(row.getBizId());
if (bizId == null) {
return null;
}
return WalletBizEnum.PAYMENT.getCode().equals(row.getBizType())
? paymentOrderMap.get(bizId) : refundOrderMap.get(bizId);
}
/**
* 支付流水:biz_id 为纯数字订单主键时批量反查订单;杂音业务号不参与。
*/
private Map<Long, HardOrderEntity> fetchOrdersByBizIds(List<WalletTransactionVo> rows) {
List<Long> orderIds = rows.stream()
.map(WalletTransactionVo::getBizId)
.map(WalletTransactionServiceImpl::parseNumericBizId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (orderIds.isEmpty()) {
return Map.of();
}
return fetchHardOrdersByIds(orderIds);
}
/**
* 退款流水:biz_id 为纯数字售后单主键时先反查售后单(保留 售后主键→订单 的行级映射),
* 再按 orderId 批量取原订单;充值售后 orderId 无效查不到订单,天然只命中订单退款。
* 返回集合的键为售后单主键(即退款流水 biz_id),供行级直接命中。
*/
private Map<Long, HardOrderEntity> fetchOrdersByAfterSaleIds(List<WalletTransactionVo> rows) {
List<Long> afterSaleIds = rows.stream()
.map(WalletTransactionVo::getBizId)
.map(WalletTransactionServiceImpl::parseNumericBizId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (afterSaleIds.isEmpty()) {
return Map.of();
}
Map<Long, Long> saleOrderMap = new HashMap<>(afterSaleIds.size());
for (List<Long> idBatch : ListUtil.partition(afterSaleIds, SCENE_BACKFILL_BATCH_SIZE)) {
try {
R<List<HardAfterSaleEntity>> result = remoteHardOrderService.listAfterSalesByIds(idBatch);
if (result == null || !result.isOk() || ObjectUtil.isEmpty(result.getData())) {
continue;
}
result.getData().stream()
.filter(sale -> sale.getId() != null && sale.getOrderId() != null)
.forEach(sale -> saleOrderMap.put(sale.getId(), sale.getOrderId()));
} catch (Exception exception) {
log.warn("|{}|退款消费场景反查售后单失败,批次大小:|{}|", TAG, idBatch.size(), exception);
}
}
if (saleOrderMap.isEmpty()) {
return Map.of();
}
Map<Long, HardOrderEntity> orderMap = fetchHardOrdersByIds(List.copyOf(saleOrderMap.values()));
// 键换回售后单主键,与退款流水 biz_id 对齐
Map<Long, HardOrderEntity> saleOrderRef = new HashMap<>(saleOrderMap.size());
saleOrderMap.forEach((saleId, orderId) -> {
HardOrderEntity order = orderMap.get(orderId);
if (order != null) {
saleOrderRef.put(saleId, order);
}
});
return saleOrderRef;
}
/**
* 批量查询硬件订单;远程失败仅告警并返回已命中部分,不阻断流水列表。
*/
private Map<Long, HardOrderEntity> fetchHardOrdersByIds(List<Long> orderIds) {
Map<Long, HardOrderEntity> orderMap = new HashMap<>(orderIds.size());
for (List<Long> idBatch : ListUtil.partition(orderIds, SCENE_BACKFILL_BATCH_SIZE)) {
try {
R<List<HardOrderEntity>> result = remoteHardOrderService.getByIds(idBatch);
if (result != null && result.isOk() && ObjectUtil.isNotEmpty(result.getData())) {
result.getData().forEach(order -> orderMap.put(order.getId(), order));
}
} catch (Exception exception) {
log.warn("|{}|消费场景回填查询订单失败,批次大小:|{}|", TAG, idBatch.size(), exception);
}
}
return orderMap;
}
/**
* 批量查询商户名(wallet 本地);失败降级返回空映射。
*/
private Map<Long, String> fetchMerchantNames(List<Long> merchantIds) {
try {
List<MchManageVo> merchants = mchManageService.list(MchManageDto.builder().ids(merchantIds).build());
if (ObjectUtil.isEmpty(merchants)) {
return Map.of();
}
return merchants.stream()
.filter(merchant -> merchant.getId() != null)
.collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left));
} catch (Exception exception) {
log.warn("|{}|消费场景回填查询商户失败,商户数:|{}|", TAG, merchantIds.size(), exception);
return Map.of();
}
}
/**
* 充值场景:biz_id 反查充值记录,回填套餐名与实付/赠送金额(到账金额即流水 price)。
*/
private void fillRechargeSceneSummary(List<WalletTransactionVo> rows) {
if (rows.isEmpty()) {
return;
}
List<Long> rechargeIds = rows.stream()
.map(WalletTransactionVo::getBizId)
.map(WalletTransactionServiceImpl::parseNumericBizId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (rechargeIds.isEmpty()) {
return;
}
Map<Long, WalletRechargeEntity> rechargeMap = walletRechargeMapper.selectBatchIds(rechargeIds).stream()
.collect(Collectors.toMap(WalletRechargeEntity::getId, recharge -> recharge, (left, right) -> left));
if (rechargeMap.isEmpty()) {
return;
}
List<Long> packageIds = rechargeMap.values().stream()
.map(WalletRechargeEntity::getPackageId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, String> packageNameMap = packageIds.isEmpty() ? Map.of()
: walletRechargePackageMapper.selectBatchIds(packageIds).stream()
.filter(pkg -> ObjectUtil.isNotEmpty(pkg.getName()))
.collect(Collectors.toMap(WalletRechargePackageEntity::getId,
WalletRechargePackageEntity::getName, (left, right) -> left));
rows.forEach(row -> {
Long rechargeId = parseNumericBizId(row.getBizId());
WalletRechargeEntity recharge = rechargeId == null ? null : rechargeMap.get(rechargeId);
if (recharge == null) {
return;
}
row.setRechargePackageName(recharge.getPackageId() == null ? null
: packageNameMap.get(recharge.getPackageId()));
row.setRechargePayPrice(recharge.getPayPrice());
row.setRechargeBonusPrice(recharge.getBonusPrice());
});
}
/**
* 转账场景:biz_id 反查转账记录,按源/目标钱包的钱包类型拼「饭卡 → 消费钱包」方向描述。
*/
private void fillTransferSceneSummary(List<WalletTransactionVo> rows) {
if (rows.isEmpty()) {
return;
}
List<Long> ruleIds = rows.stream()
.map(WalletTransactionVo::getBizId)
.map(WalletTransactionServiceImpl::parseNumericBizId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (ruleIds.isEmpty()) {
return;
}
Map<Long, WalletRuleTransactionEntity> ruleMap = walletRuleTransactionMapper.selectBatchIds(ruleIds).stream()
.collect(Collectors.toMap(WalletRuleTransactionEntity::getId, rule -> rule, (left, right) -> left));
if (ruleMap.isEmpty()) {
return;
}
Set<Long> walletIds = new HashSet<>();
ruleMap.values().forEach(rule -> {
if (rule.getSourceWalletId() != null) {
walletIds.add(rule.getSourceWalletId());
}
if (rule.getTargetWalletId() != null) {
walletIds.add(rule.getTargetWalletId());
}
});
Map<Long, String> walletTypeNameMap = resolveWalletTypeNames(new ArrayList<>(walletIds));
rows.forEach(row -> {
Long ruleId = parseNumericBizId(row.getBizId());
WalletRuleTransactionEntity rule = ruleId == null ? null : ruleMap.get(ruleId);
if (rule == null) {
return;
}
String fromName = walletTypeNameMap.getOrDefault(rule.getSourceWalletId(), "钱包");
String toName = walletTypeNameMap.getOrDefault(rule.getTargetWalletId(), "钱包");
row.setTransferDesc(String.format("%s → %s", fromName, toName));
});
}
/**
* 按钱包主键批量解析钱包类型名称;类型缺失时回退「钱包」。
*/
private Map<Long, String> resolveWalletTypeNames(List<Long> walletIds) {
if (walletIds.isEmpty()) {
return Map.of();
}
List<WalletManageEntity> wallets = walletManageMapper.selectBatchIds(walletIds);
if (ObjectUtil.isEmpty(wallets)) {
return Map.of();
}
List<Long> typeIds = wallets.stream()
.map(WalletManageEntity::getWalletTypeId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, String> typeNameMap = typeIds.isEmpty() ? Map.of()
: walletTypeMapper.selectBatchIds(typeIds).stream()
.filter(type -> ObjectUtil.isNotEmpty(type.getName()))
.collect(Collectors.toMap(WalletTypeEntity::getId, WalletTypeEntity::getName,
(left, right) -> left));
return wallets.stream()
.filter(wallet -> wallet.getId() != null)
.collect(Collectors.toMap(WalletManageEntity::getId,
wallet -> typeNameMap.getOrDefault(wallet.getWalletTypeId(), "钱包"),
(left, right) -> left));
}
/**
* 解析流水 biz_id 中的数字主键;非数字业务号(退款单号、回滚标记等)返回 null。
*/
private static Long parseNumericBizId(String bizId) {
if (ObjectUtil.isEmpty(bizId)) {
return null;
}
try {
return Long.valueOf(bizId);
} catch (NumberFormatException exception) {
return null;
}
}
}
@@ -721,6 +721,7 @@ public class WalletUserExportServiceImpl implements WalletUserExportService {
row.setWalletTypeName(source.getWalletTypeName());
row.setCardNumber(source.getWalletCardNumber());
row.setTitle(source.getTitle());
row.setSysTypeName(source.getSysTypeName());
row.setPrice(formatPrice(source.getPrice()));
row.setBalance(formatPrice(source.getBalance()));
row.setTags(source.getTags());
@@ -1173,6 +1174,7 @@ public class WalletUserExportServiceImpl implements WalletUserExportService {
BeanUtils.copyProperties(source, target);
target.setUserIds(copyList(source.getUserIds()));
target.setBizTypes(copyList(source.getBizTypes()));
target.setSysTypes(copyList(source.getSysTypes()));
target.setTagList(copyList(source.getTagList()));
target.setTotalDpetIds(copyList(source.getTotalDpetIds()));
target.setDeptIds(copyList(source.getDeptIds()));
@@ -65,6 +65,9 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
import com.bsd.pjcloud.wallet.support.WalletMessageContextHolder;
/**
* 会员钱包充值
@@ -331,10 +334,28 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
return resumeOrValidateExistingRefund(dto, existing);
}
// 增加金额;售后退款调用方渲染的系统备注为空时由钱包服务按退款类型兜底
walletTransactionService.addWalletBalance(wallet.getId(), dto.getPrice(), String.valueOf(dto.getAfterSaleId()), WalletBizEnum.REFUND, null, null, dto.getSysRemark());
refundWithSceneContext(wallet.getId(), dto);
return BeanUtils.toBean(recharge, WalletRechargeVo.class);
}
/**
* 售后退款入账并固化设备类型快照:订单售后退款经调用上下文携带订单 sysType,
* 由流水保存逻辑固化为 hard_type;充值退款不传 sysType,hard_type 保持为空。
*
* @param walletId 退款到账的钱包 ID
* @param dto 售后退款请求
*/
private void refundWithSceneContext(Long walletId, WalletRechargeRefundDto dto) {
WalletBalanceTransactionDto refundContext = new WalletBalanceTransactionDto();
refundContext.setWalletId(walletId);
refundContext.setBizId(String.valueOf(dto.getAfterSaleId()));
refundContext.setBizType(WalletBizEnum.REFUND);
refundContext.setSysType(dto.getSysType());
WalletMessageContextHolder.supplyWith(List.of(refundContext),
() -> walletTransactionService.addWalletBalance(walletId, dto.getPrice(),
String.valueOf(dto.getAfterSaleId()), WalletBizEnum.REFUND, null, null, dto.getSysRemark()));
}
/**
* 对已存在退款记录区分“幂等重试”和“人工冲正后的再次同意”。退款状态为空兼容历史成功记录,
* 状态为未售后时只在当前钱包行锁内重新入账一次,并恢复成功状态。
@@ -362,8 +383,7 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
throw new RuntimeException("钱包退款冲正流水金额不一致");
}
walletTransactionService.addWalletBalance(existing.getWalletId(), dto.getPrice(),
String.valueOf(dto.getAfterSaleId()), WalletBizEnum.REFUND, null, null, dto.getSysRemark());
refundWithSceneContext(existing.getWalletId(), dto);
LocalDateTime refundTime = LocalDateTime.now();
WalletRechargeEntity update = WalletRechargeEntity.builder().id(existing.getId())
.refundStatus(HardRefundStatusEnum.SUCCESS.getStatus()).refundTime(refundTime).build();
@@ -0,0 +1,334 @@
package com.bsd.pjcloud.wallet.service.impl;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService;
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargePackageEntity;
import com.bsd.pjcloud.wallet.api.entity.rule.WalletRuleTransactionEntity;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.transaction.WalletTransactionVo;
import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper;
import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeMapper;
import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargePackageMapper;
import com.bsd.pjcloud.wallet.mapper.rule.WalletRuleTransactionMapper;
import com.bsd.pjcloud.wallet.service.MchManageService;
import com.bsd.pjcloud.wallet.support.WalletMessageContextHolder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 设备类型快照固化与消费场景摘要回填测试:订单/充值/转账分流装配、充值退款跳过、远程失败降级。
*/
@ExtendWith(MockitoExtension.class)
class WalletTransactionSysTypeFilterTest {
/** 被测钱包流水服务,设备类型场景仅需纯内存逻辑,其余依赖由 Mockito 注入空值。 */
@InjectMocks
private WalletTransactionServiceImpl service;
/** 硬件订单远程服务 Mock,用于订单场景摘要回填。 */
@Mock
private RemoteHardOrderService remoteHardOrderService;
/** 商户服务 Mock,用于订单场景商户名回填。 */
@Mock
private MchManageService mchManageService;
/** 充值记录 Mapper Mock。 */
@Mock
private WalletRechargeMapper walletRechargeMapper;
/** 充值套餐 Mapper Mock。 */
@Mock
private WalletRechargePackageMapper walletRechargePackageMapper;
/** 转账记录 Mapper Mock。 */
@Mock
private WalletRuleTransactionMapper walletRuleTransactionMapper;
/** 钱包 Mapper Mock,用于转账方向解析。 */
@Mock
private WalletManageMapper walletManageMapper;
/** 钱包类型 Mapper Mock,用于转账方向解析。 */
@Mock
private WalletTypeMapper walletTypeMapper;
/**
* 订单调用经上下文绑定明细后,新流水应固化订单携带的设备类型快照。
*/
@Test
void orderContextShouldFillHardTypeOnSave() {
WalletBalanceTransactionDto context = new WalletBalanceTransactionDto();
context.setWalletId(20L);
context.setBizId("101");
context.setBizType(WalletBizEnum.PAYMENT);
context.setSysType(HardTypeEnum.COFFEE.getType());
WalletTransactionEntity entity = transaction(20L, "101", WalletBizEnum.PAYMENT.getCode());
WalletMessageContextHolder.supplyWith(List.of(context), () -> {
invokeFillHardType(entity);
return null;
});
assertEquals(HardTypeEnum.COFFEE.getType(), entity.getHardType());
}
/**
* 非订单调用(充值、转账等)没有上下文,hard_type 保持为空。
*/
@Test
void nonOrderCallShouldLeaveHardTypeEmpty() {
WalletTransactionEntity entity = transaction(20L, "201", WalletBizEnum.RECHARGE.getCode());
invokeFillHardType(entity);
assertNull(entity.getHardType());
}
/**
* 调用方已显式设置 hard_type 时不被上下文覆盖。
*/
@Test
void presetHardTypeShouldNotBeOverridden() {
WalletBalanceTransactionDto context = new WalletBalanceTransactionDto();
context.setWalletId(20L);
context.setBizId("101");
context.setBizType(WalletBizEnum.PAYMENT);
context.setSysType(HardTypeEnum.COFFEE.getType());
WalletTransactionEntity entity = transaction(20L, "101", WalletBizEnum.PAYMENT.getCode());
entity.setHardType(HardTypeEnum.EY_PRINT.getType());
WalletMessageContextHolder.supplyWith(List.of(context), () -> {
invokeFillHardType(entity);
return null;
});
assertEquals(HardTypeEnum.EY_PRINT.getType(), entity.getHardType());
}
/**
* 展示列回填:hard_type 非空行做本地名称映射,空行(非订单流水)保持为空。
*/
@Test
void fillSysTypeColumnsShouldMapNamesLocally() {
WalletTransactionVo payment = vo(WalletBizEnum.PAYMENT.getCode(), HardTypeEnum.COFFEE.getType());
WalletTransactionVo recharge = vo(WalletBizEnum.RECHARGE.getCode(), null);
WalletTransactionVo legacy = vo(WalletBizEnum.PAYMENT.getCode(), "99");
ReflectionTestUtils.invokeMethod(service, "fillSysTypeColumns", List.of(payment, recharge, legacy));
assertEquals(HardTypeEnum.COFFEE.getType(), payment.getSysType());
assertEquals("咖啡机", payment.getSysTypeName());
assertNull(recharge.getSysTypeName());
// 编码不在枚举内的历史脏数据回退展示原编码
assertEquals("99", legacy.getSysTypeName());
}
/**
* 订单场景摘要:支付行反查订单回填商品/预约/商户;充值退款行(hard_type 空)不触发订单查询。
*/
@Test
void orderSceneShouldFillProductApptAndMerchant() {
WalletTransactionVo payment = vo(WalletBizEnum.PAYMENT.getCode(), HardTypeEnum.CANTEEN.getType());
payment.setBizId("901");
WalletTransactionVo rechargeRefund = vo(WalletBizEnum.REFUND.getCode(), null);
rechargeRefund.setBizId("refund_1776925896139LIjX0rUNes");
HardOrderEntity order = new HardOrderEntity();
order.setId(901L);
order.setProductDesc("红烧肉套餐x1;米饭x1;");
order.setApptUseDate(LocalDate.of(2026, 10, 13));
order.setHourType("2");
order.setMerchantId(30L);
when(remoteHardOrderService.getByIds(anyList())).thenReturn(R.ok(List.of(order)));
MchManageVo merchant = new MchManageVo();
merchant.setId(30L);
merchant.setName("三食堂");
when(mchManageService.list(any(MchManageDto.class))).thenReturn(List.of(merchant));
ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(payment, rechargeRefund));
assertEquals("红烧肉套餐x1;米饭x1;", payment.getProductName());
assertEquals(LocalDate.of(2026, 10, 13), payment.getApptUseDate());
assertEquals("2", payment.getHourType());
assertEquals("三食堂", payment.getMerchantName());
assertEquals(901L, payment.getOrderId());
assertNull(rechargeRefund.getProductName());
}
/**
* 订单退款场景:biz_id 是售后单主键,经售后单 orderId 反查原订单回填摘要与 orderId。
*/
@Test
void refundSceneShouldResolveOrderThroughAfterSale() {
WalletTransactionVo refund = vo(WalletBizEnum.REFUND.getCode(), HardTypeEnum.COFFEE.getType());
refund.setBizId("801");
HardAfterSaleEntity sale = new HardAfterSaleEntity();
sale.setId(801L);
sale.setOrderId(902L);
when(remoteHardOrderService.listAfterSalesByIds(anyList())).thenReturn(R.ok(List.of(sale)));
HardOrderEntity order = new HardOrderEntity();
order.setId(902L);
order.setProductDesc("美式咖啡x1;");
order.setMerchantId(31L);
when(remoteHardOrderService.getByIds(anyList())).thenReturn(R.ok(List.of(order)));
MchManageVo merchant = new MchManageVo();
merchant.setId(31L);
merchant.setName("楼下咖啡");
when(mchManageService.list(any(MchManageDto.class))).thenReturn(List.of(merchant));
ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(refund));
assertEquals("美式咖啡x1;", refund.getProductName());
assertEquals("楼下咖啡", refund.getMerchantName());
assertEquals(902L, refund.getOrderId());
}
/**
* 充值售后退款:售后单 orderId 关联不到订单支付流水时不回填摘要(hard_type 也保持为空)。
*/
@Test
void refundSceneWithoutOrderShouldLeaveSummaryEmpty() {
WalletTransactionVo rechargeRefund = vo(WalletBizEnum.REFUND.getCode(), HardTypeEnum.COFFEE.getType());
rechargeRefund.setBizId("802");
HardAfterSaleEntity sale = new HardAfterSaleEntity();
sale.setId(802L);
sale.setOrderId(null);
when(remoteHardOrderService.listAfterSalesByIds(anyList())).thenReturn(R.ok(List.of(sale)));
ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(rechargeRefund));
assertNull(rechargeRefund.getProductName());
assertNull(rechargeRefund.getOrderId());
}
/**
* 订单远程查询失败时摘要整体降级留空,不阻断列表。
*/
@Test
void orderSceneShouldDegradeOnRemoteFailure() {
WalletTransactionVo payment = vo(WalletBizEnum.PAYMENT.getCode(), HardTypeEnum.COFFEE.getType());
payment.setBizId("902");
when(remoteHardOrderService.getByIds(anyList())).thenThrow(new RuntimeException("feign timeout"));
ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(payment));
assertNull(payment.getProductName());
}
/**
* 充值场景摘要:回填套餐名与实付/赠送金额。
*/
@Test
void rechargeSceneShouldFillPackageAndPrices() {
WalletTransactionVo recharge = vo(WalletBizEnum.RECHARGE.getCode(), null);
recharge.setBizId("701");
WalletRechargeEntity record = new WalletRechargeEntity();
record.setId(701L);
record.setPackageId(8L);
record.setPayPrice(3000);
record.setBonusPrice(300);
when(walletRechargeMapper.selectBatchIds(anyList())).thenReturn(List.of(record));
WalletRechargePackageEntity pkg = new WalletRechargePackageEntity();
pkg.setId(8L);
pkg.setName("30元套餐");
when(walletRechargePackageMapper.selectBatchIds(anyList())).thenReturn(List.of(pkg));
ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(recharge));
assertEquals("30元套餐", recharge.getRechargePackageName());
assertEquals(3000, recharge.getRechargePayPrice());
assertEquals(300, recharge.getRechargeBonusPrice());
}
/**
* 转账场景摘要:按源/目标钱包类型拼方向描述。
*/
@Test
void transferSceneShouldFillDirectionDesc() {
WalletTransactionVo transfer = vo(WalletBizEnum.TRANSFER_ACCOUNTS.getCode(), null);
transfer.setBizId("601");
WalletRuleTransactionEntity rule = new WalletRuleTransactionEntity();
rule.setId(601L);
rule.setSourceWalletId(10L);
rule.setTargetWalletId(20L);
when(walletRuleTransactionMapper.selectBatchIds(anyList())).thenReturn(List.of(rule));
WalletManageEntity eatWallet = new WalletManageEntity();
eatWallet.setId(10L);
eatWallet.setWalletTypeId(1L);
WalletManageEntity payWallet = new WalletManageEntity();
payWallet.setId(20L);
payWallet.setWalletTypeId(2L);
when(walletManageMapper.selectBatchIds(anyList())).thenReturn(List.of(eatWallet, payWallet));
WalletTypeEntity eatType = new WalletTypeEntity();
eatType.setId(1L);
eatType.setName("饭卡");
WalletTypeEntity payType = new WalletTypeEntity();
payType.setId(2L);
payType.setName("消费钱包");
when(walletTypeMapper.selectBatchIds(anyList())).thenReturn(List.of(eatType, payType));
ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(transfer));
assertEquals("饭卡 → 消费钱包", transfer.getTransferDesc());
}
/**
* 非消费场景流水(退还食堂等)不触发任何摘要查询。
*/
@Test
void unrelatedBizTypeShouldSkipAllSceneQueries() {
WalletTransactionVo canteenReturn = vo(WalletBizEnum.RETURN_CANTEEN.getCode(), null);
canteenReturn.setBizId("501");
ReflectionTestUtils.invokeMethod(service, "fillSceneSummary", List.of(canteenReturn));
verify(remoteHardOrderService, never()).getByIds(anyList());
verify(walletRechargeMapper, never()).selectBatchIds(anyList());
verify(walletRuleTransactionMapper, never()).selectBatchIds(anyList());
}
private void invokeFillHardType(WalletTransactionEntity entity) {
ReflectionTestUtils.invokeMethod(service, "fillHardTypeFromContext", entity);
}
private WalletTransactionEntity transaction(Long walletId, String bizId, String bizType) {
WalletTransactionEntity entity = new WalletTransactionEntity();
entity.setWalletId(walletId);
entity.setBizId(bizId);
entity.setBizType(bizType);
return entity;
}
private WalletTransactionVo vo(String bizType, String hardType) {
WalletTransactionVo vo = new WalletTransactionVo();
vo.setBizType(bizType);
vo.setHardType(hardType);
return vo;
}
}