From 65b00be0a48cdcf77f4d872aca58d60e9017804b Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Fri, 9 Oct 2026 09:28:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E9=92=B1=E5=8C=85=E6=B5=81=E6=B0=B4?= =?UTF-8?q?=E5=A4=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + ...1008_add_wallet_transaction_sys_remark.sql | 24 ++ db/20261008_钱包流水系统备注回填定时任务.txt | 90 ++++++++ .../api/utils/HardOrderSysRemarkHelper.java | 91 ++++++++ .../impl/order/HardAfterSaleServiceImpl.java | 5 +- .../impl/order/HardOrderServiceImpl.java | 14 +- .../utils/HardOrderSysRemarkHelperTest.java | 78 +++++++ .../task/WalletSysRemarkBackfillTask.java | 84 +++++++ .../task/WalletSysRemarkBackfillTaskTest.java | 83 +++++++ .../api/constant/WalletSysRemarkTemplate.java | 168 ++++++++++++++ .../api/dto/WalletBalanceTransactionDto.java | 6 + .../dto/recharge/WalletRechargeRefundDto.java | 7 + .../api/entity/WalletTransactionEntity.java | 9 + .../feign/RemoteWalletTransactionService.java | 11 + .../WalletRechargePersonExportExcelVo.java | 2 +- .../WalletTransactionExportExcelVo.java | 7 +- .../WalletTransactionController.java | 29 ++- .../WalletSysRemarkBackfillService.java | 24 ++ .../service/WalletTransactionService.java | 38 ++++ .../WalletSysRemarkBackfillServiceImpl.java | 212 ++++++++++++++++++ .../impl/WalletTransactionServiceImpl.java | 85 +++++++ .../export/WalletUserExportServiceImpl.java | 1 + .../recharge/WalletRechargeServiceImpl.java | 6 +- .../constant/WalletSysRemarkTemplateTest.java | 89 ++++++++ ...alletSysRemarkBackfillServiceImplTest.java | 194 ++++++++++++++++ .../WalletTransactionServiceImplTest.java | 55 +++++ 26 files changed, 1399 insertions(+), 14 deletions(-) create mode 100644 db/20261008_add_wallet_transaction_sys_remark.sql create mode 100644 db/20261008_钱包流水系统备注回填定时任务.txt create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/HardOrderSysRemarkHelper.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/HardOrderSysRemarkHelperTest.java create mode 100644 pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/WalletSysRemarkBackfillTask.java create mode 100644 pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/task/WalletSysRemarkBackfillTaskTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletSysRemarkTemplate.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletSysRemarkBackfillService.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletSysRemarkBackfillServiceImpl.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/api/constant/WalletSysRemarkTemplateTest.java create mode 100644 pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletSysRemarkBackfillServiceImplTest.java diff --git a/.gitignore b/.gitignore index 70c4da28..bb70f160 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ scripts/recharge-import/ .mimosa .v2c .zcode +.zcodeignore diff --git a/db/20261008_add_wallet_transaction_sys_remark.sql b/db/20261008_add_wallet_transaction_sys_remark.sql new file mode 100644 index 00000000..ac01a416 --- /dev/null +++ b/db/20261008_add_wallet_transaction_sys_remark.sql @@ -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; diff --git a/db/20261008_钱包流水系统备注回填定时任务.txt b/db/20261008_钱包流水系统备注回填定时任务.txt new file mode 100644 index 00000000..6f791a0c --- /dev/null +++ b/db/20261008_钱包流水系统备注回填定时任务.txt @@ -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(大表建议分批)。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/HardOrderSysRemarkHelper.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/HardOrderSysRemarkHelper.java new file mode 100644 index 00000000..d5e7a2fc --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/HardOrderSysRemarkHelper.java @@ -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)的映射工具。 + * + *

按订单类型({@link HardOrderTypeEnum})+ 系统类型({@link HardTypeEnum})选择 + * {@link WalletSysRemarkTemplate} 中的文案模板并渲染金额。hardware 在线扣款与 + * wallet 历史流水回填共用本映射,保证新旧数据口径一致。

