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 饭卡。
*