feat:钱包流水备注
This commit is contained in:
1 parent
42ac8786ee
commit
65b00be0a4
26 files changed
+1399
-14
No files matched your search
@@ -71,3 +71,4 @@ scripts/recharge-import/
|
||||
.mimosa
|
||||
.v2c
|
||||
.zcode
|
||||
.zcodeignore
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 20261008 wallet_transaction 新增 sys_remark 系统备注列(可重复执行)
|
||||
-- 背景:流水缺少面向用户的场景简介,App/管理端只能展示固定的 title(如"支付")。
|
||||
-- 新增 sys_remark 由服务端按场景模板自动生成(如"在食堂线下消费10元"、"在食堂线上预订消费10元"),
|
||||
-- 与既有 remark(后台人工备注,update-remark 端点维护)职责分离、互不覆盖。
|
||||
-- 方案:加一列可空 varchar(256),纯展示字段,不建索引、不回填默认值,读写路径零影响;
|
||||
-- 历史数据由 wallet-biz 回填任务按主键游标分批 UPDATE(见 WalletSysRemarkBackfillService)。
|
||||
-- ⚠️ 执行要求:必须在 wallet-biz 新包部署**之前**执行——新代码写入时 sys_remark 恒非空,
|
||||
-- 列不存在会导致所有流水 insert 失败。可空加列为轻量在线 DDL,远低于 20261005 的 COPY 改列,
|
||||
-- 仍建议低峰执行。
|
||||
-- 回滚:ALTER TABLE wallet_transaction DROP COLUMN sys_remark(回滚前需先回退 wallet-biz 版本,
|
||||
-- 否则新流水 insert 会因缺列失败)。
|
||||
|
||||
USE pjcloudx_wallet;
|
||||
|
||||
-- 加列:sys_remark(放在 remark 之后;OceanBase 不支持 ADD COLUMN IF NOT EXISTS,用 information_schema 判幂等)
|
||||
SET @ddl = IF(
|
||||
(SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'pjcloudx_wallet' AND table_name = 'wallet_transaction'
|
||||
AND column_name = 'sys_remark') = 0,
|
||||
'ALTER TABLE wallet_transaction ADD COLUMN sys_remark varchar(256) DEFAULT NULL COMMENT ''系统备注:按场景模板自动生成的流水简介,与人工备注remark独立'' AFTER remark',
|
||||
'SELECT ''skip: sys_remark 已存在'' AS msg');
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,90 @@
|
||||
========================================================================
|
||||
钱包流水系统备注回填 - 定时任务手工添加清单 (2026-10-08)
|
||||
========================================================================
|
||||
|
||||
说明:以下内容需要管理员在对应环境手工执行(直接执行 SQL)。
|
||||
SQL 按任务名幂等,风格与 db/20261008_钱包自动创建补漏定时任务.txt 一致。
|
||||
注意库名:sys_job 在 `pjcloudx_job` 库(与 qrtz_* / sys_job_log 同库)。
|
||||
|
||||
一、迁移背景与功能说明:
|
||||
wallet_transaction 新增 sys_remark(系统备注,按场景模板自动生成的流水简介,
|
||||
如"在食堂线下消费10元")。新流水由业务代码在写入时直接生成;本任务负责为
|
||||
约 150 万行历史流水分批补齐 sys_remark,属于一次性数据回填任务:
|
||||
只处理 sys_remark 仍为空的行,天然幂等、可随时暂停/重跑,返回 0 即全部收敛。
|
||||
|
||||
执行链路(daemon-quartz sys_job → Feign → wallet 内部端点):
|
||||
walletSysRemarkBackfillTask.backfillSysRemark
|
||||
→ POST /wallet/wallet-transaction/inner/backfill-sys-remark(@Inner)
|
||||
→ 遍历全部有效租户(RemoteTenantService.list + TenantBroker)
|
||||
→ 每租户按主键游标 500 行/批捞取 sys_remark 为空的历史流水
|
||||
→ PAYMENT/REFUND 行按 bizId 批量反查硬件订单(RemoteHardOrderService
|
||||
getByIds,校验钱包一致防撞号)渲染场景文案,与在线写入同口径
|
||||
→ 其余业务类型按 WalletSysRemarkTemplate.defaultRemark 兜底
|
||||
→ 逐行主键 UPDATE 写入 sys_remark(不触碰 update_by/update_time)
|
||||
端点仅允许服务间内部调用(@Inner + @NoToken),由定时任务驱动,无需也不应
|
||||
手工 curl 触发。
|
||||
|
||||
二、部署顺序(硬前提,必须严格按序):
|
||||
1. 执行 db/20261008_add_wallet_transaction_sys_remark.sql(加列,必须先于
|
||||
wallet-biz:新代码写入时 sys_remark 恒非空,缺列会导致所有流水 insert 失败);
|
||||
2. 部署包含本次改动的 pjcloud-hardware-biz(订单扣款链路渲染系统备注 +
|
||||
getByIds 供回填反查);
|
||||
3. 部署包含本次改动的 pjcloud-wallet-biz(回填端点及实现);
|
||||
4. 部署包含本次改动的 pjcloud-daemon-quartz(新增 spring bean
|
||||
walletSysRemarkBackfillTask);
|
||||
5. 执行下方 sys_job INSERT(job_status='2' 运行中);
|
||||
6. daemon-quartz 重启时由 PjcloudInitQuartzJob 自动注册进 Quartz;服务不重启
|
||||
可调管理端 POST /sys-job/refresh-jobs(权限 job_sys_job_refresh_job)手动加载。
|
||||
|
||||
空窗期影响:部署顺序颠倒最多造成任务首轮调用失败并记一条失败日志,下一个
|
||||
调度周期自动恢复;回填幂等,无数据风险。DDL 未先执行是唯一不可逆序的步骤。
|
||||
|
||||
三、行为说明:
|
||||
- cron '0 0/30 * * * ?':每 30 分钟触发一次;每轮每租户最多 200 批 × 500 行
|
||||
(约 10 万行),150 万行预计 1~2 天内收敛(白天业务时段批间限速运行,
|
||||
对线上写入压力很小;如需更快可在 sys_job 把 method_params_value 调大,
|
||||
服务端上限 1000 批/轮);
|
||||
- misfire_policy='3',@DisallowConcurrentExecution 由统一 PjcloudQuartzFactory
|
||||
保证,多实例部署下 Quartz 集群锁兜底,任务不会并发执行;
|
||||
- 单个租户失败不影响其余租户,单行渲染失败不影响其余行(回退兜底文案),
|
||||
均由下一轮任务自动重试;Feign 超时只影响本轮日志状态,wallet 侧照常执行完,
|
||||
下一轮续跑;
|
||||
- 返回 0(收敛)后任务变为每轮一次空查询,开销可忽略;建议观察到收敛日志后
|
||||
将 job_status 改 '3'(暂停)停用任务;
|
||||
- 任务 sys_job 行 tenant_id=1 仅用于 daemon 侧调度上下文,wallet 端点内部自行
|
||||
遍历全部有效租户。
|
||||
|
||||
------------------------------------------------------------------------
|
||||
四、sys_job:钱包流水系统备注回填(job_type=2,反射调 daemon 内 spring bean)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `pjcloudx_job`.`sys_job`
|
||||
(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
|
||||
`method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
|
||||
`job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
|
||||
SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026100800000000000) FROM `pjcloudx_job`.`sys_job`) + 1,
|
||||
'钱包流水系统备注回填', 'WALLET', '3', '2', NULL, 'walletSysRemarkBackfillTask', 'backfillSysRemark',
|
||||
NULL, '0 0/30 * * * ?', '3', '1', '2',
|
||||
'0', 'system', NOW(), 'system', NOW(), 1, '分批回填历史钱包流水系统备注(sys_remark),收敛(日志提示0行)后建议暂停'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `pjcloudx_job`.`sys_job`
|
||||
WHERE `job_name` = '钱包流水系统备注回填' AND `job_group` = 'WALLET' AND `tenant_id` = 1
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------
|
||||
五、内部端点与任务对应关系(@Inner + @NoToken 仅服务间调用,勿对外)
|
||||
------------------------------------------------------------------------
|
||||
POST /wallet/wallet-transaction/inner/backfill-sys-remark
|
||||
跨租户分批回填流水系统备注
|
||||
(RemoteWalletTransactionService
|
||||
backfillSysRemark,仅 daemon 调用)
|
||||
|
||||
------------------------------------------------------------------------
|
||||
六、回滚说明
|
||||
------------------------------------------------------------------------
|
||||
停用任务:将 sys_job 该行 job_status 改为 '4'(删除)或 '3'(暂停)并重启
|
||||
daemon-quartz(或调 POST /sys-job/refresh-jobs);
|
||||
代码回滚:回滚 wallet-biz / daemon-quartz 服务即可;已回填的 sys_remark 数据
|
||||
无需清理(可空列,旧代码读写均不感知),如确需清理执行
|
||||
UPDATE wallet_transaction SET sys_remark = NULL(大表建议分批)。
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.bsd.pjcloud.hard.api.utils;
|
||||
|
||||
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletSysRemarkTemplate;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 硬件订单场景到钱包流水系统备注(sys_remark)的映射工具。
|
||||
*
|
||||
* <p>按订单类型({@link HardOrderTypeEnum})+ 系统类型({@link HardTypeEnum})选择
|
||||
* {@link WalletSysRemarkTemplate} 中的文案模板并渲染金额。hardware 在线扣款与
|
||||
* wallet 历史流水回填共用本映射,保证新旧数据口径一致。</p>
|
||||
*
|
||||
* @author pjcloud
|
||||
*/
|
||||
public final class HardOrderSysRemarkHelper {
|
||||
|
||||
private HardOrderSysRemarkHelper() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按订单场景渲染消费系统备注。
|
||||
*
|
||||
* @param orderType 订单类型编码,取值见 {@link HardOrderTypeEnum#getType()}
|
||||
* @param sysType 系统类型编码,取值见 {@link HardTypeEnum#getType()};商超单与食堂单共用订单类型时靠它区分
|
||||
* @param amountFen 消费金额,单位分,正负均可(渲染取绝对值)
|
||||
* @return 场景文案,如"在食堂线下消费10元";类型缺失时返回通用兜底文案
|
||||
*/
|
||||
public static String renderConsume(String orderType, String sysType, Integer amountFen) {
|
||||
return WalletSysRemarkTemplate.format(resolveConsumeTemplate(orderType, sysType), amountFen);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按订单场景选择消费文案模板。
|
||||
*
|
||||
* @param orderType 订单类型编码
|
||||
* @param sysType 系统类型编码
|
||||
* @return 消费模板常量;类型缺失时返回通用兜底模板
|
||||
*/
|
||||
public static String resolveConsumeTemplate(String orderType, String sysType) {
|
||||
HardOrderTypeEnum orderTypeEnum = HardOrderTypeEnum.valueOfByType(orderType);
|
||||
if (orderTypeEnum == null) {
|
||||
return WalletSysRemarkTemplate.OFFLINE_FALLBACK;
|
||||
}
|
||||
// 商超单与食堂单共用 BELOW/RESERVE 订单类型,需先按系统类型区分
|
||||
if (Objects.equals(sysType, HardTypeEnum.SUPERMARKET.getType())) {
|
||||
return WalletSysRemarkTemplate.SUPERMARKET;
|
||||
}
|
||||
// 扫商户码扣款牌的任意金额支付单
|
||||
if (Objects.equals(sysType, HardTypeEnum.MERCHANT_QR_CODE.getType())) {
|
||||
return WalletSysRemarkTemplate.SCAN_CODE;
|
||||
}
|
||||
// 生鲜柜免密支付不挂在食堂名下,使用通用线下文案
|
||||
if (Objects.equals(sysType, HardTypeEnum.DOOR.getType())) {
|
||||
return WalletSysRemarkTemplate.OFFLINE_FALLBACK;
|
||||
}
|
||||
return switch (orderTypeEnum) {
|
||||
case BELOW, XFJ -> WalletSysRemarkTemplate.CANTEEN_OFFLINE;
|
||||
case RESERVE -> WalletSysRemarkTemplate.CANTEEN_ONLINE_RESERVE;
|
||||
case TAKE_OUT -> WalletSysRemarkTemplate.CANTEEN_TAKE_OUT;
|
||||
case FARMER -> WalletSysRemarkTemplate.MALL;
|
||||
case COFFEE -> WalletSysRemarkTemplate.COFFEE;
|
||||
case ZY -> WalletSysRemarkTemplate.ZY;
|
||||
case LB -> WalletSysRemarkTemplate.LB;
|
||||
// 外来人员、跨单位、访客购买等其余订单类型统一走通用文案
|
||||
default -> WalletSysRemarkTemplate.OFFLINE_FALLBACK;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按订单场景渲染补偿/退款退回系统备注。
|
||||
*
|
||||
* @param amountFen 退款金额,单位分,正负均可(渲染取绝对值)
|
||||
* @return 退款文案,如"订单取消退回10元"
|
||||
*/
|
||||
public static String renderCompensateRefund(Integer amountFen) {
|
||||
return WalletSysRemarkTemplate.format(WalletSysRemarkTemplate.ORDER_COMPENSATE_REFUND, amountFen);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染订单售后退款系统备注。
|
||||
*
|
||||
* @param amountFen 退款金额,单位分,正负均可(渲染取绝对值)
|
||||
* @return 售后退款文案,如"订单退款退回10元"
|
||||
*/
|
||||
public static String renderAfterSaleRefund(Integer amountFen) {
|
||||
return WalletSysRemarkTemplate.format(WalletSysRemarkTemplate.ORDER_AFTER_SALE_REFUND, amountFen);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -28,6 +28,7 @@ import com.bsd.pjcloud.common.security.service.PjcloudUser;
|
||||
import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.utils.HardOrderSysRemarkHelper;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
|
||||
@@ -679,7 +680,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
|
||||
if (isWalletPay) {
|
||||
WalletRechargeRefundDto hardRefundDto = WalletRechargeRefundDto.builder().walletId(order.getWalletId())
|
||||
.orderId(order.getId()).afterSaleId(afterSale.getId()).refundCode(refundCode).reason("订单退款")
|
||||
.price(refundPrice).autoRefundConfigId(autoRefundConfigId).autoRefundTaskType(autoRefundTaskType).build();
|
||||
.price(refundPrice).autoRefundConfigId(autoRefundConfigId).autoRefundTaskType(autoRefundTaskType)
|
||||
// 售后退款的系统备注("订单退款退回X元"),落库到钱包流水 sys_remark
|
||||
.sysRemark(HardOrderSysRemarkHelper.renderAfterSaleRefund(refundPrice)).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);
|
||||
|
||||
+11
-3
@@ -54,6 +54,7 @@ import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.HardThirdMiStatusEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.utils.HardOrderSysRemarkHelper;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderVerificationTypeEnum;
|
||||
@@ -2724,7 +2725,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
* @param orders 已分配订单 ID 且写入实际用餐人钱包 ID 的完整批次
|
||||
*/
|
||||
private void reduceReserveOrderBalances(List<HardOrderEntity> orders) {
|
||||
List<WalletBalanceTransactionDto> paymentList = buildReserveWalletTransactions(orders);
|
||||
List<WalletBalanceTransactionDto> paymentList = buildReserveWalletTransactions(orders, false);
|
||||
R<List<WalletTransactionEntity>> result = walletTransactionService.reduceWalletBalanceBatchIdempotent(paymentList);
|
||||
if (result == null || !result.isOk() || result.getData() == null || result.getData().size() != paymentList.size()) {
|
||||
throw new I18nBizException(result == null ? ErrorCodes.HARD_WALLET_BATCH_DEDUCT_NO_RESP : ErrorCodes.HARD_WALLET_BATCH_DEDUCT_BAD);
|
||||
@@ -2735,9 +2736,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
* 将预约订单转换为钱包支付或补偿使用的稳定明细,同一订单 ID 在扣款与补偿阶段始终使用同一业务号。
|
||||
*
|
||||
* @param orders 已创建的预约订单
|
||||
* @param forCompensate {@code true} 时渲染补偿退回文案("订单取消退回X元"),否则渲染消费场景文案
|
||||
* @return 与订单顺序一致的钱包支付明细
|
||||
*/
|
||||
private List<WalletBalanceTransactionDto> buildReserveWalletTransactions(List<HardOrderEntity> orders) {
|
||||
private List<WalletBalanceTransactionDto> buildReserveWalletTransactions(List<HardOrderEntity> orders, boolean forCompensate) {
|
||||
return orders.stream().map(order -> {
|
||||
if (order.getId() == null || order.getWalletId() == null || order.getPayPrice() == null || order.getPayPrice() <= 0) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_WALLET_TX_BAD);
|
||||
@@ -2751,6 +2753,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
// 钱包底层流水不保存商户,订单调用必须显式传实际商户快照供同事务Outbox使用。
|
||||
transaction.setSourceMchId(order.getMerchantId());
|
||||
transaction.setSourceScene("ORDER");
|
||||
// 系统备注:扣款渲染消费场景(如"在食堂线上预订消费10元"),补偿渲染退回文案,为空时钱包侧兜底
|
||||
transaction.setSysRemark(forCompensate
|
||||
? HardOrderSysRemarkHelper.renderCompensateRefund(order.getPayPrice())
|
||||
: HardOrderSysRemarkHelper.renderConsume(order.getOrderType(), order.getSysType(), order.getPayPrice()));
|
||||
return transaction;
|
||||
}).toList();
|
||||
}
|
||||
@@ -3100,7 +3106,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
return;
|
||||
}
|
||||
try {
|
||||
R<Boolean> result = walletTransactionService.compensatePaymentBatch(buildReserveWalletTransactions(createdOrders));
|
||||
R<Boolean> result = walletTransactionService.compensatePaymentBatch(buildReserveWalletTransactions(createdOrders, true));
|
||||
if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) {
|
||||
throw new I18nBizException(result == null ? ErrorCodes.HARD_WALLET_BATCH_COMP_NO_RESP : ErrorCodes.HARD_WALLET_BATCH_DEDUCT_BAD);
|
||||
}
|
||||
@@ -4050,6 +4056,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
// 快速支付同样使用订单实际商户,禁止退化为钱包所有人的默认食堂。
|
||||
reduceBuild.setSourceMchId(order.getMerchantId());
|
||||
reduceBuild.setSourceScene("ORDER");
|
||||
// 按订单类型+系统类型渲染消费场景系统备注(如"在食堂线下消费10元")
|
||||
reduceBuild.setSysRemark(HardOrderSysRemarkHelper.renderConsume(order.getOrderType(), order.getSysType(), order.getPayPrice()));
|
||||
R<WalletTransactionEntity> reduceRes = walletTransactionService.reduceWalletBalance(reduceBuild);
|
||||
if (reduceRes.getCode() != 0 || ObjectUtil.isEmpty(reduceRes.getData())) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_WALLET_DEDUCT_FAIL);
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.bsd.pjcloud.hard.api.utils;
|
||||
|
||||
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletSysRemarkTemplate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 硬件订单场景到钱包系统备注模板映射测试:订单类型+系统类型选择与金额渲染。
|
||||
*/
|
||||
class HardOrderSysRemarkHelperTest {
|
||||
|
||||
/**
|
||||
* 食堂线下与线上预订按订单类型区分,商超共用订单类型时按系统类型优先区分。
|
||||
*/
|
||||
@Test
|
||||
void resolveConsumeTemplateShouldDistinguishCanteenAndSupermarket() {
|
||||
assertEquals(WalletSysRemarkTemplate.CANTEEN_OFFLINE,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.BELOW.getType(), null));
|
||||
assertEquals(WalletSysRemarkTemplate.CANTEEN_OFFLINE,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.XFJ.getType(), HardTypeEnum.YUN_XFJ.getType()));
|
||||
assertEquals(WalletSysRemarkTemplate.CANTEEN_ONLINE_RESERVE,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.RESERVE.getType(), HardTypeEnum.CANTEEN.getType()));
|
||||
assertEquals(WalletSysRemarkTemplate.SUPERMARKET,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.BELOW.getType(), HardTypeEnum.SUPERMARKET.getType()));
|
||||
assertEquals(WalletSysRemarkTemplate.SUPERMARKET,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.RESERVE.getType(), HardTypeEnum.SUPERMARKET.getType()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 外卖、商城、咖啡机、第三方渠道按各自订单类型选择模板。
|
||||
*/
|
||||
@Test
|
||||
void resolveConsumeTemplateShouldMapTakeOutMallCoffeeAndThirdChannels() {
|
||||
assertEquals(WalletSysRemarkTemplate.CANTEEN_TAKE_OUT,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.TAKE_OUT.getType(), null));
|
||||
assertEquals(WalletSysRemarkTemplate.MALL,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.FARMER.getType(), null));
|
||||
assertEquals(WalletSysRemarkTemplate.COFFEE,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.COFFEE.getType(), HardTypeEnum.COFFEE.getType()));
|
||||
assertEquals(WalletSysRemarkTemplate.ZY,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.ZY.getType(), null));
|
||||
assertEquals(WalletSysRemarkTemplate.LB,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.LB.getType(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫商户码、生鲜柜走系统类型特判,其余订单类型和未知类型回落通用线下文案。
|
||||
*/
|
||||
@Test
|
||||
void resolveConsumeTemplateShouldFallbackForScanDoorAndUnknown() {
|
||||
assertEquals(WalletSysRemarkTemplate.SCAN_CODE,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.BELOW.getType(), HardTypeEnum.MERCHANT_QR_CODE.getType()));
|
||||
assertEquals(WalletSysRemarkTemplate.OFFLINE_FALLBACK,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.BELOW.getType(), HardTypeEnum.DOOR.getType()));
|
||||
assertEquals(WalletSysRemarkTemplate.OFFLINE_FALLBACK,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(HardOrderTypeEnum.FOREIGN_TICKET.getType(), null));
|
||||
assertEquals(WalletSysRemarkTemplate.OFFLINE_FALLBACK,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate("999", null));
|
||||
assertEquals(WalletSysRemarkTemplate.OFFLINE_FALLBACK,
|
||||
HardOrderSysRemarkHelper.resolveConsumeTemplate(null, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费、补偿退回和售后退款渲染应替换金额占位符并去掉尾零。
|
||||
*/
|
||||
@Test
|
||||
void renderShouldProduceReadableRemark() {
|
||||
assertEquals("在食堂线上预订消费10.5元",
|
||||
HardOrderSysRemarkHelper.renderConsume(HardOrderTypeEnum.RESERVE.getType(), HardTypeEnum.SELF_APP.getType(), 1050));
|
||||
assertEquals("在食堂线下消费10元",
|
||||
HardOrderSysRemarkHelper.renderConsume(HardOrderTypeEnum.BELOW.getType(), null, -1000));
|
||||
assertEquals("订单取消退回10元", HardOrderSysRemarkHelper.renderCompensateRefund(1000));
|
||||
assertEquals("订单退款退回0.01元", HardOrderSysRemarkHelper.renderAfterSaleRefund(1));
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.bsd.pjcloud.daemon.quartz.task;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.daemon.quartz.constants.PjcloudQuartzEnum;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 钱包流水系统备注回填任务。sys_job 通过 spring bean 反射调用(job_type=2,
|
||||
* class_name=walletSysRemarkBackfillTask),bean 内走 Feign 调 wallet 服务内部端点:
|
||||
* 跨租户按主键游标分批为 sys_remark 为空的历史流水生成系统备注(PAYMENT/REFUND
|
||||
* 按 bizId 反查订单渲染场景文案,其余按业务类型兜底)。任务只处理空值行、天然幂等,
|
||||
* 重复触发安全;返回 0 表示已全部收敛,可停用任务(job_status 改 '3' 后刷新),
|
||||
* 继续空跑也只产生一次空查询,无额外开销。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("walletSysRemarkBackfillTask")
|
||||
@RequiredArgsConstructor
|
||||
public class WalletSysRemarkBackfillTask {
|
||||
|
||||
/** 单轮默认批次数(每批 500 行,即每租户每轮约 10 万行),可被任务参数覆盖。 */
|
||||
private static final int DEFAULT_MAX_BATCHES = 200;
|
||||
|
||||
private final RemoteWalletTransactionService remoteWalletTransactionService;
|
||||
|
||||
/**
|
||||
* 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
|
||||
*/
|
||||
public String backfillSysRemark() {
|
||||
return backfillSysRemark(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分批回填历史流水的系统备注,每 30 分钟触发一次直至收敛。
|
||||
*
|
||||
* @param para 可选数字:每租户本轮最多处理的批次数(每批 500 行),缺省 200,服务端上限 1000
|
||||
*/
|
||||
@SneakyThrows
|
||||
public String backfillSysRemark(String para) {
|
||||
int maxBatches = parseMaxBatches(para);
|
||||
log.info("钱包流水系统备注回填开始:{},本批次上限:{}", LocalDateTime.now(), maxBatches);
|
||||
try {
|
||||
R<Integer> result = remoteWalletTransactionService.backfillSysRemark(maxBatches);
|
||||
if (result.isOk()) {
|
||||
int processed = ObjectUtil.defaultIfNull(result.getData(), 0);
|
||||
if (processed == 0) {
|
||||
log.info("钱包流水系统备注回填已全部收敛,可停用任务(job_status改'3'并刷新)或继续空跑,时间:{}", LocalDateTime.now());
|
||||
} else {
|
||||
log.info("钱包流水系统备注回填完成,本轮回填:{}行,时间:{}", processed, LocalDateTime.now());
|
||||
}
|
||||
} else {
|
||||
log.error("钱包流水系统备注回填失败,code:{},msg:{}", result.getCode(), result.getMsg());
|
||||
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 超时等异常不代表 wallet 侧未执行完成;回填幂等,由下一轮任务自动续跑
|
||||
log.error("钱包流水系统备注回填调用异常error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
|
||||
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
|
||||
}
|
||||
return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析任务参数为批次数,空或非数字时使用默认值。
|
||||
*/
|
||||
private int parseMaxBatches(String para) {
|
||||
if (StrUtil.isBlank(para)) {
|
||||
return DEFAULT_MAX_BATCHES;
|
||||
}
|
||||
try {
|
||||
return Math.max(1, Integer.parseInt(para.trim()));
|
||||
} catch (NumberFormatException ex) {
|
||||
log.warn("钱包流水系统备注回填参数非数字,使用默认值:{},para:{}", DEFAULT_MAX_BATCHES, para);
|
||||
return DEFAULT_MAX_BATCHES;
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.bsd.pjcloud.daemon.quartz.task;
|
||||
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletTransactionService;
|
||||
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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 钱包流水系统备注回填定时任务的回调结果与参数解析测试。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WalletSysRemarkBackfillTaskTest {
|
||||
|
||||
/** 钱包流水远程服务 Mock。 */
|
||||
@Mock
|
||||
private RemoteWalletTransactionService remoteWalletTransactionService;
|
||||
|
||||
/** 被测回填任务。 */
|
||||
@InjectMocks
|
||||
private WalletSysRemarkBackfillTask task;
|
||||
|
||||
/**
|
||||
* 远程回填成功时任务应返回成功状态,收敛(0 行)同样视为成功。
|
||||
*/
|
||||
@Test
|
||||
void convergedRoundShouldStillReportSuccess() {
|
||||
when(remoteWalletTransactionService.backfillSysRemark(anyInt())).thenReturn(R.ok(0));
|
||||
|
||||
String status = task.backfillSysRemark("200");
|
||||
|
||||
assertEquals("0", status);
|
||||
verify(remoteWalletTransactionService).backfillSysRemark(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 未配置任务参数时应按默认批次数(200)发起调用。
|
||||
*/
|
||||
@Test
|
||||
void blankParaShouldUseDefaultMaxBatches() {
|
||||
when(remoteWalletTransactionService.backfillSysRemark(anyInt())).thenReturn(R.ok(500));
|
||||
|
||||
assertEquals("0", task.backfillSysRemark((String) null));
|
||||
verify(remoteWalletTransactionService).backfillSysRemark(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数非数字时不应抛异常,回退默认值继续执行。
|
||||
*/
|
||||
@Test
|
||||
void nonNumericParaShouldFallBackToDefault() {
|
||||
when(remoteWalletTransactionService.backfillSysRemark(anyInt())).thenReturn(R.ok(1));
|
||||
|
||||
assertEquals("0", task.backfillSysRemark("abc"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 远端返回失败时应记失败状态,交由下一轮调度重试。
|
||||
*/
|
||||
@Test
|
||||
void remoteFailureShouldReportFailStatus() {
|
||||
when(remoteWalletTransactionService.backfillSysRemark(anyInt())).thenReturn(R.failed("服务不可用"));
|
||||
|
||||
assertEquals("1", task.backfillSysRemark("50"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Feign 调用异常(如超时)时应记失败状态;wallet 侧照常执行,下一轮续跑。
|
||||
*/
|
||||
@Test
|
||||
void feignExceptionShouldReportFailStatus() {
|
||||
when(remoteWalletTransactionService.backfillSysRemark(anyInt())).thenThrow(new RuntimeException("read timeout"));
|
||||
|
||||
assertEquals("1", task.backfillSysRemark("50"));
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package com.bsd.pjcloud.wallet.api.constant;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 钱包流水系统备注(sys_remark)文案模板。
|
||||
*
|
||||
* <p>所有模板使用 {@code {}} 作为金额占位符,金额单位为分,渲染时自动转为元并去掉多余的尾零
|
||||
* (1000 分 -> "在食堂线下消费10元",1050 分 -> "在食堂线上预订消费10.5元")。
|
||||
* 文案集中定义在本类,调整措辞只需修改常量值,新流水立即生效;历史流水不追溯。</p>
|
||||
*
|
||||
* <p>两类模板:</p>
|
||||
* <ul>
|
||||
* <li>订单场景模板:由订单调用方(hardware 统一订单中心)按订单类型+系统类型选定后,
|
||||
* 经 {@link com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto#getSysRemark()} 透传给钱包服务;</li>
|
||||
* <li>钱包内部默认模板:调用方未显式传文案时,钱包服务在落库前按业务类型+金额方向
|
||||
* 通过 {@link #defaultRemark(String, Integer)} 自动兜底。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author pjcloud
|
||||
*/
|
||||
public final class WalletSysRemarkTemplate {
|
||||
|
||||
/* ==================== 订单场景模板(hardware 侧按 orderType + sysType 选择) ==================== */
|
||||
|
||||
/** 食堂线下消费:终端下单(BELOW)、消费机(XFJ)等非预订类食堂扣款。 */
|
||||
public static final String CANTEEN_OFFLINE = "在食堂线下消费{}元";
|
||||
|
||||
/** 食堂线上预订消费:App 预订(RESERVE)、每日菜谱批量预订。 */
|
||||
public static final String CANTEEN_ONLINE_RESERVE = "在食堂线上预订消费{}元";
|
||||
|
||||
/** 食堂外卖消费。 */
|
||||
public static final String CANTEEN_TAKE_OUT = "食堂外卖消费{}元";
|
||||
|
||||
/** 商超消费。 */
|
||||
public static final String SUPERMARKET = "在商超消费{}元";
|
||||
|
||||
/** 商城消费(共富商城现用作商城)。 */
|
||||
public static final String MALL = "商城消费{}元";
|
||||
|
||||
/** 咖啡机消费。 */
|
||||
public static final String COFFEE = "咖啡机消费{}元";
|
||||
|
||||
/** 正元公务餐消费。 */
|
||||
public static final String ZY = "正元公务餐消费{}元";
|
||||
|
||||
/** 宁波银行(美好生活)消费。 */
|
||||
public static final String LB = "宁波银行消费{}元";
|
||||
|
||||
/** App 扫商户码支付。 */
|
||||
public static final String SCAN_CODE = "扫码支付消费{}元";
|
||||
|
||||
/** 预订批次失败/取消的整批补偿退回。 */
|
||||
public static final String ORDER_COMPENSATE_REFUND = "订单取消退回{}元";
|
||||
|
||||
/** 订单售后退款退回。 */
|
||||
public static final String ORDER_AFTER_SALE_REFUND = "订单退款退回{}元";
|
||||
|
||||
/** 其余订单类型(外来人员、跨单位、访客购买等)的兜底文案。 */
|
||||
public static final String OFFLINE_FALLBACK = "线下消费{}元";
|
||||
|
||||
/* ==================== 钱包内部默认模板(按业务类型+金额方向兜底) ==================== */
|
||||
|
||||
/** 支付扣款兜底(无场景信息的钱包消费)。 */
|
||||
public static final String WALLET_PAY = "钱包消费{}元";
|
||||
|
||||
/** 充值到账(支付回调、后台充值)。 */
|
||||
public static final String WALLET_RECHARGE = "钱包充值{}元";
|
||||
|
||||
/** 充值退款扣回(第三方支付退款回调冲减)。 */
|
||||
public static final String RECHARGE_REFUND_DEDUCT = "充值退款扣回{}元";
|
||||
|
||||
/** 退款/补偿入账兜底。 */
|
||||
public static final String REFUND_RETURN = "退款退回{}元";
|
||||
|
||||
/** 售后退款冲正扣回。 */
|
||||
public static final String REFUND_ROLLBACK = "退款冲正扣回{}元";
|
||||
|
||||
/** 钱包/饭卡互转、手动转账转出。 */
|
||||
public static final String TRANSFER_OUT = "转账转出{}元";
|
||||
|
||||
/** 钱包/饭卡互转、手动转账转入。 */
|
||||
public static final String TRANSFER_IN = "转账转入{}元";
|
||||
|
||||
/** 转账退回。 */
|
||||
public static final String TRANSFER_REFUND = "转账退回{}元";
|
||||
|
||||
/** 退还各自食堂。 */
|
||||
public static final String RETURN_CANTEEN = "退还食堂{}元";
|
||||
|
||||
/** Excel 批量导入充值。 */
|
||||
public static final String BATCH_RECHARGE = "批量充值{}元";
|
||||
|
||||
/** Excel 批量导入扣款、摊销扣回。 */
|
||||
public static final String BATCH_DEDUCT = "批量扣款{}元";
|
||||
|
||||
/** 重置余额。 */
|
||||
public static final String RESET_BALANCE = "重置余额{}元";
|
||||
|
||||
/** 后台调整余额(增加)。 */
|
||||
public static final String UPDATE_INCREASE = "后台调整余额增加{}元";
|
||||
|
||||
/** 后台调整余额(减少)。 */
|
||||
public static final String UPDATE_DECREASE = "后台调整余额减少{}元";
|
||||
|
||||
private WalletSysRemarkTemplate() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按模板渲染系统备注;金额单位分,自动取绝对值并转为元。
|
||||
*
|
||||
* @param template 模板常量,含 {@code {}} 金额占位符
|
||||
* @param priceFen 金额,单位分,正负均可
|
||||
* @return 渲染后的文案;模板或金额为空时返回 null(交由调用方兜底)
|
||||
*/
|
||||
public static String format(String template, Integer priceFen) {
|
||||
if (ObjectUtil.isEmpty(template) || priceFen == null) {
|
||||
return null;
|
||||
}
|
||||
return StrUtil.format(template, formatFen(priceFen));
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额分转元字符串并去掉多余的尾零:1000 -> "10",1050 -> "10.5",1 -> "0.01"。
|
||||
*
|
||||
* @param priceFen 金额,单位分,正负均可(结果取绝对值)
|
||||
* @return 元字符串
|
||||
*/
|
||||
public static String formatFen(Integer priceFen) {
|
||||
return BigDecimal.valueOf(Math.abs(priceFen)).movePointLeft(2).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用方未显式传系统备注时,按业务类型+金额方向选择默认文案(金额取绝对值渲染)。
|
||||
*
|
||||
* @param bizTypeCode 业务类型编码,取值见 {@link WalletBizEnum#getCode()}
|
||||
* @param signedPriceFen 流水金额(带符号,支出为负),单位分
|
||||
* @return 默认文案;编码无法识别或金额为空时返回 null(保持 sys_remark 为空)
|
||||
*/
|
||||
public static String defaultRemark(String bizTypeCode, Integer signedPriceFen) {
|
||||
if (ObjectUtil.isEmpty(bizTypeCode) || signedPriceFen == null) {
|
||||
return null;
|
||||
}
|
||||
WalletBizEnum bizType = Arrays.stream(WalletBizEnum.values())
|
||||
.filter(v -> ObjectUtil.equals(v.getCode(), bizTypeCode))
|
||||
.findFirst().orElse(null);
|
||||
if (bizType == null) {
|
||||
return null;
|
||||
}
|
||||
boolean debit = signedPriceFen < 0;
|
||||
String template = switch (bizType) {
|
||||
case PAYMENT -> WALLET_PAY;
|
||||
case RECHARGE -> debit ? RECHARGE_REFUND_DEDUCT : WALLET_RECHARGE;
|
||||
case REFUND -> debit ? REFUND_ROLLBACK : REFUND_RETURN;
|
||||
case TRANSFER_ACCOUNTS -> debit ? TRANSFER_OUT : TRANSFER_IN;
|
||||
case TRANSFER_ACCOUNTS_REFUND -> TRANSFER_REFUND;
|
||||
case RETURN_CANTEEN -> RETURN_CANTEEN;
|
||||
case BATCH -> debit ? BATCH_DEDUCT : BATCH_RECHARGE;
|
||||
case RESET -> RESET_BALANCE;
|
||||
case UPDATE -> debit ? UPDATE_DECREASE : UPDATE_INCREASE;
|
||||
};
|
||||
return format(template, signedPriceFen);
|
||||
}
|
||||
}
|
||||
+6
@@ -45,6 +45,12 @@ public class WalletBalanceTransactionDto {
|
||||
*/
|
||||
private String tags;
|
||||
|
||||
/**
|
||||
* 系统备注,调用方按场景渲染好的流水简介(模板见 WalletSysRemarkTemplate);
|
||||
* 未传时由钱包服务按业务类型兜底生成,最多 256 个字符。
|
||||
*/
|
||||
private String sysRemark;
|
||||
|
||||
/** 订单实际所属商户ID;账户级操作允许为空并由钱包服务解析当前食堂。 */
|
||||
private Long sourceMchId;
|
||||
|
||||
|
||||
+7
@@ -71,4 +71,11 @@ public class WalletRechargeRefundDto {
|
||||
* 自动退款任务场景编码;过期预约任务为 20,空值兼容历史售后免审调用,人工退款不得填写。
|
||||
*/
|
||||
private String autoRefundTaskType;
|
||||
|
||||
/**
|
||||
* 系统备注,调用方按场景渲染好的流水简介(模板见 WalletSysRemarkTemplate,如"订单退款退回10元");
|
||||
* 未传时由钱包服务按退款类型兜底生成,最多 256 个字符。
|
||||
*/
|
||||
@Length(max = 256, message = "系统备注长度不能超过 256")
|
||||
private String sysRemark;
|
||||
}
|
||||
+9
@@ -92,6 +92,15 @@ public class WalletTransactionEntity extends Model<WalletTransactionEntity> {
|
||||
@Schema(description = "流水备注,最多256个字符")
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 系统备注,由服务端按场景模板自动生成的流水简介(如"在食堂线下消费10元"),
|
||||
* 文案模板见 {@link com.bsd.pjcloud.wallet.api.constant.WalletSysRemarkTemplate};
|
||||
* 与人工备注 remark 职责分离、互不覆盖,历史流水和模板无法覆盖的场景允许为空,最多 256 个字符。
|
||||
*/
|
||||
@Size(max = 256, message = "系统备注不能超过256个字符")
|
||||
@Schema(description = "系统备注,按场景模板自动生成的流水简介,最多256个字符")
|
||||
private String sysRemark;
|
||||
|
||||
/**
|
||||
* 创建人
|
||||
*/
|
||||
|
||||
+11
@@ -13,6 +13,7 @@ import com.bsd.pjcloud.wallet.api.vo.WalletTransactionSumAndCountVo;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -66,6 +67,16 @@ public interface RemoteWalletTransactionService {
|
||||
@PostMapping("/wallet-transaction/sum-and-count")
|
||||
R<List<WalletTransactionSumAndCountVo>> sumAndCount(@RequestBody WalletTransactionSumAndCountDto dto);
|
||||
|
||||
/**
|
||||
* 定时任务回填:跨租户按主键游标分批为历史流水生成系统备注,仅 daemon-quartz 调用。
|
||||
*
|
||||
* @param maxBatches 每租户本轮最多处理的批次(每批 500 行),为空时服务端取默认值
|
||||
* @return 本轮实际回填行数;0 表示已全部收敛
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/wallet-transaction/inner/backfill-sys-remark")
|
||||
R<Integer> backfillSysRemark(@RequestParam(value = "maxBatches", required = false) Integer maxBatches);
|
||||
|
||||
/**
|
||||
* 按业务类型和业务号批量查询流水创建时固化的部门快照。
|
||||
*
|
||||
|
||||
+1
-1
@@ -15,6 +15,6 @@ import lombok.EqualsAndHashCode;
|
||||
public class WalletRechargePersonExportExcelVo extends WalletTransactionExportExcelVo {
|
||||
|
||||
/** 流水所属食堂名称,来源于钱包商户部门视图;无法匹配时为空。 */
|
||||
@ExcelProperty(value = {"食堂", "所属食堂"}, index = 19)
|
||||
@ExcelProperty(value = {"食堂", "所属食堂"}, index = 20)
|
||||
private String canteenName;
|
||||
}
|
||||
+6
-1
@@ -60,8 +60,13 @@ public class WalletTransactionExportExcelVo extends WalletUserExportExcelVo {
|
||||
@ColumnWidth(24)
|
||||
private String remark;
|
||||
|
||||
/** 服务端按场景模板自动生成的流水简介(如"在食堂线下消费10元");历史未回填记录允许为空。 */
|
||||
@ExcelProperty(value = {"流水", "系统备注"}, index = 18)
|
||||
@ColumnWidth(24)
|
||||
private String sysRemark;
|
||||
|
||||
/** 流水创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */
|
||||
@ExcelProperty(value = {"流水", "交易时间"}, index = 18)
|
||||
@ExcelProperty(value = {"流水", "交易时间"}, index = 19)
|
||||
@ColumnWidth(22)
|
||||
private String createTime;
|
||||
}
|
||||
+24
-5
@@ -27,6 +27,7 @@ import com.bsd.pjcloud.wallet.api.vo.WalletTransactionSumAndCountVo;
|
||||
import com.bsd.pjcloud.wallet.api.vo.WalletTypeTotalVo;
|
||||
import com.bsd.pjcloud.wallet.api.vo.transaction.WalletTransactionVo;
|
||||
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
|
||||
import com.bsd.pjcloud.wallet.service.WalletSysRemarkBackfillService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletTransactionService;
|
||||
import com.bsd.pjcloud.wallet.service.WalletTransactionExportScene;
|
||||
import com.bsd.pjcloud.wallet.service.WalletUserExportService;
|
||||
@@ -64,12 +65,15 @@ public class WalletTransactionController {
|
||||
/** 钱包用户导出服务,负责异步分片、用户身份补齐和自定义二级表头文件生成。 */
|
||||
private final WalletUserExportService walletUserExportService;
|
||||
|
||||
/** 历史流水系统备注回填任务,daemon 定时任务或手动 Inner 调用反复触发直至收敛。 */
|
||||
private final WalletSysRemarkBackfillService walletSysRemarkBackfillService;
|
||||
|
||||
/* ************************************* start 远程调用 start ************************************* */
|
||||
@Inner
|
||||
@PostMapping("/add-balance")
|
||||
public R<WalletTransactionEntity> addWalletBalance(@Valid @RequestBody WalletBalanceTransactionDto dto) {
|
||||
WalletTransactionEntity walletTransaction = WalletMessageContextHolder.supplyWith(List.of(dto),
|
||||
() -> walletTransactionService.addWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getTags()));
|
||||
() -> walletTransactionService.addWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getTags(), null, dto.getSysRemark()));
|
||||
if (ObjectUtil.isEmpty(walletTransaction)) return R.failed("增加钱包余额错误");
|
||||
return R.ok(walletTransaction);
|
||||
}
|
||||
@@ -78,7 +82,7 @@ public class WalletTransactionController {
|
||||
@PostMapping("/reduce-balance")
|
||||
public R<WalletTransactionEntity> reduceWalletBalance(@Valid @RequestBody WalletBalanceTransactionDto dto) {
|
||||
WalletTransactionEntity walletTransaction = WalletMessageContextHolder.supplyWith(List.of(dto),
|
||||
() -> walletTransactionService.reduceWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getForce()));
|
||||
() -> walletTransactionService.reduceWalletBalance(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getForce(), dto.getSysRemark()));
|
||||
if (ObjectUtil.isEmpty(walletTransaction)) return R.failed("扣减钱包余额");
|
||||
return R.ok(walletTransaction);
|
||||
}
|
||||
@@ -90,7 +94,7 @@ public class WalletTransactionController {
|
||||
@PostMapping("/reduce-balance-idempotent")
|
||||
public R<WalletTransactionEntity> reduceWalletBalanceIdempotent(@Valid @RequestBody WalletBalanceTransactionDto dto) {
|
||||
return R.ok(WalletMessageContextHolder.supplyWith(List.of(dto),
|
||||
() -> walletTransactionService.reduceWalletBalanceIdempotent(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getForce())));
|
||||
() -> walletTransactionService.reduceWalletBalanceIdempotent(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getBizType(), dto.getForce(), dto.getSysRemark())));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,7 +118,7 @@ public class WalletTransactionController {
|
||||
@PostMapping("/compensate-payment")
|
||||
public R<Boolean> compensatePayment(@Valid @RequestBody WalletBalanceTransactionDto dto) {
|
||||
return R.ok(WalletMessageContextHolder.supplyWith(List.of(dto),
|
||||
() -> walletTransactionService.compensatePayment(dto.getWalletId(), dto.getPrice(), dto.getBizId())));
|
||||
() -> walletTransactionService.compensatePayment(dto.getWalletId(), dto.getPrice(), dto.getBizId(), dto.getSysRemark())));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,7 +157,7 @@ public class WalletTransactionController {
|
||||
* 用一次内部服务请求查询多个支付或退款分组的流水历史部门快照。
|
||||
*
|
||||
* @param dto 已按业务类型和数据库参数容量拆分的查询组
|
||||
* @return 所有查询组命中的历史部门快照
|
||||
* @return 所有查询组命中的流水历史部门快照
|
||||
*/
|
||||
@Inner
|
||||
@PostMapping("/department-snapshots-batch")
|
||||
@@ -161,6 +165,21 @@ public class WalletTransactionController {
|
||||
@Valid @RequestBody WalletTransactionDepartmentSnapshotBatchDto dto) {
|
||||
return R.ok(walletTransactionService.listDepartmentSnapshotsBatch(dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时任务入口:跨租户分批回填历史流水的系统备注(仅 daemon-quartz 的
|
||||
* walletSysRemarkBackfillTask 经 Feign 调用,@Inner 禁止外部请求,不支持手动触发)。
|
||||
* 每轮最多处理 maxBatches×500 行,返回 0 表示已全部收敛。
|
||||
*
|
||||
* @param maxBatches 每租户本轮最多处理的批次,每批 500 行,默认 100,上限 1000
|
||||
* @return 全部租户本轮实际回填的行数
|
||||
*/
|
||||
@Inner
|
||||
@PostMapping("/inner/backfill-sys-remark")
|
||||
public R<Integer> backfillSysRemark(
|
||||
@RequestParam(required = false, defaultValue = "100") Integer maxBatches) {
|
||||
return R.ok(walletSysRemarkBackfillService.backfillAllTenants(Math.min(maxBatches, 1000)));
|
||||
}
|
||||
/* ************************************* end 远程调用 end ************************************* */
|
||||
|
||||
/* ************************************* start app start ************************************* */
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.bsd.pjcloud.wallet.service;
|
||||
|
||||
/**
|
||||
* 钱包流水系统备注(sys_remark)历史数据回填任务。
|
||||
*
|
||||
* <p>任务只处理 sys_remark 仍为空的历史流水,与线上新写入互不冲突,天然幂等可重跑;
|
||||
* 由 daemon-quartz 的 walletSysRemarkBackfillTask 定时任务经 Feign 反复调用直至返回 0。</p>
|
||||
*
|
||||
* @author pjcloud
|
||||
*/
|
||||
public interface WalletSysRemarkBackfillService {
|
||||
|
||||
/**
|
||||
* 跨租户按主键游标分批回填历史流水的系统备注。
|
||||
*
|
||||
* <p>每租户最多处理 {@code maxBatches * 500} 行;PAYMENT/REFUND 行按 bizId 反查硬件订单
|
||||
* 渲染场景文案(与在线写入共用 HardOrderSysRemarkHelper 口径),其余按业务类型兜底。
|
||||
* 单租户异常不阻断其余租户,留待下一轮重试。</p>
|
||||
*
|
||||
* @param maxBatches 每租户本轮最多处理的批次(每批 500 行),需大于 0
|
||||
* @return 全部租户本轮实际回填的行数;返回 0 表示已全部收敛
|
||||
*/
|
||||
int backfillAllTenants(int maxBatches);
|
||||
}
|
||||
+38
@@ -105,11 +105,26 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
|
||||
*/
|
||||
WalletTransactionEntity reduceWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force);
|
||||
|
||||
/**
|
||||
* 扣减钱包余额并携带系统备注。
|
||||
*
|
||||
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
|
||||
* @return 钱包流水
|
||||
*/
|
||||
WalletTransactionEntity reduceWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force, String sysRemark);
|
||||
|
||||
/**
|
||||
* 按业务编号幂等扣减钱包余额。
|
||||
*/
|
||||
WalletTransactionEntity reduceWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force);
|
||||
|
||||
/**
|
||||
* 按业务编号幂等扣减钱包余额并携带系统备注。
|
||||
*
|
||||
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
|
||||
*/
|
||||
WalletTransactionEntity reduceWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force, String sysRemark);
|
||||
|
||||
/**
|
||||
* 在同一本地事务内锁定全部钱包、校验整批余额并按业务号幂等扣款。
|
||||
*
|
||||
@@ -123,6 +138,13 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
|
||||
*/
|
||||
Boolean compensatePayment(Long walletId, Integer price, String bizId);
|
||||
|
||||
/**
|
||||
* 对指定支付业务执行幂等补偿并携带系统备注;支付流水不存在时不增加余额。
|
||||
*
|
||||
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
|
||||
*/
|
||||
Boolean compensatePayment(Long walletId, Integer price, String bizId, String sysRemark);
|
||||
|
||||
/**
|
||||
* 在同一本地事务内批量补偿已存在的支付流水,未扣款和已补偿明细均按成功处理。
|
||||
*
|
||||
@@ -167,6 +189,14 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
|
||||
*/
|
||||
WalletTransactionEntity addWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark);
|
||||
|
||||
/**
|
||||
* 锁定目标钱包并按稳定业务号幂等增加余额,同时记录财务统计标签、流水备注和系统备注。
|
||||
*
|
||||
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
|
||||
* @return 首次创建或此前已经存在的同一笔钱包流水
|
||||
*/
|
||||
WalletTransactionEntity addWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark, String sysRemark);
|
||||
|
||||
/**
|
||||
* 增加钱包余额并记录财务统计标签。
|
||||
*
|
||||
@@ -192,6 +222,14 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
|
||||
*/
|
||||
WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark);
|
||||
|
||||
/**
|
||||
* 增加钱包余额并记录财务统计标签、流水备注和系统备注。
|
||||
*
|
||||
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
|
||||
* @return 已保存的钱包流水
|
||||
*/
|
||||
WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark, String sysRemark);
|
||||
|
||||
/**
|
||||
* 只修改单笔钱包流水的业务备注,空白内容按清除备注处理。
|
||||
*
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package com.bsd.pjcloud.wallet.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysTenant;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteTenantService;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
|
||||
import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService;
|
||||
import com.bsd.pjcloud.hard.api.utils.HardOrderSysRemarkHelper;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletSysRemarkTemplate;
|
||||
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
|
||||
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
|
||||
import com.bsd.pjcloud.wallet.service.WalletSysRemarkBackfillService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 钱包流水系统备注历史数据回填实现。
|
||||
*
|
||||
* <p>wallet_transaction 线上约 150 万行,为控制大表与跨服务查询压力:
|
||||
* 按主键游标每批 500 行、批间休眠、每轮调用限量(maxBatches),由外部反复触发直至收敛;
|
||||
* 单行更新走主键条件,不触碰 update_by/update_time 审计字段。</p>
|
||||
*
|
||||
* @author pjcloud
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WalletSysRemarkBackfillServiceImpl implements WalletSysRemarkBackfillService {
|
||||
|
||||
private static final String TAG = "WalletSysRemarkBackfill";
|
||||
|
||||
/** 每批处理的历史流水行数(主键游标分页)。 */
|
||||
private static final int BATCH_SIZE = 500;
|
||||
|
||||
/** 批间休眠毫秒数,控制对大表写入和硬件订单查询接口的压力。 */
|
||||
private static final long BATCH_INTERVAL_MS = 20L;
|
||||
|
||||
private final WalletTransactionMapper walletTransactionMapper;
|
||||
|
||||
private final RemoteTenantService tenantService;
|
||||
|
||||
private final RemoteHardOrderService remoteHardOrderService;
|
||||
|
||||
/**
|
||||
* 跨租户入口:逐租户切换上下文执行回填;单租户失败不阻断其余租户,留待下一轮重试。
|
||||
*/
|
||||
@Override
|
||||
public int backfillAllTenants(int maxBatches) {
|
||||
if (maxBatches <= 0) {
|
||||
throw new IllegalArgumentException("maxBatches must be positive");
|
||||
}
|
||||
List<SysTenant> tenants = tenantService.list().getData();
|
||||
if (ObjectUtil.isEmpty(tenants)) {
|
||||
log.warn("|{}|系统备注回填未查询到有效租户", TAG);
|
||||
return 0;
|
||||
}
|
||||
int total = 0;
|
||||
for (SysTenant tenant : tenants) {
|
||||
try {
|
||||
Integer tenantProcessed = TenantBroker.applyAs(tenant.getId(),
|
||||
tenantId -> backfillInTenant(maxBatches));
|
||||
total += ObjectUtil.defaultIfNull(tenantProcessed, 0);
|
||||
} catch (Exception ex) {
|
||||
log.error("|{}|租户系统备注回填异常,tenantId:{}", TAG, tenant.getId(), ex);
|
||||
}
|
||||
}
|
||||
log.info("|{}|系统备注回填完成,租户数:{},本轮回填行数:{}", TAG, tenants.size(), total);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前租户内按主键游标分批处理 sys_remark 为空的历史流水,返回本轮实际处理行数。
|
||||
*/
|
||||
private Integer backfillInTenant(int maxBatches) {
|
||||
int processedTotal = 0;
|
||||
long cursor = 0L;
|
||||
for (int batchIndex = 0; batchIndex < maxBatches; batchIndex++) {
|
||||
// 只捞系统备注仍为空的行:与线上新写入天然不冲突,任务可随时暂停/重跑
|
||||
List<WalletTransactionEntity> rows = walletTransactionMapper.selectList(
|
||||
new LambdaQueryWrapper<WalletTransactionEntity>()
|
||||
.select(WalletTransactionEntity::getId, WalletTransactionEntity::getWalletId,
|
||||
WalletTransactionEntity::getBizType, WalletTransactionEntity::getBizId,
|
||||
WalletTransactionEntity::getPrice)
|
||||
.isNull(WalletTransactionEntity::getSysRemark)
|
||||
.gt(WalletTransactionEntity::getId, cursor)
|
||||
.orderByAsc(WalletTransactionEntity::getId)
|
||||
.last("limit " + BATCH_SIZE));
|
||||
if (ObjectUtil.isEmpty(rows)) {
|
||||
break;
|
||||
}
|
||||
cursor = rows.get(rows.size() - 1).getId();
|
||||
renderAndApply(rows);
|
||||
processedTotal += rows.size();
|
||||
if (rows.size() < BATCH_SIZE) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(BATCH_INTERVAL_MS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return processedTotal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染并落库一批流水:PAYMENT/REFUND 行按 bizId 反查硬件订单渲染场景文案,其余按业务类型兜底。
|
||||
*/
|
||||
private void renderAndApply(List<WalletTransactionEntity> rows) {
|
||||
Map<Long, HardOrderEntity> orderMap = fetchOrdersByIds(rows);
|
||||
int updated = 0;
|
||||
for (WalletTransactionEntity row : rows) {
|
||||
String sysRemark = resolveSysRemark(row, orderMap);
|
||||
if (ObjectUtil.isEmpty(sysRemark)) {
|
||||
continue;
|
||||
}
|
||||
// 传空实体跳过 MyBatis-Plus 审计字段填充:数据回填不改动 update_by/update_time
|
||||
walletTransactionMapper.update(null, Wrappers.<WalletTransactionEntity>lambdaUpdate()
|
||||
.eq(WalletTransactionEntity::getId, row.getId())
|
||||
.set(WalletTransactionEntity::getSysRemark, sysRemark));
|
||||
updated++;
|
||||
}
|
||||
log.info("|{}|系统备注回填批次完成,行数:{},更新数:{}", TAG, rows.size(), updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集本批 PAYMENT/REFUND 行的可解析订单号并分批查询硬件订单;查询失败仅记录告警,
|
||||
* 对应行回退业务类型兜底文案,不中断整体回填。
|
||||
*/
|
||||
private Map<Long, HardOrderEntity> fetchOrdersByIds(List<WalletTransactionEntity> rows) {
|
||||
List<Long> orderIds = rows.stream()
|
||||
.filter(row -> isPaymentOrRefund(row.getBizType()))
|
||||
.map(WalletTransactionEntity::getBizId)
|
||||
.map(WalletSysRemarkBackfillServiceImpl::parseLongOrNull)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (orderIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, HardOrderEntity> orderMap = new HashMap<>(orderIds.size());
|
||||
for (List<Long> idBatch : ListUtil.partition(orderIds, BATCH_SIZE)) {
|
||||
R<List<HardOrderEntity>> result;
|
||||
try {
|
||||
result = remoteHardOrderService.getByIds(idBatch);
|
||||
} catch (Exception ex) {
|
||||
log.warn("|{}|系统备注回填查询订单失败,本批走兜底文案,orderCount:{}", TAG, idBatch.size(), ex);
|
||||
continue;
|
||||
}
|
||||
if (result == null || !result.isOk() || result.getData() == null) {
|
||||
log.warn("|{}|系统备注回填查询订单未成功,本批走兜底文案,orderCount:{},code:{}",
|
||||
TAG, idBatch.size(), result == null ? null : result.getCode());
|
||||
continue;
|
||||
}
|
||||
result.getData().forEach(order -> orderMap.put(order.getId(), order));
|
||||
}
|
||||
return orderMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单行流水的系统备注:命中订单时按订单场景渲染(与在线写入共用同一映射口径),
|
||||
* 未命中(售后退款、充值退款、转账等)按业务类型+金额方向兜底。
|
||||
*/
|
||||
private String resolveSysRemark(WalletTransactionEntity row, Map<Long, HardOrderEntity> orderMap) {
|
||||
HardOrderEntity order = null;
|
||||
if (isPaymentOrRefund(row.getBizType())) {
|
||||
Long orderId = parseLongOrNull(row.getBizId());
|
||||
order = orderId == null ? null : orderMap.get(orderId);
|
||||
// 订单号可能与其他业务号数值撞车,校验钱包一致才认定是本订单的支付/补偿流水
|
||||
if (order != null && !Objects.equals(order.getWalletId(), row.getWalletId())) {
|
||||
order = null;
|
||||
}
|
||||
}
|
||||
if (order == null) {
|
||||
return WalletSysRemarkTemplate.defaultRemark(row.getBizType(), row.getPrice());
|
||||
}
|
||||
if (WalletBizEnum.PAYMENT.getCode().equals(row.getBizType())) {
|
||||
return HardOrderSysRemarkHelper.renderConsume(order.getOrderType(), order.getSysType(), row.getPrice());
|
||||
}
|
||||
// REFUND 命中订单说明是预订批次补偿退回
|
||||
return HardOrderSysRemarkHelper.renderCompensateRefund(row.getPrice());
|
||||
}
|
||||
|
||||
private boolean isPaymentOrRefund(String bizType) {
|
||||
return WalletBizEnum.PAYMENT.getCode().equals(bizType) || WalletBizEnum.REFUND.getCode().equals(bizType);
|
||||
}
|
||||
|
||||
private static Long parseLongOrNull(String bizId) {
|
||||
if (ObjectUtil.isEmpty(bizId)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(bizId);
|
||||
} catch (NumberFormatException ex) {
|
||||
// 售后退款、充值退款等业务号不是订单 ID,直接走兜底文案
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -34,6 +34,7 @@ import com.bsd.pjcloud.common.data.datascope.PjcloudDefaultDataScopeHandle;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import com.bsd.pjcloud.thirdparty.constant.appmessage.AppMessageEventEnum;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletSysRemarkTemplate;
|
||||
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
|
||||
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
|
||||
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
|
||||
@@ -186,6 +187,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
|
||||
/**
|
||||
* 保存新钱包流水并在同一本地事务内追加业务消息Outbox;幂等查询旧流水不会进入本方法。
|
||||
* 调用方未显式传系统备注时,按业务类型+金额方向自动生成默认场景文案(模板见 WalletSysRemarkTemplate)。
|
||||
*
|
||||
* @param entity 待持久化钱包流水
|
||||
* @return 流水和Outbox均写入成功时返回true
|
||||
@@ -193,6 +195,10 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean save(WalletTransactionEntity entity) {
|
||||
// 系统备注兜底:保证所有新流水 sys_remark 尽量非空,无法识别的场景保持为空
|
||||
if (StrUtil.isBlank(entity.getSysRemark())) {
|
||||
entity.setSysRemark(WalletSysRemarkTemplate.defaultRemark(entity.getBizType(), entity.getPrice()));
|
||||
}
|
||||
boolean saved = baseMapper.insert(entity) > 0;
|
||||
if (saved) {
|
||||
createMessageOutbox(entity);
|
||||
@@ -281,6 +287,17 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
// @CacheEvict(value = CacheConstants.WALLET_DETAIL, key = "#walletId")
|
||||
public WalletTransactionEntity reduceWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force) {
|
||||
return getSelf().reduceWalletBalance(walletId, price, bizId, bizType, force, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带系统备注的扣减钱包余额;备注为空时由 save 按业务类型兜底生成默认场景文案。
|
||||
*
|
||||
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WalletTransactionEntity reduceWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force, String sysRemark) {
|
||||
if (ObjectUtil.isEmpty(force)) force = false;
|
||||
WalletManageEntity wallet = walletManageService.getById(walletId);
|
||||
if (ObjectUtil.isEmpty(wallet)) {
|
||||
@@ -302,6 +319,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
insetObj.setBizId(String.valueOf(bizId));
|
||||
insetObj.setBizType(bizType.getCode());
|
||||
insetObj.setTitle(bizType.getDesc());
|
||||
insetObj.setSysRemark(sysRemark);
|
||||
save(insetObj);
|
||||
return insetObj;
|
||||
}
|
||||
@@ -312,6 +330,17 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WalletTransactionEntity reduceWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force) {
|
||||
return getSelf().reduceWalletBalanceIdempotent(walletId, price, bizId, bizType, force, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带系统备注的幂等扣减钱包余额;备注为空时由 save 按业务类型兜底生成默认场景文案。
|
||||
*
|
||||
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WalletTransactionEntity reduceWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, Boolean force, String sysRemark) {
|
||||
if (ObjectUtil.isEmpty(force)) {
|
||||
force = false;
|
||||
}
|
||||
@@ -330,6 +359,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
throw new I18nBizException(ErrorCodes.COMMON_WALLET_BALANCE_INSUFFICIENT);
|
||||
}
|
||||
WalletTransactionEntity transaction = buildWalletTransaction(wallet, walletId, -price, wallet.getBalance() - price, bizId, bizType);
|
||||
transaction.setSysRemark(sysRemark);
|
||||
save(transaction);
|
||||
return transaction;
|
||||
}
|
||||
@@ -396,6 +426,8 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
WalletTransactionEntity transaction = buildWalletTransaction(wallet, payment.getWalletId(),
|
||||
-payment.getPrice(), afterBalance, payment.getBizId(), WalletBizEnum.PAYMENT);
|
||||
transaction.setTags(payment.getTags());
|
||||
// 订单调用方按场景渲染好的系统备注(如"在食堂线上预订消费10元"),为空时由 save 兜底
|
||||
transaction.setSysRemark(payment.getSysRemark());
|
||||
if (!save(transaction)) {
|
||||
throw new I18nBizException(ErrorCodes.WALLET_TX_PAYMENT_SAVE_FAIL);
|
||||
}
|
||||
@@ -410,6 +442,17 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean compensatePayment(Long walletId, Integer price, String bizId) {
|
||||
return getSelf().compensatePayment(walletId, price, bizId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带系统备注的幂等补偿;备注为空时由 save 按业务类型兜底生成默认场景文案。
|
||||
*
|
||||
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean compensatePayment(Long walletId, Integer price, String bizId, String sysRemark) {
|
||||
WalletManageEntity wallet = walletManageMapper.selectByIdForUpdate(walletId);
|
||||
if (ObjectUtil.isEmpty(wallet)) {
|
||||
throw new I18nBizException(ErrorCodes.COMMON_WALLET_NOT_FOUND);
|
||||
@@ -432,6 +475,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
throw new I18nBizException(ErrorCodes.WALLET_TX_COMPENSATE_FAIL);
|
||||
}
|
||||
WalletTransactionEntity refund = buildWalletTransaction(wallet, walletId, price, wallet.getBalance() + price, bizId, WalletBizEnum.REFUND);
|
||||
refund.setSysRemark(sysRemark);
|
||||
save(refund);
|
||||
return true;
|
||||
}
|
||||
@@ -489,6 +533,8 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
runningBalanceMap.put(payment.getWalletId(), afterBalance);
|
||||
WalletTransactionEntity refund = buildWalletTransaction(wallet, payment.getWalletId(), payment.getPrice(),
|
||||
afterBalance, payment.getBizId(), WalletBizEnum.REFUND);
|
||||
// 补偿退回场景的系统备注(如"订单取消退回10元"),为空时由 save 兜底
|
||||
refund.setSysRemark(payment.getSysRemark());
|
||||
if (!save(refund)) {
|
||||
throw new I18nBizException(ErrorCodes.WALLET_TX_REFUND_SAVE_FAIL);
|
||||
}
|
||||
@@ -736,6 +782,25 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WalletTransactionEntity addWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark) {
|
||||
return getSelf().addWalletBalanceIdempotent(walletId, price, bizId, bizType, tags, remark, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁定钱包后按钱包、业务号和业务类型查询已有流水,解决远程调用成功但调用方未落状态时的重复入账;
|
||||
* 新建流水时同步保存财务标签、流水备注和系统备注。
|
||||
*
|
||||
* @param walletId 目标钱包 ID
|
||||
* @param price 增加金额,单位分
|
||||
* @param bizId 稳定业务号
|
||||
* @param bizType 钱包业务类型
|
||||
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
|
||||
* @param remark 流水业务备注,允许为空,最多 256 个字符
|
||||
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
|
||||
* @return 首次创建或已经存在且金额一致的流水
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WalletTransactionEntity addWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark, String sysRemark) {
|
||||
WalletManageEntity wallet = walletManageMapper.selectByIdForUpdate(walletId);
|
||||
if (ObjectUtil.isEmpty(wallet)) {
|
||||
throw new I18nBizException(ErrorCodes.COMMON_WALLET_NOT_FOUND);
|
||||
@@ -757,6 +822,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
transaction.setTags(tags);
|
||||
String normalizedRemark = remark == null ? null : remark.strip();
|
||||
transaction.setRemark(ObjectUtil.isEmpty(normalizedRemark) ? null : normalizedRemark);
|
||||
transaction.setSysRemark(sysRemark);
|
||||
save(transaction);
|
||||
return transaction;
|
||||
}
|
||||
@@ -791,6 +857,24 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark) {
|
||||
return getSelf().addWalletBalance(walletId, price, bizId, bizType, tags, remark, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加钱包余额,并在同一笔最终流水中保存财务标签、人工备注和系统备注。
|
||||
*
|
||||
* @param walletId 钱包 id
|
||||
* @param price 增加金额,单位分
|
||||
* @param bizId 业务关联 id
|
||||
* @param bizType 业务关联分类
|
||||
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
|
||||
* @param remark 流水业务备注,允许为空,最多 256 个字符
|
||||
* @param sysRemark 调用方按场景渲染好的系统备注(模板见 WalletSysRemarkTemplate),允许为空
|
||||
* @return 已保存的钱包流水
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark, String sysRemark) {
|
||||
WalletManageEntity wallet = walletManageService.getById(walletId);
|
||||
if (ObjectUtil.isEmpty(wallet)) {
|
||||
log.error("|{}|,用户钱包id({})不存在.", TAG, walletId);
|
||||
@@ -823,6 +907,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
|
||||
insetObj.setTags(tags);
|
||||
String normalizedRemark = remark == null ? null : remark.strip();
|
||||
insetObj.setRemark(ObjectUtil.isEmpty(normalizedRemark) ? null : normalizedRemark);
|
||||
insetObj.setSysRemark(sysRemark);
|
||||
save(insetObj);
|
||||
return insetObj;
|
||||
}
|
||||
|
||||
+1
@@ -725,6 +725,7 @@ public class WalletUserExportServiceImpl implements WalletUserExportService {
|
||||
row.setBalance(formatPrice(source.getBalance()));
|
||||
row.setTags(source.getTags());
|
||||
row.setRemark(source.getRemark());
|
||||
row.setSysRemark(source.getSysRemark());
|
||||
row.setCreateTime(formatDateTime(source.getCreateTime()));
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -330,8 +330,8 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
|
||||
}
|
||||
return resumeOrValidateExistingRefund(dto, existing);
|
||||
}
|
||||
// 增加金额
|
||||
walletTransactionService.addWalletBalance(wallet.getId(), dto.getPrice(), String.valueOf(dto.getAfterSaleId()), WalletBizEnum.REFUND);
|
||||
// 增加金额;售后退款调用方渲染的系统备注为空时由钱包服务按退款类型兜底
|
||||
walletTransactionService.addWalletBalance(wallet.getId(), dto.getPrice(), String.valueOf(dto.getAfterSaleId()), WalletBizEnum.REFUND, null, null, dto.getSysRemark());
|
||||
return BeanUtils.toBean(recharge, WalletRechargeVo.class);
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
|
||||
}
|
||||
|
||||
walletTransactionService.addWalletBalance(existing.getWalletId(), dto.getPrice(),
|
||||
String.valueOf(dto.getAfterSaleId()), WalletBizEnum.REFUND);
|
||||
String.valueOf(dto.getAfterSaleId()), WalletBizEnum.REFUND, null, null, dto.getSysRemark());
|
||||
LocalDateTime refundTime = LocalDateTime.now();
|
||||
WalletRechargeEntity update = WalletRechargeEntity.builder().id(existing.getId())
|
||||
.refundStatus(HardRefundStatusEnum.SUCCESS.getStatus()).refundTime(refundTime).build();
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.bsd.pjcloud.wallet.api.constant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* 钱包流水系统备注模板渲染测试:金额分转元、占位符渲染和业务类型兜底映射。
|
||||
*/
|
||||
class WalletSysRemarkTemplateTest {
|
||||
|
||||
/**
|
||||
* 金额分转元应去掉多余的尾零并取绝对值。
|
||||
*/
|
||||
@Test
|
||||
void formatFenShouldStripTrailingZeros() {
|
||||
assertEquals("10", WalletSysRemarkTemplate.formatFen(1000));
|
||||
assertEquals("10.5", WalletSysRemarkTemplate.formatFen(1050));
|
||||
assertEquals("0.01", WalletSysRemarkTemplate.formatFen(1));
|
||||
assertEquals("0.1", WalletSysRemarkTemplate.formatFen(10));
|
||||
assertEquals("10", WalletSysRemarkTemplate.formatFen(-1000));
|
||||
assertEquals("0", WalletSysRemarkTemplate.formatFen(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板渲染应替换金额占位符;模板或金额为空时返回 null 交由调用方兜底。
|
||||
*/
|
||||
@Test
|
||||
void formatShouldRenderAmountPlaceholder() {
|
||||
assertEquals("在食堂线下消费10元", WalletSysRemarkTemplate.format(WalletSysRemarkTemplate.CANTEEN_OFFLINE, 1000));
|
||||
assertEquals("在食堂线上预订消费10.5元", WalletSysRemarkTemplate.format(WalletSysRemarkTemplate.CANTEEN_ONLINE_RESERVE, 1050));
|
||||
assertEquals("订单取消退回0.01元", WalletSysRemarkTemplate.format(WalletSysRemarkTemplate.ORDER_COMPENSATE_REFUND, -1));
|
||||
assertNull(WalletSysRemarkTemplate.format(null, 1000));
|
||||
assertNull(WalletSysRemarkTemplate.format(WalletSysRemarkTemplate.CANTEEN_OFFLINE, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 充值、支付、退款按业务类型和金额方向渲染默认文案。
|
||||
*/
|
||||
@Test
|
||||
void defaultRemarkShouldResolveByBizTypeAndDirection() {
|
||||
assertEquals("钱包消费10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.PAYMENT.getCode(), -1000));
|
||||
assertEquals("钱包充值10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.RECHARGE.getCode(), 1000));
|
||||
assertEquals("退款退回10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.REFUND.getCode(), 1000));
|
||||
assertEquals("退款冲正扣回10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.REFUND.getCode(), -1000));
|
||||
assertEquals("充值退款扣回10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.RECHARGE.getCode(), -1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* 转账、批量、重置和后台调整按方向渲染对应文案。
|
||||
*/
|
||||
@Test
|
||||
void defaultRemarkShouldCoverTransferBatchResetAndUpdate() {
|
||||
assertEquals("转账转出10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.TRANSFER_ACCOUNTS.getCode(), -1000));
|
||||
assertEquals("转账转入10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.TRANSFER_ACCOUNTS.getCode(), 1000));
|
||||
assertEquals("转账退回10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.TRANSFER_ACCOUNTS_REFUND.getCode(), 1000));
|
||||
assertEquals("退还食堂10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.RETURN_CANTEEN.getCode(), 1000));
|
||||
assertEquals("批量充值10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.BATCH.getCode(), 1000));
|
||||
assertEquals("批量扣款10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.BATCH.getCode(), -1000));
|
||||
assertEquals("重置余额10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.RESET.getCode(), -1000));
|
||||
assertEquals("后台调整余额增加10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.UPDATE.getCode(), 1000));
|
||||
assertEquals("后台调整余额减少10元",
|
||||
WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.UPDATE.getCode(), -1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* 无法识别的业务编码或空金额不得生成兜底文案,保持 sys_remark 为空。
|
||||
*/
|
||||
@Test
|
||||
void defaultRemarkShouldReturnNullForUnknownInput() {
|
||||
assertNull(WalletSysRemarkTemplate.defaultRemark("99", 1000));
|
||||
assertNull(WalletSysRemarkTemplate.defaultRemark(null, 1000));
|
||||
assertNull(WalletSysRemarkTemplate.defaultRemark(WalletBizEnum.PAYMENT.getCode(), null));
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package com.bsd.pjcloud.wallet.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.bsd.pjcloud.admin.api.entity.SysTenant;
|
||||
import com.bsd.pjcloud.admin.api.feign.RemoteTenantService;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
|
||||
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.entity.WalletTransactionEntity;
|
||||
import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 钱包流水系统备注历史回填测试:订单场景渲染、未命中兜底和收敛判定。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WalletSysRemarkBackfillServiceImplTest {
|
||||
|
||||
/** 钱包流水 Mapper Mock,捕获回填写入的系统备注值。 */
|
||||
@Mock
|
||||
private WalletTransactionMapper walletTransactionMapper;
|
||||
|
||||
/** 租户远程服务 Mock,提供单租户回填上下文。 */
|
||||
@Mock
|
||||
private RemoteTenantService tenantService;
|
||||
|
||||
/** 硬件订单远程服务 Mock,提供订单类型反查。 */
|
||||
@Mock
|
||||
private RemoteHardOrderService remoteHardOrderService;
|
||||
|
||||
/** 被测系统备注回填服务。 */
|
||||
@InjectMocks
|
||||
private WalletSysRemarkBackfillServiceImpl service;
|
||||
|
||||
/**
|
||||
* 初始化 MyBatis-Plus 实体元数据缓存:纯单测没有 MyBatis 启动流程,
|
||||
* Lambda 包装器解析实体列前必须先注册 TableInfo,否则抛 lambda cache 异常。
|
||||
*/
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
WalletTransactionEntity.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAYMENT 行按 bizId 命中订单时应渲染订单场景文案(与在线写入同一映射口径)。
|
||||
*/
|
||||
@Test
|
||||
void paymentRowShouldRenderOrderSceneRemark() {
|
||||
stubSingleTenant();
|
||||
WalletTransactionEntity row = transaction(1L, 20L, WalletBizEnum.PAYMENT.getCode(), "9001", -1000);
|
||||
when(walletTransactionMapper.selectList(any(Wrapper.class))).thenReturn(List.of(row));
|
||||
when(remoteHardOrderService.getByIds(anyList()))
|
||||
.thenReturn(R.ok(List.of(order(9001L, 20L, HardOrderTypeEnum.RESERVE, HardTypeEnum.CANTEEN))));
|
||||
when(walletTransactionMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||
|
||||
int processed = service.backfillAllTenants(1);
|
||||
|
||||
assertEquals(1, processed);
|
||||
assertTrue(captureUpdateValues().contains("在食堂线上预订消费10元"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务号不是订单 ID(退款冲正前缀等)时应跳过订单反查并按业务类型兜底。
|
||||
*/
|
||||
@Test
|
||||
void nonOrderBizIdShouldFallbackToBizTypeDefault() {
|
||||
stubSingleTenant();
|
||||
WalletTransactionEntity row = transaction(2L, 20L, WalletBizEnum.REFUND.getCode(), "REFUND_ROLLBACK_123", -1000);
|
||||
when(walletTransactionMapper.selectList(any(Wrapper.class))).thenReturn(List.of(row));
|
||||
when(walletTransactionMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||
|
||||
int processed = service.backfillAllTenants(1);
|
||||
|
||||
assertEquals(1, processed);
|
||||
verify(remoteHardOrderService, never()).getByIds(anyList());
|
||||
assertTrue(captureUpdateValues().contains("退款冲正扣回10元"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单号命中但钱包不一致(业务号数值撞车)时必须回落兜底文案,防止错标场景。
|
||||
*/
|
||||
@Test
|
||||
void walletMismatchedOrderShouldFallback() {
|
||||
stubSingleTenant();
|
||||
WalletTransactionEntity row = transaction(3L, 20L, WalletBizEnum.PAYMENT.getCode(), "9001", -1000);
|
||||
when(walletTransactionMapper.selectList(any(Wrapper.class))).thenReturn(List.of(row));
|
||||
when(remoteHardOrderService.getByIds(anyList()))
|
||||
.thenReturn(R.ok(List.of(order(9001L, 99L, HardOrderTypeEnum.BELOW, null))));
|
||||
when(walletTransactionMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||
|
||||
int processed = service.backfillAllTenants(1);
|
||||
|
||||
assertEquals(1, processed);
|
||||
assertTrue(captureUpdateValues().contains("钱包消费10元"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前租户没有待回填流水时应返回 0 且不发起任何更新。
|
||||
*/
|
||||
@Test
|
||||
void emptyTenantShouldReturnZeroWithoutUpdates() {
|
||||
stubSingleTenant();
|
||||
when(walletTransactionMapper.selectList(any(Wrapper.class))).thenReturn(List.of());
|
||||
|
||||
assertEquals(0, service.backfillAllTenants(1));
|
||||
|
||||
verify(walletTransactionMapper, never()).update(isNull(), any(Wrapper.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获回填 update 包装条件中的参数值,用于断言实际写入的系统备注文案。
|
||||
*
|
||||
* @return 本轮全部 update 的参数值集合字符串
|
||||
*/
|
||||
private String captureUpdateValues() {
|
||||
ArgumentCaptor<Wrapper<WalletTransactionEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(walletTransactionMapper).update(isNull(), captor.capture());
|
||||
LambdaUpdateWrapper<WalletTransactionEntity> wrapper =
|
||||
(LambdaUpdateWrapper<WalletTransactionEntity>) captor.getValue();
|
||||
return String.valueOf(wrapper.getParamNameValuePairs().values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供单个租户(ID=1)的回填上下文。
|
||||
*/
|
||||
private void stubSingleTenant() {
|
||||
SysTenant tenant = new SysTenant();
|
||||
tenant.setId(1L);
|
||||
when(tenantService.list()).thenReturn(R.ok(List.of(tenant)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一条待回填的钱包流水。
|
||||
*
|
||||
* @param id 流水主键
|
||||
* @param walletId 钱包 ID
|
||||
* @param bizType 业务类型编码
|
||||
* @param bizId 业务号
|
||||
* @param price 带符号金额,单位分
|
||||
* @return 待回填流水实体
|
||||
*/
|
||||
private WalletTransactionEntity transaction(Long id, Long walletId, String bizType, String bizId, Integer price) {
|
||||
WalletTransactionEntity row = new WalletTransactionEntity();
|
||||
row.setId(id);
|
||||
row.setWalletId(walletId);
|
||||
row.setBizType(bizType);
|
||||
row.setBizId(bizId);
|
||||
row.setPrice(price);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一条用于场景反查的硬件订单。
|
||||
*
|
||||
* @param orderId 订单 ID
|
||||
* @param walletId 支付钱包 ID
|
||||
* @param orderType 订单类型
|
||||
* @param sysType 系统类型;允许为空
|
||||
* @return 硬件订单实体
|
||||
*/
|
||||
private HardOrderEntity order(Long orderId, Long walletId, HardOrderTypeEnum orderType, HardTypeEnum sysType) {
|
||||
HardOrderEntity order = new HardOrderEntity();
|
||||
order.setId(orderId);
|
||||
order.setWalletId(walletId);
|
||||
order.setOrderType(orderType.getType());
|
||||
order.setSysType(sysType == null ? null : sysType.getType());
|
||||
return order;
|
||||
}
|
||||
}
|
||||
+55
@@ -228,6 +228,61 @@ class WalletTransactionServiceImplTest {
|
||||
verify(walletTransactionMapper, never()).insert(any(WalletTransactionEntity.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用方未传系统备注时,save 应按业务类型+金额方向自动生成默认场景文案。
|
||||
*/
|
||||
@Test
|
||||
void saveShouldFillDefaultSysRemarkWhenBlank() {
|
||||
WalletManageEntity wallet = wallet(20L, 1L);
|
||||
wallet.setBalance(100);
|
||||
WalletTransactionEntity entity = new WalletTransactionEntity();
|
||||
entity.setWalletId(20L);
|
||||
entity.setBizId("order-1001");
|
||||
entity.setBizType(WalletBizEnum.PAYMENT.getCode());
|
||||
entity.setPrice(-1000);
|
||||
entity.setBalance(0);
|
||||
when(walletTransactionMapper.insert(any(WalletTransactionEntity.class))).thenAnswer(invocation -> {
|
||||
WalletTransactionEntity transaction = invocation.getArgument(0);
|
||||
transaction.setId(1001L);
|
||||
transaction.setTenantId(1L);
|
||||
return 1;
|
||||
});
|
||||
when(walletManageMapper.selectById(20L)).thenReturn(wallet);
|
||||
when(walletMessageOutboxMapper.insert(any(WalletMessageOutboxEntity.class))).thenReturn(1);
|
||||
|
||||
service.save(entity);
|
||||
|
||||
assertEquals("钱包消费10元", entity.getSysRemark());
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用方已显式传入系统备注时,save 不得覆盖为兜底文案。
|
||||
*/
|
||||
@Test
|
||||
void saveShouldKeepExplicitSysRemark() {
|
||||
WalletManageEntity wallet = wallet(20L, 1L);
|
||||
wallet.setBalance(100);
|
||||
WalletTransactionEntity entity = new WalletTransactionEntity();
|
||||
entity.setWalletId(20L);
|
||||
entity.setBizId("order-1002");
|
||||
entity.setBizType(WalletBizEnum.PAYMENT.getCode());
|
||||
entity.setPrice(-1000);
|
||||
entity.setBalance(0);
|
||||
entity.setSysRemark("在食堂线下消费10元");
|
||||
when(walletTransactionMapper.insert(any(WalletTransactionEntity.class))).thenAnswer(invocation -> {
|
||||
WalletTransactionEntity transaction = invocation.getArgument(0);
|
||||
transaction.setId(1002L);
|
||||
transaction.setTenantId(1L);
|
||||
return 1;
|
||||
});
|
||||
when(walletManageMapper.selectById(20L)).thenReturn(wallet);
|
||||
when(walletMessageOutboxMapper.insert(any(WalletMessageOutboxEntity.class))).thenReturn(1);
|
||||
|
||||
service.save(entity);
|
||||
|
||||
assertEquals("在食堂线下消费10元", entity.getSysRemark());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造指定所有人和 ID 的 EAT 饭卡。
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user