+ * + * @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); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index f9330745..4e28a012 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -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 walletRefundResult = walletRechargeService.refund(hardRefundDto); if (walletRefundResult == null || !walletRefundResult.isOk() || ObjectUtil.isEmpty(walletRefundResult.getData())) { throw new I18nBizException(ErrorCodes.AFTER_SALE_WALLET_REFUND_FAIL); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index b25e6d4f..80b65ca5 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -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 orders) { - List paymentList = buildReserveWalletTransactions(orders); + List paymentList = buildReserveWalletTransactions(orders, false); R> 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 buildReserveWalletTransactions(List orders) { + private List buildReserveWalletTransactions(List 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 result = walletTransactionService.compensatePaymentBatch(buildReserveWalletTransactions(createdOrders)); + R 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 reduceRes = walletTransactionService.reduceWalletBalance(reduceBuild); if (reduceRes.getCode() != 0 || ObjectUtil.isEmpty(reduceRes.getData())) { throw new I18nBizException(ErrorCodes.HARD_WALLET_DEDUCT_FAIL); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/HardOrderSysRemarkHelperTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/HardOrderSysRemarkHelperTest.java new file mode 100644 index 00000000..f545031f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/HardOrderSysRemarkHelperTest.java @@ -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)); + } +} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/WalletSysRemarkBackfillTask.java b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/WalletSysRemarkBackfillTask.java new file mode 100644 index 00000000..5db03a18 --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/WalletSysRemarkBackfillTask.java @@ -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 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; + } + } +} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/task/WalletSysRemarkBackfillTaskTest.java b/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/task/WalletSysRemarkBackfillTaskTest.java new file mode 100644 index 00000000..466578de --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/task/WalletSysRemarkBackfillTaskTest.java @@ -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")); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletSysRemarkTemplate.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletSysRemarkTemplate.java new file mode 100644 index 00000000..7d91500c --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletSysRemarkTemplate.java @@ -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)文案模板。 + * + *

所有模板使用 {@code {}} 作为金额占位符,金额单位为分,渲染时自动转为元并去掉多余的尾零 + * (1000 分 -> "在食堂线下消费10元",1050 分 -> "在食堂线上预订消费10.5元")。 + * 文案集中定义在本类,调整措辞只需修改常量值,新流水立即生效;历史流水不追溯。

+ * + *

两类模板:

