feat:钱包流水备注

This commit is contained in:
dzc committed 2026-10-09 09:28:21 +08:00
1 parent 42ac8786ee
commit 65b00be0a4
26 files changed
+1399 -14

No files matched your search

+1
View File
@@ -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(大表建议分批)。
@@ -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);
}
}
@@ -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);
@@ -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);
@@ -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));
}
}
@@ -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;
}
}
}
@@ -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"));
}
}
@@ -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 分 -&gt; "在食堂线下消费10元",1050 分 -&gt; "在食堂线上预订消费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 -&gt; "10",1050 -&gt; "10.5",1 -&gt; "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);
}
}
@@ -45,6 +45,12 @@ public class WalletBalanceTransactionDto {
*/
private String tags;
/**
* 系统备注,调用方按场景渲染好的流水简介(模板见 WalletSysRemarkTemplate);
* 未传时由钱包服务按业务类型兜底生成,最多 256 个字符。
*/
private String sysRemark;
/** 订单实际所属商户ID;账户级操作允许为空并由钱包服务解析当前食堂。 */
private Long sourceMchId;
@@ -71,4 +71,11 @@ public class WalletRechargeRefundDto {
* 自动退款任务场景编码;过期预约任务为 20,空值兼容历史售后免审调用,人工退款不得填写。
*/
private String autoRefundTaskType;
/**
* 系统备注,调用方按场景渲染好的流水简介(模板见 WalletSysRemarkTemplate,如"订单退款退回10元");
* 未传时由钱包服务按退款类型兜底生成,最多 256 个字符。
*/
@Length(max = 256, message = "系统备注长度不能超过 256")
private String sysRemark;
}
@@ -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;
/**
* 创建人
*/
@@ -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);
/**
* 按业务类型和业务号批量查询流水创建时固化的部门快照。
*
@@ -15,6 +15,6 @@ import lombok.EqualsAndHashCode;
public class WalletRechargePersonExportExcelVo extends WalletTransactionExportExcelVo {
/** 流水所属食堂名称,来源于钱包商户部门视图;无法匹配时为空。 */
@ExcelProperty(value = {"食堂", "所属食堂"}, index = 19)
@ExcelProperty(value = {"食堂", "所属食堂"}, index = 20)
private String canteenName;
}
@@ -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;
}
@@ -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 ************************************* */
@@ -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);
}
@@ -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);
/**
* 只修改单笔钱包流水的业务备注,空白内容按清除备注处理。
*
@@ -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;
}
}
}
@@ -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;
}
@@ -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()));
}
@@ -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();
@@ -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));
}
}
@@ -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;
}
}
@@ -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 饭卡。
*