+ *
    + *
  • 订单场景模板:由订单调用方(hardware 统一订单中心)按订单类型+系统类型选定后, + * 经 {@link com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto#getSysRemark()} 透传给钱包服务;
  • + *
  • 钱包内部默认模板:调用方未显式传文案时,钱包服务在落库前按业务类型+金额方向 + * 通过 {@link #defaultRemark(String, Integer)} 自动兜底。
  • + *
+ * + * @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); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java index b4ef5ef6..e4e06d32 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletBalanceTransactionDto.java @@ -45,6 +45,12 @@ public class WalletBalanceTransactionDto { */ private String tags; + /** + * 系统备注,调用方按场景渲染好的流水简介(模板见 WalletSysRemarkTemplate); + * 未传时由钱包服务按业务类型兜底生成,最多 256 个字符。 + */ + private String sysRemark; + /** 订单实际所属商户ID;账户级操作允许为空并由钱包服务解析当前食堂。 */ private Long sourceMchId; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java index 53f8c464..2e9477cc 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeRefundDto.java @@ -71,4 +71,11 @@ public class WalletRechargeRefundDto { * 自动退款任务场景编码;过期预约任务为 20,空值兼容历史售后免审调用,人工退款不得填写。 */ private String autoRefundTaskType; + + /** + * 系统备注,调用方按场景渲染好的流水简介(模板见 WalletSysRemarkTemplate,如"订单退款退回10元"); + * 未传时由钱包服务按退款类型兜底生成,最多 256 个字符。 + */ + @Length(max = 256, message = "系统备注长度不能超过 256") + private String sysRemark; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java index 21c5504d..78719162 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/WalletTransactionEntity.java @@ -92,6 +92,15 @@ public class WalletTransactionEntity extends Model { @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; + /** * 创建人 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java index 19d04103..2f116657 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletTransactionService.java @@ -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> sumAndCount(@RequestBody WalletTransactionSumAndCountDto dto); + /** + * 定时任务回填:跨租户按主键游标分批为历史流水生成系统备注,仅 daemon-quartz 调用。 + * + * @param maxBatches 每租户本轮最多处理的批次(每批 500 行),为空时服务端取默认值 + * @return 本轮实际回填行数;0 表示已全部收敛 + */ + @NoToken + @PostMapping("/wallet-transaction/inner/backfill-sys-remark") + R backfillSysRemark(@RequestParam(value = "maxBatches", required = false) Integer maxBatches); + /** * 按业务类型和业务号批量查询流水创建时固化的部门快照。 * diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargePersonExportExcelVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargePersonExportExcelVo.java index de14d46f..c74fea6d 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargePersonExportExcelVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargePersonExportExcelVo.java @@ -15,6 +15,6 @@ import lombok.EqualsAndHashCode; public class WalletRechargePersonExportExcelVo extends WalletTransactionExportExcelVo { /** 流水所属食堂名称,来源于钱包商户部门视图;无法匹配时为空。 */ - @ExcelProperty(value = {"食堂", "所属食堂"}, index = 19) + @ExcelProperty(value = {"食堂", "所属食堂"}, index = 20) private String canteenName; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java index ecdc94ee..b44bae09 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java @@ -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; } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java index 3cd63b61..6b3a37fc 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java @@ -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 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 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 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 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 backfillSysRemark( + @RequestParam(required = false, defaultValue = "100") Integer maxBatches) { + return R.ok(walletSysRemarkBackfillService.backfillAllTenants(Math.min(maxBatches, 1000))); + } /* ************************************* end 远程调用 end ************************************* */ /* ************************************* start app start ************************************* */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletSysRemarkBackfillService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletSysRemarkBackfillService.java new file mode 100644 index 00000000..69ae3757 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletSysRemarkBackfillService.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.wallet.service; + +/** + * 钱包流水系统备注(sys_remark)历史数据回填任务。 + * + *

任务只处理 sys_remark 仍为空的历史流水,与线上新写入互不冲突,天然幂等可重跑; + * 由 daemon-quartz 的 walletSysRemarkBackfillTask 定时任务经 Feign 反复调用直至返回 0。

+ * + * @author pjcloud + */ +public interface WalletSysRemarkBackfillService { + + /** + * 跨租户按主键游标分批回填历史流水的系统备注。 + * + *

每租户最多处理 {@code maxBatches * 500} 行;PAYMENT/REFUND 行按 bizId 反查硬件订单 + * 渲染场景文案(与在线写入共用 HardOrderSysRemarkHelper 口径),其余按业务类型兜底。 + * 单租户异常不阻断其余租户,留待下一轮重试。

+ * + * @param maxBatches 每租户本轮最多处理的批次(每批 500 行),需大于 0 + * @return 全部租户本轮实际回填的行数;返回 0 表示已全部收敛 + */ + int backfillAllTenants(int maxBatches); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java index 2ddb0aa7..331b38fa 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java @@ -105,11 +105,26 @@ public interface WalletTransactionService extends IServicewallet_transaction 线上约 150 万行,为控制大表与跨服务查询压力: + * 按主键游标每批 500 行、批间休眠、每轮调用限量(maxBatches),由外部反复触发直至收敛; + * 单行更新走主键条件,不触碰 update_by/update_time 审计字段。

+ * + * @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 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 rows = walletTransactionMapper.selectList( + new LambdaQueryWrapper() + .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 rows) { + Map 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.lambdaUpdate() + .eq(WalletTransactionEntity::getId, row.getId()) + .set(WalletTransactionEntity::getSysRemark, sysRemark)); + updated++; + } + log.info("|{}|系统备注回填批次完成,行数:{},更新数:{}", TAG, rows.size(), updated); + } + + /** + * 收集本批 PAYMENT/REFUND 行的可解析订单号并分批查询硬件订单;查询失败仅记录告警, + * 对应行回退业务类型兜底文案,不中断整体回填。 + */ + private Map fetchOrdersByIds(List rows) { + List 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 orderMap = new HashMap<>(orderIds.size()); + for (List idBatch : ListUtil.partition(orderIds, BATCH_SIZE)) { + R> 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 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; + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java index 7f4adb75..a5576cfc 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java @@ -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 0; if (saved) { createMessageOutbox(entity); @@ -281,6 +287,17 @@ public class WalletTransactionServiceImpl extends ServiceImpl> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(walletTransactionMapper).update(isNull(), captor.capture()); + LambdaUpdateWrapper wrapper = + (LambdaUpdateWrapper) 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; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java index d776aa38..827ea6e1 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImplTest.java @@ -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 饭卡。 *