Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-09-24 16:23:41 +08:00
commit aee1fb1647
30 files changed
+1755 -16

No files matched your search

+3 -3
View File
@@ -20,7 +20,7 @@ https://www.pjzhhq.cn/h5/#/pages/pay/coffee-pay?order_no={厂商订单号}
**必须登录**: 页面 onLoad 检查登录态,未登录 toast 提示后跳转 `/pages/root/login`(登录后重新扫码进入)。所有支付走登录用户自己的饭卡钱包,无需输卡号。
页面流程:
1. 进入即调拉单接口,展示商品图片/名称/冷热/机器号/金额与支付倒计时(5分钟)。
1. 进入即调拉单接口,展示商品图片/名称/冷热/机器号/金额与支付倒计时(2分钟)。
2. 展示登录用户饭卡余额(钱包类型编码 `1`)。
3. 点击支付 → 确认弹窗 → 调支付接口 → 页内展示成功结果(支付人/支付后余额/取咖啡提示)。
4. 订单已支付(paid=true)展示已支付态;倒计时归零展示超时态,禁用支付。
@@ -44,7 +44,7 @@ GET /api/hard/hard-coffee/order-info?orderNo={order_no}
| priceYuan | string | 价格(元,两位小数),直接展示 |
| machineName | string | 机器号 |
| paid | boolean | 是否已支付,`true` 时禁止再次支付 |
| remainSeconds | long | 支付剩余秒数(下单起5分钟),超时提示重新发起 |
| remainSeconds | long | 支付剩余秒数(下单起2分钟),超时提示重新发起 |
### 3.2 饭卡支付(登录态,扣本人饭卡)
@@ -80,6 +80,6 @@ POST /api/hard/hard-coffee/card-pay { "orderId": ..., "cardNo": ... }
## 4. 注意事项
1. 前端固定 TENANT-ID:1,后端通过机器号自动定位设备租户,多租户无需前端处理。
2. 同一订单支付窗口 5 分钟,过期后在咖啡机重新发起生成新二维码。
2. 同一订单支付窗口 2 分钟,过期后在咖啡机重新发起生成新二维码。
3. 防重复提交由后端并发锁保证,前端支付按钮加 loading 即可。
4. 支付出餐由后端回调厂商完成,前端无需轮询(如需兜底可再调 3.1 查看 `paid`)。
+2 -2
View File
@@ -74,7 +74,7 @@
<circle cx="714" cy="452" r="11" fill="#409EFF"/><text x="714" y="456" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">6</text>
<text x="858" y="456" text-anchor="middle" font-size="11.5" fill="#303133" font-weight="600">本地落单(并发锁 + 幂等)</text>
<text x="858" y="474" text-anchor="middle" font-size="10.5" fill="#606266">机器号定位设备(type=13) → TenantBroker 切设备租户</text>
<text x="858" y="490" text-anchor="middle" font-size="10.5" fill="#606266">建 hard_order(CREATE)+扩展表 · Redis 支付窗口 5min</text>
<text x="858" y="490" text-anchor="middle" font-size="10.5" fill="#606266">建 hard_order(CREATE)+扩展表 · Redis 支付窗口 2min</text>
<text x="858" y="505" text-anchor="middle" font-size="10.5" fill="#909399">生命周期日志 ①GET_ORDER ②CREATE_ORDER</text>
<!-- ⑦ 返回订单信息 -->
@@ -91,7 +91,7 @@
<circle cx="560" cy="612" r="11" fill="#67C23A"/><text x="560" y="616" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">9</text>
<line x1="571" y1="612" x2="849" y2="612" stroke="#67C23A" stroke-width="1.6" marker-end="url(#aGreen)"/>
<text x="710" y="604" text-anchor="middle" font-size="12" fill="#67C23A">POST /hard-coffee/wallet-pay {orderId}(登录态)</text>
<text x="710" y="627" text-anchor="middle" font-size="10" fill="#909399">校验 5 分钟窗口 · 支付并发锁</text>
<text x="710" y="627" text-anchor="middle" font-size="10" fill="#909399">校验 2 分钟窗口 · 支付并发锁</text>
<!-- ⑩ 查钱包 -->
<circle cx="850" cy="662" r="11" fill="#409EFF"/><text x="850" y="666" text-anchor="middle" font-size="11" fill="#fff" font-weight="700">10</text>

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,135 @@
-- 充值/扣款申请「预约执行时间」功能:预约到点的申请由定时任务统一执行(扫描模式,10 分钟一轮)。
-- 幂等迁移脚本:可反复执行。每个字段/索引先查 information_schema,已存在则跳过(执行 SELECT 1 占位),
-- 不存在才执行 DDL;部分应用过的库重跑不会报 Duplicate column / Duplicate key。
-- 配套定时任务注册与菜单权限见 db/20260924_钱包预约执行定时任务.txt。
-- 【可选诊断】充值申请表当前是否已含预约执行相关列。
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet'
AND TABLE_NAME = 'wallet_recharge_apply'
AND COLUMN_NAME IN ('reserve_flag', 'reserve_time', 'exec_status', 'exec_time', 'exec_count',
'next_exec_time', 'last_error', 'claim_time')
ORDER BY ORDINAL_POSITION;
-- 一、wallet_recharge_apply 预约执行列(各自独立判断)。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`wallet_recharge_apply` ADD COLUMN `reserve_flag` char(1) NOT NULL DEFAULT ''0'' COMMENT ''预约执行开关,0关闭,1开启'' AFTER `is_authorization`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_recharge_apply' AND COLUMN_NAME = 'reserve_flag');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`wallet_recharge_apply` ADD COLUMN `reserve_time` datetime DEFAULT NULL COMMENT ''预约执行时间,reserve_flag=1 时必填'' AFTER `reserve_flag`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_recharge_apply' AND COLUMN_NAME = 'reserve_time');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`wallet_recharge_apply` ADD COLUMN `exec_status` char(2) DEFAULT NULL COMMENT ''预约执行状态,10待执行,20执行成功,25执行中,30待重试,40重试耗尽;reserve_flag=0 时为空'' AFTER `reserve_time`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_recharge_apply' AND COLUMN_NAME = 'exec_status');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`wallet_recharge_apply` ADD COLUMN `exec_time` datetime DEFAULT NULL COMMENT ''预约执行实际完成时间'' AFTER `exec_status`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_recharge_apply' AND COLUMN_NAME = 'exec_time');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`wallet_recharge_apply` ADD COLUMN `exec_count` int(11) NOT NULL DEFAULT 0 COMMENT ''预约执行已尝试次数'' AFTER `exec_time`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_recharge_apply' AND COLUMN_NAME = 'exec_count');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`wallet_recharge_apply` ADD COLUMN `next_exec_time` datetime DEFAULT NULL COMMENT ''下次该执行或重试时间,审批通过时初始化为 reserve_time'' AFTER `exec_count`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_recharge_apply' AND COLUMN_NAME = 'next_exec_time');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`wallet_recharge_apply` ADD COLUMN `last_error` varchar(512) DEFAULT NULL COMMENT ''预约执行最近一次失败原因'' AFTER `next_exec_time`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_recharge_apply' AND COLUMN_NAME = 'last_error');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`wallet_recharge_apply` ADD COLUMN `claim_time` datetime DEFAULT NULL COMMENT ''执行抢占时间,用于执行中卡死恢复'' AFTER `last_error`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_recharge_apply' AND COLUMN_NAME = 'claim_time');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 二、扫描索引:缺失才创建。供跨租户扫描「待执行/待重试且到期」的申请。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`wallet_recharge_apply` ADD KEY `idx_wra_reserve_scan` (`exec_status`, `next_exec_time`)',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'wallet_recharge_apply' AND INDEX_NAME = 'idx_wra_reserve_scan');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 三、预约执行对账差异表:只存差异记录,正常数据不产生行。
CREATE TABLE IF NOT EXISTS `pjcloudx_wallet`.`wallet_recharge_exec_check`
(
id bigint NOT NULL COMMENT '主键ID',
apply_id bigint NOT NULL COMMENT '充值/扣款申请ID',
import_id bigint NOT NULL DEFAULT 0 COMMENT '充值导入明细ID,0表示申请单级差异',
user_id bigint DEFAULT NULL COMMENT '涉及用户ID',
diff_type char(2) NOT NULL COMMENT '差异类型,1明细缺流水,2流水重复,3金额不符,4无钱包被跳过,5执行卡死,6重试耗尽',
expected_price int DEFAULT NULL COMMENT '预期金额,单位分',
actual_price int DEFAULT NULL COMMENT '实际流水金额,单位分',
check_time datetime NOT NULL COMMENT '检查时间',
status char(2) NOT NULL DEFAULT '10' COMMENT '处理状态,10待处理,20人工已处理,30已自动恢复',
handle_by varchar(64) DEFAULT NULL COMMENT '处理人',
handle_time datetime DEFAULT NULL COMMENT '处理时间',
handle_remark varchar(512) DEFAULT NULL COMMENT '处理备注',
create_by varchar(64) DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) DEFAULT NULL COMMENT '修改人',
update_time datetime DEFAULT NULL COMMENT '修改时间',
del_flag char(1) DEFAULT '0' COMMENT '删除标记',
tenant_id bigint DEFAULT NULL COMMENT '租户ID',
PRIMARY KEY (id),
UNIQUE KEY uk_wrec_diff (apply_id, import_id, diff_type, del_flag),
KEY idx_wrec_status_check (status, check_time)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci COMMENT = '充值扣款预约执行对账差异表';
-- 【验收】八列、扫描索引、差异表均应存在。
SELECT TABLE_NAME, COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet'
AND ((TABLE_NAME = 'wallet_recharge_apply' AND COLUMN_NAME IN ('reserve_flag', 'reserve_time', 'exec_status',
'exec_time', 'exec_count', 'next_exec_time', 'last_error', 'claim_time'))
OR (TABLE_NAME = 'wallet_recharge_exec_check' AND COLUMN_NAME = 'diff_type'));
SET @ddl = NULL;
@@ -0,0 +1,140 @@
========================================================================
充值/扣款申请「预约执行时间」- 定时任务/菜单权限手工添加清单 (2026-09-24)
========================================================================
说明:以下内容需要管理员在对应环境手工添加( Quartz 任务管理页或直接执行 SQL、
UPMS 管理端「菜单管理」)。SQL 按租户幂等,风格与 db/20260920_饭卡卡面模板菜单权限.txt 一致。
前置条件:
1. 先执行 db/20260924_add_wallet_recharge_apply_reserve_exec.sql(加列 + 扫描索引 + 对账差异表);
2. 部署包含本次改动的 pjcloud-wallet-biz 与 pjcloud-daemon-quartz 服务;
3. 两条 sys_job 使用 job_status='2'(运行中),daemon-quartz 重启时由
PjcloudInitQuartzJob 自动注册进 Quartz;服务不重启可调管理端
POST /sys-job/refresh-jobs(权限 job_sys_job_refresh_job)手动加载。
功能说明:
预约执行到期任务:每 10 分钟扫描「已审批通过 + 预约时间已到 + 未执行完」的充值/扣款申请,
按明细幂等执行(CAS 抢占防并发,失败退避重试,耗尽转终态等人工)。
预约执行对账任务:每 10 分钟(错开 5 分钟)复核近 7 天预约单「申请明细 ↔ 钱包流水」一致性,
差异落 wallet_recharge_exec_check 表,修复后自动恢复,页面人工闭环。
------------------------------------------------------------------------
一、sys_job:钱包预约充值到期执行(job_type=2,反射调 daemon 内 spring bean)
------------------------------------------------------------------------
INSERT INTO `pjcloudx`.`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), 2026092400000000000) FROM `pjcloudx`.`sys_job`) + 1,
'钱包预约充值到期执行', 'WALLET', '1', '2', NULL, 'walletReserveTask', 'executeDueApplies',
NULL, '0 */10 * * * ?', '3', '1', '2',
'0', 'system', NOW(), 'system', NOW(), 1, '每10分钟扫描到期预约充值/扣款申请并幂等执行'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_job`
WHERE `job_name` = '钱包预约充值到期执行' AND `job_group` = 'WALLET' AND `tenant_id` = 1
);
------------------------------------------------------------------------
二、sys_job:钱包预约充值对账(同 bean 第二个方法,错开 5 分钟)
------------------------------------------------------------------------
INSERT INTO `pjcloudx`.`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), 2026092400000000000) FROM `pjcloudx`.`sys_job`) + 2,
'钱包预约充值对账', 'WALLET', '2', '2', NULL, 'walletReserveTask', 'reconcile',
NULL, '5 */10 * * * ?', '3', '1', '2',
'0', 'system', NOW(), 'system', NOW(), 1, '每10分钟复核近7天预约单明细与钱包流水一致性'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_job`
WHERE `job_name` = '钱包预约充值对账' AND `job_group` = 'WALLET' AND `tenant_id` = 1
);
------------------------------------------------------------------------
三、按钮权限:充值/扣款申请「立即执行」(挂在充值申请页面菜单下,人工兜底)
------------------------------------------------------------------------
INSERT INTO `pjcloudx`.`sys_menu`
(`menu_id`, `name`, `permission`, `parent_id`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026092400000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY page_menu.`tenant_id`, page_menu.`menu_id`),
'立即执行', 'wallet_walletRechargeApply_execute', page_menu.`menu_id`,
'1', 60, '0', '0', '2', 'system', NOW(), '0', page_menu.`tenant_id`
FROM `pjcloudx`.`sys_menu` page_menu
WHERE page_menu.`permission` = 'wallet_walletRechargeApply_view' AND page_menu.`menu_type` = '0' AND page_menu.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_menu` existing
WHERE existing.`tenant_id` = page_menu.`tenant_id`
AND existing.`permission` = 'wallet_walletRechargeApply_execute' AND existing.`del_flag` = '0'
);
------------------------------------------------------------------------
四、页面菜单:预约执行对账(与充值/扣款申请同级,复用其父菜单)
------------------------------------------------------------------------
前端页面:src/views/wallet/wallet-recharge-exec-check/index.vue
INSERT INTO `pjcloudx`.`sys_menu`
(`menu_id`, `name`, `permission`, `path`, `component`, `parent_id`, `icon`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026092400000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY sibling.`tenant_id`, sibling.`menu_id`),
'预约执行对账', 'wallet_walletRechargeExecCheck_view', '/wallet/wallet-recharge-exec-check', 'wallet/wallet-recharge-exec-check/index',
sibling.`parent_id`, NULL, '1', 55, '0', '0', '0',
'system', NOW(), 'system', NOW(), '0', sibling.`tenant_id`
FROM `pjcloudx`.`sys_menu` sibling
WHERE sibling.`permission` = 'wallet_walletRechargeApply_view' AND sibling.`menu_type` = '0' AND sibling.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_menu` existing
WHERE existing.`tenant_id` = sibling.`tenant_id`
AND existing.`permission` = 'wallet_walletRechargeExecCheck_view' AND existing.`del_flag` = '0'
);
------------------------------------------------------------------------
五、按钮权限:预约执行对账「标记已处理」(挂在对账页面菜单下)
------------------------------------------------------------------------
INSERT INTO `pjcloudx`.`sys_menu`
(`menu_id`, `name`, `permission`, `parent_id`, `visible`, `sort_order`, `keep_alive`, `embedded`, `menu_type`, `create_by`, `create_time`, `del_flag`, `tenant_id`)
SELECT (SELECT GREATEST(COALESCE(MAX(`menu_id`), 0), 2026092400000000000) FROM `pjcloudx`.`sys_menu`) + ROW_NUMBER() OVER (ORDER BY page_menu.`tenant_id`, page_menu.`menu_id`),
'标记已处理', 'wallet_walletRechargeExecCheck_handle', page_menu.`menu_id`,
'1', 10, '0', '0', '2', 'system', NOW(), '0', page_menu.`tenant_id`
FROM `pjcloudx`.`sys_menu` page_menu
WHERE page_menu.`permission` = 'wallet_walletRechargeExecCheck_view' AND page_menu.`menu_type` = '0' AND page_menu.`del_flag` = '0'
AND NOT EXISTS (
SELECT 1 FROM `pjcloudx`.`sys_menu` existing
WHERE existing.`tenant_id` = page_menu.`tenant_id`
AND existing.`permission` = 'wallet_walletRechargeExecCheck_handle' AND existing.`del_flag` = '0'
);
------------------------------------------------------------------------
六、后端权限字符串与接口对应关系(前端页面取用)
------------------------------------------------------------------------
wallet_walletRechargeApply_execute:
POST /wallet/wallet-recharge-apply/execute 立即执行预约单(待执行/待重试/失败终态均可触发)
wallet_walletRechargeExecCheck_view:
GET /wallet/wallet-recharge-exec-check/page 对账差异分页
wallet_walletRechargeExecCheck_handle:
PUT /wallet/wallet-recharge-exec-check 标记差异已处理(填处理备注)
内部端点(daemon-quartz 定时任务专用,@Inner 鉴权,勿对外):
POST /wallet/wallet-recharge-apply/inner/execute-due-reserve 扫描并执行到期预约单
POST /wallet/wallet-recharge-apply/inner/reconcile 预约单对账复核
------------------------------------------------------------------------
七、回滚说明
------------------------------------------------------------------------
停用任务:将两条 sys_job 的 job_status 改为 '4'(删除)或 '3'(暂停)并重启 daemon-quartz
(或调 POST /sys-job/refresh-jobs);
回滚菜单:按本文件新增的 permission 值删除对应 sys_menu 行;
字段与差异表回滚(确认无进行中的预约单后执行):
ALTER TABLE `pjcloudx_wallet`.`wallet_recharge_apply`
DROP INDEX `idx_wra_reserve_scan`,
DROP COLUMN `reserve_flag`, DROP COLUMN `reserve_time`, DROP COLUMN `exec_status`,
DROP COLUMN `exec_time`, DROP COLUMN `exec_count`, DROP COLUMN `next_exec_time`,
DROP COLUMN `last_error`, DROP COLUMN `claim_time`;
DROP TABLE IF EXISTS `pjcloudx_wallet`.`wallet_recharge_exec_check`;
@@ -1,5 +1,6 @@
package com.bsd.pjcloud.hard.controller.coffee;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.hard.api.dto.coffee.HardCoffeeCardPayDto;
@@ -18,8 +19,10 @@ import org.springframework.web.bind.annotation.*;
/**
* 咖啡机H5匿名支付接口。
* 用户扫描咖啡机二维码打开我方H5支付页,页面依次调用拉单和饭卡支付接口;
* 匿名安全由厂商订单号随机性+5分钟支付窗口+并发锁保证。
* 匿名安全由厂商订单号随机性+2分钟支付窗口+并发锁保证。
* 移动端开启接口加密后POST body为{encryption:...}密文,必须配套AES解密注解,否则字段解析为空
*/
@ApiCryptoAes
@RestController
@RequiredArgsConstructor
@RequestMapping("/hard-coffee")
@@ -87,9 +87,9 @@ public class HardCoffeeServiceImpl implements HardCoffeeService {
private static final String PAY_WINDOW_KEY = "hard:coffee:order:non:pay:";
/**
* 支付窗口时长(秒),与商户扫码订单保持一致
* 支付窗口时长(秒),咖啡机场景用户在场决策快,取2分钟;前端倒计时读后端TTL自动跟随
*/
private static final long PAY_WINDOW_SECONDS = 300;
private static final long PAY_WINDOW_SECONDS = 120;
/**
* 拉单并发锁Key前缀
@@ -153,9 +153,13 @@ public class HardCoffeeServiceImpl implements HardCoffeeService {
if (ObjectUtil.isNotEmpty(exist)) {
return buildOrderInfoVo(exist);
}
// 并发锁防止同一厂商订单号重复落单
Boolean locked = redisTemplate.opsForValue().setIfAbsent(CREATE_LOCK_KEY + orderNo, "1", Duration.ofSeconds(LOCK_SECONDS));
if (!Boolean.TRUE.equals(locked)) {
// 并发锁防止同一厂商订单号重复落单。
// 部分Redis环境下setIfAbsent可能假失败(返回非TRUE但未写入),参照人脸质量租约的成熟做法,
// 只有锁key确实存在时才判定为撞锁,避免用户被永久卡在"处理中"
String createLockKey = CREATE_LOCK_KEY + orderNo;
Boolean createLocked = redisTemplate.opsForValue().setIfAbsent(createLockKey, "1", Duration.ofSeconds(LOCK_SECONDS));
if (!Boolean.TRUE.equals(createLocked) && redisTemplate.opsForValue().get(createLockKey) != null) {
log.warn("|{}|拉单并发锁拦截,orderNo:{},setIfAbsent返回:{}", TAG, orderNo, createLocked);
throw new RuntimeException("订单处理中,请稍后重试");
}
try {
@@ -271,9 +275,13 @@ public class HardCoffeeServiceImpl implements HardCoffeeService {
if (StrUtil.isBlank(orderNoInWindow)) {
throw new RuntimeException("订单已超时,请在咖啡机上重新发起");
}
// 支付并发锁,防止同一订单重复提交
Boolean locked = redisTemplate.opsForValue().setIfAbsent(PAY_LOCK_KEY + orderId, "1", Duration.ofSeconds(LOCK_SECONDS));
if (!Boolean.TRUE.equals(locked)) {
// 支付并发锁,防止同一订单重复提交。
// 部分Redis环境下setIfAbsent可能假失败(返回非TRUE但未写入),参照人脸质量租约的成熟做法,
// 只有锁key确实存在时才判定为撞锁,避免用户被永久卡在"支付处理中"
String payLockKey = PAY_LOCK_KEY + orderId;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(payLockKey, "1", Duration.ofSeconds(LOCK_SECONDS));
if (!Boolean.TRUE.equals(locked) && redisTemplate.opsForValue().get(payLockKey) != null) {
log.warn("|{}|支付并发锁拦截,orderId:{},setIfAbsent返回:{},orderNo:{}", TAG, orderId, locked, orderNoInWindow);
throw new RuntimeException("支付处理中,请勿重复提交");
}
try {
@@ -286,7 +294,7 @@ public class HardCoffeeServiceImpl implements HardCoffeeService {
redisTemplate.delete(redisKey);
return result;
} finally {
redisTemplate.delete(PAY_LOCK_KEY + orderId);
redisTemplate.delete(payLockKey);
}
}
@@ -26,6 +26,12 @@
<artifactId>pjcloud-daemon-quartz-api</artifactId>
<version>5.7.0</version>
</dependency>
<!-- 钱包预约充值/扣款到期执行与对账任务只依赖 wallet Feign 契约。 -->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-wallet-api</artifactId>
<version>5.7.0</version>
</dependency>
<!--注册中心客户端-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
@@ -0,0 +1,67 @@
package com.bsd.pjcloud.daemon.quartz.task;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.daemon.quartz.constants.PjcloudQuartzEnum;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargeApplyService;
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=walletReserveTask),bean 内走 Feign 调 wallet 服务内部端点执行,
* 申请状态 CAS 与明细级幂等由 wallet 侧保证,任务重复触发安全。
*/
@Slf4j
@Component("walletReserveTask")
@RequiredArgsConstructor
public class WalletReserveTask {
private final RemoteWalletRechargeApplyService remoteWalletRechargeApplyService;
/**
* 扫描并执行到期的预约充值/扣款申请,每 10 分钟触发一次。
*/
@SneakyThrows
public String executeDueApplies(String para) {
log.info("钱包预约充值到期执行开始:{},输入参数:{}", LocalDateTime.now(), para);
try {
R<Integer> result = remoteWalletRechargeApplyService.executeDueReserveApplies();
if (result.isOk()) {
log.info("钱包预约充值到期执行完成,本轮受理:{}单", result.getData());
} else {
log.error("钱包预约充值到期执行失败,code:{},msg:{}", result.getCode(), result.getMsg());
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
}
} catch (Exception e) {
log.error("钱包预约充值到期执行错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
}
return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
}
/**
* 复核近期预约申请明细与钱包流水一致性,每 10 分钟触发一次,与执行任务错开 5 分钟。
*/
@SneakyThrows
public String reconcile(String para) {
log.info("钱包预约充值对账开始:{},输入参数:{}", LocalDateTime.now(), para);
try {
R<Integer> result = remoteWalletRechargeApplyService.reconcileReserveApplies();
if (result.isOk()) {
log.info("钱包预约充值对账完成,本轮复核:{}单", result.getData());
} else {
log.error("钱包预约充值对账失败,code:{},msg:{}", result.getCode(), result.getMsg());
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
}
} catch (Exception e) {
log.error("钱包预约充值对账错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
}
return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
}
}
@@ -0,0 +1,56 @@
package com.bsd.pjcloud.daemon.quartz.task;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.daemon.quartz.constants.PjcloudQuartzEnum;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargeApplyService;
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.Mockito.when;
/**
* 钱包预约充值任务的结果映射测试:Feign 成败正确转换为 sys_job 日志状态。
*/
@ExtendWith(MockitoExtension.class)
class WalletReserveTaskTest {
/**
* 钱包充值申请 Feign 契约 Mock。
*/
@Mock
private RemoteWalletRechargeApplyService remoteWalletRechargeApplyService;
@InjectMocks
private WalletReserveTask walletReserveTask;
/**
* Feign 返回成功时任务日志记成功。
*/
@Test
void executeDueAppliesShouldReturnSuccessWhenFeignOk() {
when(remoteWalletRechargeApplyService.executeDueReserveApplies()).thenReturn(R.ok(3));
assertEquals(PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType(), walletReserveTask.executeDueApplies(null));
}
/**
* Feign 返回失败码时任务日志记失败,便于后台告警。
*/
@Test
void executeDueAppliesShouldReturnFailWhenFeignRejected() {
when(remoteWalletRechargeApplyService.executeDueReserveApplies()).thenReturn(R.failed("钱包服务不可用"));
assertEquals(PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType(), walletReserveTask.executeDueApplies(null));
}
/**
* Feign 抛异常时任务日志记失败,异常不外抛。
*/
@Test
void reconcileShouldReturnFailWhenFeignThrows() {
when(remoteWalletRechargeApplyService.reconcileReserveApplies()).thenThrow(new RuntimeException("timeout"));
assertEquals(PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType(), walletReserveTask.reconcile(null));
}
}
@@ -0,0 +1,47 @@
package com.bsd.pjcloud.wallet.api.constant;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 充值/扣款申请预约执行对账差异类型。
*/
@Getter
@AllArgsConstructor
public enum WalletRechargeCheckDiffTypeEnum {
/**
* 申请明细在钱包流水中找不到对应记录。
*/
MISSING_TX("1", "明细缺流水"),
/**
* 同一业务号在钱包流水中出现多条。
*/
DUPLICATE_TX("2", "流水重复"),
/**
* 流水金额与申请明细预期金额不一致。
*/
AMOUNT_MISMATCH("3", "金额不符"),
/**
* 执行时用户无对应钱包,明细被跳过未入账。
*/
NO_WALLET("4", "无钱包被跳过"),
/**
* 申请长期停留在待执行/执行中状态。
*/
STUCK("5", "执行卡死"),
/**
* 自动重试次数耗尽转终态,等待人工处理。
*/
RETRY_EXHAUSTED("6", "重试耗尽"),
;
private final String code;
private final String desc;
}
@@ -0,0 +1,32 @@
package com.bsd.pjcloud.wallet.api.constant;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 充值/扣款申请预约执行对账差异处理状态。
*/
@Getter
@AllArgsConstructor
public enum WalletRechargeCheckStatusEnum {
/**
* 差异待处理。
*/
PENDING("10", "待处理"),
/**
* 人工确认处理完成。
*/
HANDLED("20", "人工已处理"),
/**
* 复核时差异已不存在,系统自动恢复。
*/
AUTO_RECOVERED("30", "已自动恢复"),
;
private final String code;
private final String desc;
}
@@ -0,0 +1,51 @@
package com.bsd.pjcloud.wallet.api.constant;
import cn.hutool.core.util.ObjectUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 充值/扣款申请预约执行状态。
*/
@Getter
@AllArgsConstructor
public enum WalletReserveExecStatusEnum {
/**
* 审批已通过,等待预约时间到达。
*/
PENDING("10", "待执行"),
/**
* 全部明细执行完成。
*/
SUCCESS("20", "执行成功"),
/**
* 已被某个执行器抢占,正在执行。
*/
EXECUTING("25", "执行中"),
/**
* 执行失败,等待退避重试。
*/
RETRY_WAIT("30", "待重试"),
/**
* 重试次数耗尽,等待人工处理。
*/
FAILED("40", "重试耗尽"),
;
private final String code;
private final String desc;
public static WalletReserveExecStatusEnum valueOfByType(String code) {
return Arrays.stream(WalletReserveExecStatusEnum.values())
.filter(v -> ObjectUtil.equal(v.getCode(), code))
.findFirst().orElse(null);
}
}
@@ -0,0 +1,20 @@
package com.bsd.pjcloud.wallet.api.dto.recharge;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeExecCheckEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 充值/扣款申请预约执行对账差异查询条件。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class WalletRechargeExecCheckDto extends WalletRechargeExecCheckEntity {
/**
* 检查时间搜索范围。
*/
private LocalDateTime[] checkTimeList;
}
@@ -101,6 +101,54 @@ public class WalletRechargeApplyEntity extends Model<WalletRechargeApplyEntity>
@Schema(description = "是否授权")
private String isAuthorization;
/**
* 预约执行开关,0关闭,1开启
*/
@Schema(description = "预约执行开关,0关闭,1开启")
private String reserveFlag;
/**
* 预约执行时间,reserve_flag=1 时必填
*/
@Schema(description = "预约执行时间")
private LocalDateTime reserveTime;
/**
* 预约执行状态,10待执行,20执行成功,25执行中,30待重试,40重试耗尽;非预约单为空
*/
@Schema(description = "预约执行状态")
private String execStatus;
/**
* 预约执行实际完成时间
*/
@Schema(description = "预约执行实际完成时间")
private LocalDateTime execTime;
/**
* 预约执行已尝试次数
*/
@Schema(description = "预约执行已尝试次数")
private Integer execCount;
/**
* 下次该执行或重试时间,审批通过时初始化为预约时间
*/
@Schema(description = "下次该执行或重试时间")
private LocalDateTime nextExecTime;
/**
* 预约执行最近一次失败原因
*/
@Schema(description = "预约执行最近一次失败原因")
private String lastError;
/**
* 执行抢占时间,用于执行中卡死恢复
*/
@Schema(description = "执行抢占时间")
private LocalDateTime claimTime;
/**
* 备注
*/
@@ -0,0 +1,144 @@
package com.bsd.pjcloud.wallet.api.entity.recharge;
import cn.hutool.core.date.DatePattern;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import com.bsd.pjcloud.common.core.util.TenantTable;
import java.time.LocalDateTime;
/**
* 充值/扣款申请预约执行对账差异
*
* @author dzc
* @date 2026-09-24
*/
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@TenantTable
@TableName("wallet_recharge_exec_check")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "充值/扣款申请预约执行对账差异")
public class WalletRechargeExecCheckEntity extends Model<WalletRechargeExecCheckEntity> {
/**
* 主键ID
*/
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键ID")
private Long id;
/**
* 充值/扣款申请ID
*/
@Schema(description = "充值/扣款申请ID")
private Long applyId;
/**
* 充值导入明细ID,0表示申请单级差异
*/
@Schema(description = "充值导入明细ID,0表示申请单级差异")
private Long importId;
/**
* 涉及用户ID
*/
@Schema(description = "涉及用户ID")
private Long userId;
/**
* 差异类型,1明细缺流水,2流水重复,3金额不符,4无钱包被跳过,5执行卡死,6重试耗尽
*/
@Schema(description = "差异类型")
private String diffType;
/**
* 预期金额,单位分
*/
@Schema(description = "预期金额,单位分")
private Integer expectedPrice;
/**
* 实际流水金额,单位分
*/
@Schema(description = "实际流水金额,单位分")
private Integer actualPrice;
/**
* 检查时间
*/
@Schema(description = "检查时间")
private LocalDateTime checkTime;
/**
* 处理状态,10待处理,20人工已处理,30已自动恢复
*/
@Schema(description = "处理状态")
private String status;
/**
* 处理人
*/
@Schema(description = "处理人")
private String handleBy;
/**
* 处理时间
*/
@JsonFormat(pattern = DatePattern.NORM_DATETIME_PATTERN)
@Schema(description = "处理时间")
private LocalDateTime handleTime;
/**
* 处理备注
*/
@Schema(description = "处理备注")
private String handleRemark;
/**
* 创建人
*/
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建人")
private String createBy;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createTime;
/**
* 修改人
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改人")
private String updateBy;
/**
* 修改时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改时间")
private LocalDateTime updateTime;
/**
* 删除标记
*/
@TableLogic
@TableField(fill = FieldFill.INSERT)
@Schema(description = "删除标记")
private String delFlag;
/**
* 租户ID
*/
@Schema(description = "租户ID")
private Long tenantId;
}
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.wallet.api.feign;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
/**
* 充值/扣款申请预约执行的跨服务契约,供 daemon-quartz 定时任务触发。
*/
@FeignClient(contextId = "remoteWalletRechargeApplyService", value = ServiceNameConstants.WALLET_SERVICE)
public interface RemoteWalletRechargeApplyService {
/**
* 扫描并执行到期的预约充值/扣款申请。执行器内部跨租户扫描并按申请租户恢复上下文,
* 重复触发由申请执行状态 CAS 与明细级幂等共同防护。
*
* @return 本轮实际执行(含重试)的申请数量
*/
@NoToken
@PostMapping("/wallet-recharge-apply/inner/execute-due-reserve")
R<Integer> executeDueReserveApplies();
/**
* 复核近几天预约申请的明细与钱包流水一致性,差异落对账差异表。
*
* @return 本轮复核的申请数量
*/
@NoToken
@PostMapping("/wallet-recharge-apply/inner/reconcile")
R<Integer> reconcileReserveApplies();
}
@@ -0,0 +1,23 @@
package com.bsd.pjcloud.wallet.api.vo.recharge;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeExecCheckEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 充值/扣款申请预约执行对账差异展示对象。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class WalletRechargeExecCheckVo extends WalletRechargeExecCheckEntity {
/**
* 申请充值类型,对应 wallet_biz_recharge 字典。
*/
private String bizType;
/**
* 饭卡类型名称。
*/
private String walletTypeName;
}
@@ -24,6 +24,7 @@ import com.bsd.pjcloud.wallet.api.vo.recharge.excel.WalletRechargeByApplyExcelVo
import com.bsd.pjcloud.wallet.service.WalletRechargeApplyService;
import com.bsd.pjcloud.wallet.service.WalletRechargeApplyImportService;
import com.bsd.pjcloud.wallet.service.WalletRechargeApplyComposeService;
import com.bsd.pjcloud.wallet.service.WalletReserveExecService;
import org.springframework.security.access.prepost.PreAuthorize;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -48,6 +49,43 @@ public class WalletRechargeApplyController {
/** 充值/扣款 Excel 导入服务,独立承载默认用户和学生用户模板校验及落库。 */
private final WalletRechargeApplyImportService walletRechargeApplyImportService;
private final WalletRechargeApplyComposeService walletTransactionComposeService;
/** 预约执行器,定时任务触发与手动兜底共用。 */
private final WalletReserveExecService walletReserveExecService;
/* ************************************* reserve exec start ************************************* */
/**
* 定时任务入口:扫描并执行到期的预约充值/扣款申请(daemon-quartz Feign 调用)。
*/
@Inner
@PostMapping("/inner/execute-due-reserve")
public R<Integer> executeDueReserveApplies() {
return R.ok(walletReserveExecService.executeDueReserveApplies());
}
/**
* 定时任务入口:复核近期预约申请明细与钱包流水一致性(daemon-quartz Feign 调用)。
*/
@Inner
@PostMapping("/inner/reconcile")
public R<Integer> reconcileReserveApplies() {
return R.ok(walletReserveExecService.reconcileReserveApplies());
}
/**
* 手动立即执行预约单:待执行/待重试/重试耗尽均可触发,不受预约时间限制。
*/
@Operation(summary = "手动立即执行预约单", description = "手动立即执行预约单")
@SysLog("手动立即执行预约单")
@PostMapping("/execute")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeApply_execute')")
@Idempotent(expireTime = 5, info = "正在执行预约单,请勿重复操作")
public R executeReserveApply(@RequestParam Long id) {
boolean success = walletReserveExecService.executeReserveApply(id, true);
return success ? R.ok(true) : R.failed("预约单执行失败,请查看执行状态或稍后重试");
}
/* ************************************* reserve exec end ************************************* */
/* ************************************* start callback start ************************************* */
@Inner(value = false)
@@ -0,0 +1,56 @@
package com.bsd.pjcloud.wallet.controller.recharge;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeExecCheckDto;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeExecCheckVo;
import com.bsd.pjcloud.wallet.service.WalletRechargeExecCheckService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 充值/扣款申请预约执行对账差异管理。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/wallet-recharge-exec-check")
@Tag(description = "wallet-recharge-exec-check", name = "充值扣款预约执行对账差异管理")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class WalletRechargeExecCheckController {
private final WalletRechargeExecCheckService walletRechargeExecCheckService;
/**
* 分页查询对账差异。
*/
@Operation(summary = "分页查询", description = "分页查询")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeExecCheck_view')")
public R getPage(@ParameterObject Page page, @ParameterObject WalletRechargeExecCheckDto dto) {
return R.ok(walletRechargeExecCheckService.pageList(page, dto));
}
/**
* 人工标记差异已处理。
*/
@Operation(summary = "标记已处理", description = "标记已处理")
@SysLog("预约执行对账差异标记已处理")
@PutMapping("/handle")
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeExecCheck_handle')")
public R handle(@RequestParam Long id, @RequestBody(required = false) WalletRechargeExecCheckDto dto) {
String handleRemark = dto == null ? null : dto.getHandleRemark();
return R.ok(walletRechargeExecCheckService.handle(id, handleRemark));
}
}
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.wallet.mapper.recharge;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.BeanUtils;
@@ -17,7 +18,10 @@ import com.github.yulichang.wrapper.MPJLambdaWrapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
@@ -51,6 +55,49 @@ public interface WalletRechargeApplyMapper extends PjcloudBaseMapper<WalletRecha
*/
List<WalletRechargeApplyVo> listTotalPriceByApplyIds(@Param("applyIds") List<Long> applyIds);
/**
* 跨租户复活执行中卡死的预约单:抢占时间超时的执行中状态回退为待重试。
*
* @param before 执行抢占时间截止值,早于该值视为卡死
* @return 实际复活的申请数量
*/
@InterceptorIgnore(tenantLine = "true")
@Update("UPDATE wallet_recharge_apply SET exec_status = '30', next_exec_time = NOW(), update_time = NOW() "
+ "WHERE exec_status = '25' AND claim_time < #{before} AND del_flag = '0'")
int reviveStuckReserve(@Param("before") LocalDateTime before);
/**
* 跨租户扫描到期待执行的预约单:已审批通过、开启预约、待执行或待重试且到达执行时间。
*
* @param limit 单轮最大扫描数量
* @return 到期待执行的申请列表
*/
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM wallet_recharge_apply "
+ "WHERE del_flag = '0' AND process_status = '2' AND reserve_flag = '1' "
+ "AND exec_status IN ('10', '30') AND next_exec_time <= NOW() "
+ "ORDER BY next_exec_time ASC, id ASC LIMIT #{limit}")
List<WalletRechargeApplyEntity> listDueReserveApplies(@Param("limit") int limit);
/**
* 跨租户筛选待对账的预约单:近期已执行/重试耗尽,或长期停留在未完成状态(卡死检测)。
*
* @param checkBefore 已执行单的复核时间下限(近 N 天)
* @param stuckBefore 未完成单的卡死判定时间上限
* @param limit 单轮最大复核数量
* @return 待对账的申请列表
*/
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM wallet_recharge_apply "
+ "WHERE del_flag = '0' AND process_status = '2' AND reserve_flag = '1' "
+ "AND ((exec_status = '20' AND exec_time >= #{checkBefore}) "
+ "OR (exec_status = '40' AND update_time >= #{checkBefore}) "
+ "OR (exec_status IN ('10', '25', '30') AND next_exec_time < #{stuckBefore})) "
+ "ORDER BY id ASC LIMIT #{limit}")
List<WalletRechargeApplyEntity> listReserveReconcileCandidates(@Param("checkBefore") LocalDateTime checkBefore,
@Param("stuckBefore") LocalDateTime stuckBefore,
@Param("limit") int limit);
default Page<WalletRechargeApplyVo> scopePageList(Page page, WalletRechargeApplyDto dto) {
LambdaQueryWrapper<WalletRechargeApplyEntity> wrapper = new LambdaQueryWrapper<WalletRechargeApplyEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getBizType()), WalletRechargeApplyEntity::getBizType, dto.getBizType())
@@ -0,0 +1,13 @@
package com.bsd.pjcloud.wallet.mapper.recharge;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeExecCheckEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 充值/扣款申请预约执行对账差异数据访问入口。
*/
@Mapper
public interface WalletRechargeExecCheckMapper extends PjcloudBaseMapper<WalletRechargeExecCheckEntity> {
}
@@ -0,0 +1,31 @@
package com.bsd.pjcloud.wallet.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeExecCheckDto;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeExecCheckEntity;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeExecCheckVo;
/**
* 充值/扣款申请预约执行对账差异服务。
*/
public interface WalletRechargeExecCheckService extends IService<WalletRechargeExecCheckEntity> {
/**
* 分页查询对账差异,并补齐申请的充值类型与饭卡类型名称。
*
* @param page 分页参数
* @param dto 差异类型、处理状态与检查时间筛选条件
* @return 已装配申请信息的差异分页
*/
Page<WalletRechargeExecCheckVo> pageList(Page page, WalletRechargeExecCheckDto dto);
/**
* 人工标记差异已处理。
*
* @param id 差异主键
* @param handleRemark 处理备注
* @return 仅待处理状态更新成功时返回 {@code true}
*/
Boolean handle(Long id, String handleRemark);
}
@@ -0,0 +1,36 @@
package com.bsd.pjcloud.wallet.service;
/**
* 充值/扣款申请预约执行器与对账复核服务。
*
* <p>预约单的资金变动只允许从本执行器发生:审批回调只登记待执行状态,
* 到期由定时任务逐单 CAS 抢占后按明细幂等执行,配合对账复核保证资金一致性。</p>
*/
public interface WalletReserveExecService {
/**
* 扫描并执行到期的预约充值/扣款申请。
*
* <p>跨租户扫描「已审批通过 + 预约时间已到 + 待执行/待重试」的申请,
* 先复活执行中卡死的申请,再按申请租户恢复上下文逐单执行。</p>
*
* @return 本轮实际受理(抢占成功)的申请数量
*/
int executeDueReserveApplies();
/**
* 执行单笔预约申请;自动扫描与手动兜底共用同一抢占与幂等逻辑。
*
* @param id 申请主键
* @param manual true 表示手动立即执行:忽略预约时间未到限制,并允许从重试耗尽终态挽回
* @return 抢占成功且执行完成的申请返回 {@code true}
*/
boolean executeReserveApply(Long id, boolean manual);
/**
* 复核近期预约申请的明细与钱包流水一致性,差异落对账差异表并自动恢复已修复差异。
*
* @return 本轮复核的申请数量
*/
int reconcileReserveApplies();
}
@@ -154,6 +154,19 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
*/
WalletTransactionEntity addWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType);
/**
* 锁定目标钱包并按稳定业务号幂等增加余额,同时记录财务统计标签和流水备注。
*
* @param walletId 钱包 ID
* @param price 增加金额,单位分且必须与首次请求一致
* @param bizId 稳定业务号,与业务类型共同确定幂等范围
* @param bizType 钱包业务类型
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @return 首次创建或此前已经存在的同一笔钱包流水
*/
WalletTransactionEntity addWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark);
/**
* 增加钱包余额并记录财务统计标签。
*
@@ -221,6 +234,16 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
*/
Boolean resetWalletBalance(Long walletId, String bizId, String tags);
/**
* 锁定目标钱包并按稳定业务号幂等重置余额,重复请求直接返回成功。
*
* @param walletId 钱包 id
* @param bizId 业务关联 id,与重置业务类型共同确定幂等范围
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @return 重置成功或此前已经重置过返回 {@code true}
*/
Boolean resetWalletBalanceIdempotent(Long walletId, String bizId, String tags);
void exportByTemplate(HttpServletResponse response, WalletTransactionDto dto, String fileName, List<WalletTransactionVo> list);
/**
@@ -718,6 +718,24 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
@Override
@Transactional(rollbackFor = Exception.class)
public WalletTransactionEntity addWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType) {
return getSelf().addWalletBalanceIdempotent(walletId, price, bizId, bizType, null, null);
}
/**
* 锁定钱包后按钱包、业务号和业务类型查询已有流水,解决远程调用成功但调用方未落状态时的重复入账;
* 新建流水时同步保存财务标签和流水备注。
*
* @param walletId 目标钱包 ID
* @param price 增加金额,单位分
* @param bizId 稳定业务号
* @param bizType 钱包业务类型
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @return 首次创建或已经存在且金额一致的流水
*/
@Override
@Transactional(rollbackFor = Exception.class)
public WalletTransactionEntity addWalletBalanceIdempotent(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark) {
WalletManageEntity wallet = walletManageMapper.selectByIdForUpdate(walletId);
if (ObjectUtil.isEmpty(wallet)) {
throw new I18nBizException(ErrorCodes.COMMON_WALLET_NOT_FOUND);
@@ -736,6 +754,9 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
}
WalletTransactionEntity transaction = buildWalletTransaction(wallet, walletId, price,
wallet.getBalance() + price, bizId, bizType);
transaction.setTags(tags);
String normalizedRemark = remark == null ? null : remark.strip();
transaction.setRemark(ObjectUtil.isEmpty(normalizedRemark) ? null : normalizedRemark);
save(transaction);
return transaction;
}
@@ -929,6 +950,48 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
return true;
}
/**
* 锁定钱包行后按业务号查重,再重置余额;预约执行等可重试场景重复调用不会产生第二笔重置流水。
*
* @param walletId 钱包 id
* @param bizId 业务关联 id,与重置业务类型共同确定幂等范围
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @return 重置成功或此前已经重置过返回 {@code true}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean resetWalletBalanceIdempotent(Long walletId, String bizId, String tags) {
WalletManageEntity wallet = walletManageMapper.selectByIdForUpdate(walletId);
if (ObjectUtil.isEmpty(wallet)) {
log.error("|{}|幂等重置余额,用户钱包id({})不存在.", TAG, walletId);
throw new I18nBizException(ErrorCodes.COMMON_WALLET_NOT_FOUND);
}
WalletTransactionEntity existing = findBizTransaction(walletId, bizId, WalletBizEnum.RESET);
if (existing != null) {
return true;
}
if (ObjectUtil.equals(wallet.getBalance(), 0)) {
return true;
}
int updateCounts = walletManageMapper.resetWhenRecharge(wallet.getId());
if (updateCounts == 0) {
log.error("|{}|幂等重置余额失败walletId:{}", TAG, walletId);
throw new I18nBizException(ErrorCodes.WALLET_RESET_BALANCE_FAIL);
}
WalletTransactionEntity insetObj = new WalletTransactionEntity();
insetObj.setUserId(wallet.getUserId());
insetObj.setDeptId(wallet.getDeptId());
insetObj.setWalletId(walletId);
insetObj.setPrice(wallet.getBalance() * -1);
insetObj.setBalance(0);
insetObj.setBizId(String.valueOf(bizId));
insetObj.setBizType(WalletBizEnum.RESET.getCode());
insetObj.setTitle(WalletBizEnum.RESET.getDesc());
insetObj.setTags(tags);
save(insetObj);
return true;
}
private WalletTransactionServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
@@ -33,6 +33,7 @@ import com.bsd.pjcloud.flow.task.dto.TaskParamDto;
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletBizRechargeEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletRechargeInvoiceStatusEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletReserveExecStatusEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.dto.WalletManageDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeApplyDto;
@@ -147,6 +148,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
PjcloudUser loginUser = SecurityUtils.getUser();
dto.setDeptId(loginUser.getDeptId());
dto.setUserId(loginUser.getId());
validateReserve(dto);
baseMapper.insert(dto);
}
@@ -160,12 +162,36 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
if (ObjectUtil.equals(dto.getIsAuthorization(), YesNoEnum.NO.getCode())) {
throw new RuntimeException("请同意授权协议");
}
if (ObjectUtil.isNotEmpty(detail.getProcessId())) {
// 已提交审批的申请不允许再修改预约执行设置(正常编辑入口在提交前,这里兜底)
String newReserveFlag = ObjectUtil.isEmpty(dto.getReserveFlag()) ? YesNoEnum.NO.getCode() : dto.getReserveFlag();
if (!ObjectUtil.equals(detail.getReserveFlag(), newReserveFlag)
|| !ObjectUtil.equals(detail.getReserveTime(), dto.getReserveTime())) {
throw new RuntimeException("申请已提交审批,不能修改预约执行设置");
}
}
validateReserve(dto);
WalletRechargeApplyEntity updateObj = BeanUtils.toBean(dto, WalletRechargeApplyEntity.class);
baseMapper.updateById(updateObj);
getSelf().updateBeforePrice(baseMapper.selectById(dto.getId()));
return true;
}
/**
* 预约执行参数校验:开启预约时执行时间必填且必须晚于当前时间。
*/
private void validateReserve(WalletRechargeApplyEntity dto) {
if (!ObjectUtil.equals(dto.getReserveFlag(), YesNoEnum.YES.getCode())) {
return;
}
if (ObjectUtil.isEmpty(dto.getReserveTime())) {
throw new RuntimeException("开启预约执行后,预约执行时间不能为空");
}
if (!dto.getReserveTime().isAfter(LocalDateTime.now())) {
throw new RuntimeException("预约执行时间必须晚于当前时间");
}
}
/**
* 通过id删除充值/扣款申请
@@ -337,7 +363,23 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
try {
Boolean isConsent = flowUtil.isConsent(dto);
String processStatus = isConsent ? TaskCheckStatusEnum.RESULT.getCode() : TaskCheckStatusEnum.REJECT.getCode();
baseMapper.updateById(WalletRechargeApplyEntity.builder().id(detail.getId()).processTime(LocalDateTime.now()).processStatus(processStatus).build());
// 预约单:审批通过后不立即动钱,只登记待执行状态,到期由定时任务统一执行;
// 预约时间缺失时按非预约处理立即执行,避免单据卡死
boolean reserved = Boolean.TRUE.equals(isConsent)
&& ObjectUtil.equals(detail.getReserveFlag(), YesNoEnum.YES.getCode())
&& ObjectUtil.isNotEmpty(detail.getReserveTime());
WalletRechargeApplyEntity.WalletRechargeApplyEntityBuilder statusUpdate = WalletRechargeApplyEntity.builder()
.id(detail.getId()).processTime(LocalDateTime.now()).processStatus(processStatus);
if (reserved) {
statusUpdate.execStatus(WalletReserveExecStatusEnum.PENDING.getCode())
.nextExecTime(detail.getReserveTime());
}
baseMapper.updateById(statusUpdate.build());
if (reserved) {
log.info("|{}|预约单审批通过,登记待执行,applyId:{},reserveTime:{}", TAG, detail.getId(), detail.getReserveTime());
return;
}
if (ObjectUtil.equals(detail.getBizType(), WalletBizRechargeEnum.RESET.getCode())) {
getSelf().applyEditCallback(detail.getId(), isConsent);
@@ -0,0 +1,111 @@
package com.bsd.pjcloud.wallet.service.impl.recharge;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeExecCheckDto;
import com.bsd.pjcloud.wallet.api.constant.WalletRechargeCheckStatusEnum;
import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeExecCheckEntity;
import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeExecCheckVo;
import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeExecCheckMapper;
import com.bsd.pjcloud.wallet.service.WalletRechargeApplyService;
import com.bsd.pjcloud.wallet.service.WalletRechargeExecCheckService;
import com.bsd.pjcloud.wallet.service.WalletTypeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 充值/扣款申请预约执行对账差异服务实现。
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class WalletRechargeExecCheckServiceImpl extends ServiceImpl<WalletRechargeExecCheckMapper, WalletRechargeExecCheckEntity> implements WalletRechargeExecCheckService {
private final WalletRechargeApplyService walletRechargeApplyService;
private final WalletTypeService walletTypeService;
/**
* 分页查询对账差异并按当前页申请批量补齐充值类型与饭卡类型名称。
*/
@Override
public Page<WalletRechargeExecCheckVo> pageList(Page page, WalletRechargeExecCheckDto dto) {
LambdaQueryWrapper<WalletRechargeExecCheckEntity> wrapper = new LambdaQueryWrapper<WalletRechargeExecCheckEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getApplyId()), WalletRechargeExecCheckEntity::getApplyId, dto.getApplyId())
.eq(ObjectUtil.isNotEmpty(dto.getUserId()), WalletRechargeExecCheckEntity::getUserId, dto.getUserId())
.eq(ObjectUtil.isNotEmpty(dto.getDiffType()), WalletRechargeExecCheckEntity::getDiffType, dto.getDiffType())
.eq(ObjectUtil.isNotEmpty(dto.getStatus()), WalletRechargeExecCheckEntity::getStatus, dto.getStatus())
.between(ObjectUtil.isNotEmpty(dto.getCheckTimeList()),
WalletRechargeExecCheckEntity::getCheckTime,
ObjectUtil.isNotEmpty(dto.getCheckTimeList()) ? dto.getCheckTimeList()[0] : null,
ObjectUtil.isNotEmpty(dto.getCheckTimeList()) ? dto.getCheckTimeList()[1] : null)
.orderByDesc(WalletRechargeExecCheckEntity::getCheckTime)
.orderByDesc(WalletRechargeExecCheckEntity::getId);
Page<WalletRechargeExecCheckEntity> list = page(page, wrapper);
Page<WalletRechargeExecCheckVo> result = new Page<>(list.getCurrent(), list.getSize(), list.getTotal());
result.setRecords(BeanUtils.toBean(list.getRecords(), WalletRechargeExecCheckVo.class));
fillApplyInfo(result.getRecords());
return result;
}
/**
* 人工标记待处理差异为已处理,重复处理时保持幂等返回失败。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean handle(Long id, String handleRemark) {
WalletRechargeExecCheckEntity detail = getById(id);
if (ObjectUtil.isEmpty(detail)) {
throw new RuntimeException("对账差异记录不存在");
}
if (!ObjectUtil.equals(detail.getStatus(), WalletRechargeCheckStatusEnum.PENDING.getCode())) {
throw new RuntimeException("仅待处理状态的差异可标记已处理");
}
WalletRechargeExecCheckEntity updateObj = new WalletRechargeExecCheckEntity();
updateObj.setId(id);
updateObj.setStatus(WalletRechargeCheckStatusEnum.HANDLED.getCode());
updateObj.setHandleTime(LocalDateTime.now());
updateObj.setHandleRemark(handleRemark);
return updateById(updateObj);
}
/**
* 按当前页差异批量补齐申请的充值类型与饭卡类型名称。
*/
private void fillApplyInfo(List<WalletRechargeExecCheckVo> records) {
if (CollectionUtils.isEmpty(records)) {
return;
}
List<Long> applyIds = records.stream().map(WalletRechargeExecCheckVo::getApplyId).distinct().toList();
Map<Long, WalletRechargeApplyEntity> applyMap = walletRechargeApplyService.listByIds(applyIds).stream()
.collect(Collectors.toMap(WalletRechargeApplyEntity::getId, Function.identity(), (a, b) -> a));
List<Long> walletTypeIds = applyMap.values().stream()
.map(WalletRechargeApplyEntity::getWalletTypeId)
.filter(ObjectUtil::isNotEmpty)
.distinct().toList();
Map<Long, String> walletTypeNameMap = walletTypeIds.isEmpty() ? Map.of()
: walletTypeService.listByIds(walletTypeIds).stream()
.collect(Collectors.toMap(WalletTypeEntity::getId, WalletTypeEntity::getName, (a, b) -> a));
for (WalletRechargeExecCheckVo record : records) {
WalletRechargeApplyEntity apply = applyMap.get(record.getApplyId());
if (ObjectUtil.isEmpty(apply)) {
continue;
}
record.setBizType(apply.getBizType());
record.setWalletTypeName(walletTypeNameMap.get(apply.getWalletTypeId()));
}
}
}
@@ -0,0 +1,427 @@
package com.bsd.pjcloud.wallet.service.impl.recharge;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletBizRechargeEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletRechargeCheckDiffTypeEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletRechargeCheckStatusEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletRechargeInvoiceStatusEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletReserveExecStatusEnum;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeExecCheckEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeImportEntity;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeInvoiceEntity;
import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeApplyMapper;
import com.bsd.pjcloud.wallet.service.WalletManageService;
import com.bsd.pjcloud.wallet.service.WalletRechargeApplyService;
import com.bsd.pjcloud.wallet.service.WalletRechargeExecCheckService;
import com.bsd.pjcloud.wallet.service.WalletRechargeImportService;
import com.bsd.pjcloud.wallet.service.WalletRechargeInvoiceService;
import com.bsd.pjcloud.wallet.service.WalletReserveExecService;
import com.bsd.pjcloud.wallet.service.WalletThirdZyService;
import com.bsd.pjcloud.wallet.service.WalletTransactionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 充值/扣款申请预约执行器与对账复核实现。
*
* <p>资金安全设计:预约单的余额变动只从本执行器发生(审批回调只登记状态);
* 执行前按 exec_status 条件更新 CAS 抢占,多执行器并发时同一单只有一个赢家;
* 每条明细独立事务调用幂等动钱方法(钱包行锁 + 业务号查重),重试、断点续跑、
* 卡死复活重跑都不会重复入账;无钱包用户不再静默跳过,而是落对账差异等待人工处理;
* 执行成功后立即自检,另有低频对账任务全量复核。</p>
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class WalletReserveExecServiceImpl implements WalletReserveExecService {
private static final String TAG = "预约执行器";
/**
* 单轮最多扫描的到期申请数量,控制单轮执行时长与钱包服务压力。
*/
private static final int SCAN_LIMIT = 50;
/**
* 执行中抢占时间超过该分钟数视为执行器卡死,扫描时复活为待重试。
*/
private static final int CLAIM_STUCK_MINUTES = 10;
/**
* 待执行/待重试状态超过该分钟数仍未完成,对账时按执行卡死落差异。
*/
private static final int STUCK_ALERT_MINUTES = 30;
/**
* 对账复核窗口天数,只复核近期执行过或重试耗尽的预约单。
*/
private static final int RECONCILE_DAYS = 7;
/**
* 自动重试次数上限,超过后转终态等待人工处理。
*/
private static final int MAX_EXEC_COUNT = 10;
/**
* 预约执行失败退避分钟数,按尝试次数递增,对齐 10 分钟扫描粒度。
*/
private static final int[] RETRY_BACKOFF_MINUTES = {10, 20, 30};
private final WalletRechargeApplyMapper applyMapper;
private final WalletRechargeApplyService applyService;
private final WalletRechargeImportService rechargeImportService;
private final WalletManageService walletManageService;
private final WalletTransactionService walletTransactionService;
private final WalletRechargeInvoiceService walletRechargeInvoiceService;
private final WalletRechargeExecCheckService execCheckService;
private final WalletThirdZyService walletThirdZyService;
/**
* 跨租户扫描到期申请并逐单按租户执行;单笔失败不影响其余申请。
*/
@Override
public int executeDueReserveApplies() {
LocalDateTime now = LocalDateTime.now();
int revived = applyMapper.reviveStuckReserve(now.minusMinutes(CLAIM_STUCK_MINUTES));
if (revived > 0) {
log.warn("|{}|复活执行中卡死的预约单,count:{}", TAG, revived);
}
List<WalletRechargeApplyEntity> dueList = applyMapper.listDueReserveApplies(SCAN_LIMIT);
int executed = 0;
for (WalletRechargeApplyEntity due : dueList) {
if (due.getTenantId() == null) {
log.warn("|{}|预约单缺少租户ID,applyId:{}", TAG, due.getId());
continue;
}
try {
Boolean ok = TenantBroker.applyAs(due.getTenantId(), tenantId -> executeReserveApply(due.getId(), false));
if (Boolean.TRUE.equals(ok)) {
executed++;
}
} catch (Exception exception) {
log.error("|{}|预约单执行异常,applyId:{},tenantId:{},error:{}", TAG, due.getId(), due.getTenantId(), exception.getMessage(), exception);
}
}
return executed;
}
/**
* 单笔预约申请执行:抢占 → 逐明细幂等动钱 → 收尾;明细各自独立事务,失败退避重试。
*/
@Override
public boolean executeReserveApply(Long id, boolean manual) {
WalletRechargeApplyEntity detail = applyService.getById(id);
if (ObjectUtil.isEmpty(detail) || !ObjectUtil.equals(detail.getReserveFlag(), YesNoEnum.YES.getCode())) {
return false;
}
if (!ObjectUtil.equals(detail.getProcessStatus(), TaskCheckStatusEnum.RESULT.getCode())) {
log.warn("|{}|预约单未审批通过,跳过执行,applyId:{},processStatus:{}", TAG, id, detail.getProcessStatus());
return false;
}
if (!claim(detail, manual)) {
return false;
}
try {
doExecute(detail);
LocalDateTime finishTime = LocalDateTime.now();
boolean finalized = applyService.update(new LambdaUpdateWrapper<WalletRechargeApplyEntity>()
.set(WalletRechargeApplyEntity::getExecStatus, WalletReserveExecStatusEnum.SUCCESS.getCode())
.set(WalletRechargeApplyEntity::getExecTime, finishTime)
.set(WalletRechargeApplyEntity::getLastError, null)
.eq(WalletRechargeApplyEntity::getId, id)
.eq(WalletRechargeApplyEntity::getExecStatus, WalletReserveExecStatusEnum.EXECUTING.getCode()));
if (!finalized) {
log.error("|{}|预约单收尾状态更新失败,applyId:{},当前状态可能已被并发修改", TAG, id);
return false;
}
syncThirdZy(detail);
log.info("|{}|预约单执行成功,applyId:{},manual:{}", TAG, id, manual);
selfCheckApply(id);
return true;
} catch (Exception exception) {
log.error("|{}|预约单执行失败,applyId:{},manual:{},error:{}", TAG, id, manual, exception.getMessage(), exception);
recordFailure(detail, exception);
return false;
}
}
/**
* 对账复核:近期已执行/重试耗尽的预约单核对明细与流水,长期未完成的按卡死落差异并自动恢复。
*/
@Override
public int reconcileReserveApplies() {
LocalDateTime now = LocalDateTime.now();
List<WalletRechargeApplyEntity> candidates = applyMapper.listReserveReconcileCandidates(
now.minusDays(RECONCILE_DAYS), now.minusMinutes(STUCK_ALERT_MINUTES), SCAN_LIMIT);
for (WalletRechargeApplyEntity candidate : candidates) {
if (candidate.getTenantId() == null) {
continue;
}
try {
TenantBroker.runAs(candidate.getTenantId(), tenantId -> reconcileSingle(candidate));
} catch (Exception exception) {
log.error("|{}|预约单对账异常,applyId:{},tenantId:{},error:{}", TAG, candidate.getId(), candidate.getTenantId(), exception.getMessage(), exception);
}
}
return candidates.size();
}
/**
* CAS 抢占:条件更新执行状态为执行中,抢不到说明被并发执行器持有或未到期。
*/
private boolean claim(WalletRechargeApplyEntity detail, boolean manual) {
List<String> claimableStatus = manual
? List.of(WalletReserveExecStatusEnum.PENDING.getCode(), WalletReserveExecStatusEnum.RETRY_WAIT.getCode(), WalletReserveExecStatusEnum.FAILED.getCode())
: List.of(WalletReserveExecStatusEnum.PENDING.getCode(), WalletReserveExecStatusEnum.RETRY_WAIT.getCode());
LambdaUpdateWrapper<WalletRechargeApplyEntity> claimWrapper = new LambdaUpdateWrapper<WalletRechargeApplyEntity>()
.set(WalletRechargeApplyEntity::getExecStatus, WalletReserveExecStatusEnum.EXECUTING.getCode())
.set(WalletRechargeApplyEntity::getClaimTime, LocalDateTime.now())
.eq(WalletRechargeApplyEntity::getId, detail.getId())
.in(WalletRechargeApplyEntity::getExecStatus, claimableStatus);
if (!manual) {
claimWrapper.le(WalletRechargeApplyEntity::getNextExecTime, LocalDateTime.now());
}
boolean claimed = applyService.update(claimWrapper);
if (!claimed) {
log.info("|{}|预约单抢占失败,跳过,applyId:{},manual:{}", TAG, detail.getId(), manual);
}
return claimed;
}
/**
* 逐明细幂等动钱:与审批立即执行路径语义一致(发票核对、扣款取负、流水带标签备注),
* 但使用幂等方法并给无钱包用户落差异。单条明细独立事务,部分失败可断点续跑。
*/
private void doExecute(WalletRechargeApplyEntity detail) {
// 与立即执行路径保持一致:执行时才把绑定发票置为已核对
if (ObjectUtil.isNotEmpty(detail.getInvoiceId())) {
WalletRechargeInvoiceEntity invoiceUpdate = new WalletRechargeInvoiceEntity();
invoiceUpdate.setId(detail.getInvoiceId());
invoiceUpdate.setStatus(WalletRechargeInvoiceStatusEnum.CHECKED.getCode());
walletRechargeInvoiceService.updateById(invoiceUpdate);
}
// 与审批路径行为对齐(审批后该快照更新为空操作)
applyService.updateBeforePrice(detail);
List<WalletRechargeImportEntity> importList = rechargeImportService.list(
new LambdaQueryWrapper<WalletRechargeImportEntity>().eq(WalletRechargeImportEntity::getApplyId, detail.getId()));
if (ObjectUtil.isEmpty(importList)) {
log.warn("|{}|预约单没有导入明细,直接视为执行完成,applyId:{}", TAG, detail.getId());
return;
}
boolean reset = ObjectUtil.equals(detail.getBizType(), WalletBizRechargeEnum.RESET.getCode());
for (WalletRechargeImportEntity item : importList) {
Integer price = reset ? item.getPrice()
: (WalletBizRechargeEnum.reductionCodes().contains(detail.getBizType()) ? -1 * Math.abs(item.getPrice()) : item.getPrice());
WalletManageEntity wallet = walletManageService.getWallet(item.getUserId(), detail.getWalletTypeId());
if (ObjectUtil.isEmpty(wallet)) {
// 资金安全:无钱包用户不静默跳过,落差异等待人工处理,整单其余明细继续
recordDiff(detail.getId(), item.getId(), item.getUserId(), WalletRechargeCheckDiffTypeEnum.NO_WALLET, Math.abs(item.getPrice()), null);
continue;
}
if (reset) {
walletTransactionService.resetWalletBalanceIdempotent(wallet.getId(), String.valueOf(item.getId()), item.getTags());
} else {
WalletBizEnum walletBiz = ObjectUtil.equal(WalletBizRechargeEnum.RETURN_CANTEEN.getCode(), detail.getBizType())
? WalletBizEnum.RETURN_CANTEEN : WalletBizEnum.BATCH;
walletTransactionService.addWalletBalanceIdempotent(wallet.getId(), price, String.valueOf(item.getId()), walletBiz, item.getTags(), item.getRemark());
}
}
}
/**
* 失败收尾:累加尝试次数并按退避策略安排下次重试,耗尽转终态等待人工。
*/
private void recordFailure(WalletRechargeApplyEntity detail, Exception exception) {
int execCount = (detail.getExecCount() == null ? 0 : detail.getExecCount()) + 1;
boolean exhausted = execCount >= MAX_EXEC_COUNT;
LocalDateTime nextExecTime = exhausted ? null : LocalDateTime.now().plusMinutes(backoffMinutes(execCount));
String lastError = StrUtil.sub(exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage(), 0, 500);
boolean updated = applyService.update(new LambdaUpdateWrapper<WalletRechargeApplyEntity>()
.set(WalletRechargeApplyEntity::getExecStatus, exhausted ? WalletReserveExecStatusEnum.FAILED.getCode() : WalletReserveExecStatusEnum.RETRY_WAIT.getCode())
.set(WalletRechargeApplyEntity::getExecCount, execCount)
.set(WalletRechargeApplyEntity::getNextExecTime, nextExecTime)
.set(WalletRechargeApplyEntity::getLastError, lastError)
.eq(WalletRechargeApplyEntity::getId, detail.getId())
.eq(WalletRechargeApplyEntity::getExecStatus, WalletReserveExecStatusEnum.EXECUTING.getCode()));
if (updated) {
log.warn("|{}|预约单执行失败已登记,applyId:{},execCount:{},nextExecTime:{},exhausted:{}", TAG, detail.getId(), execCount, nextExecTime, exhausted);
}
}
/**
* 执行成功后同步正元钱包;失败不影响执行结果,与立即执行路径一致。
*/
private void syncThirdZy(WalletRechargeApplyEntity detail) {
try {
List<WalletRechargeImportEntity> importList = rechargeImportService.list(
new LambdaQueryWrapper<WalletRechargeImportEntity>().eq(WalletRechargeImportEntity::getApplyId, detail.getId()));
List<Long> userIds = importList.stream().map(WalletRechargeImportEntity::getUserId).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
walletThirdZyService.syncAllUser(userIds);
}
} catch (Exception exception) {
log.error("|{}|预约执行后同步正元钱包失败,applyId:{},error:{}", TAG, detail.getId(), exception.getMessage());
}
}
/**
* 对账单笔复核:未完成状态按卡死落差异;已完成状态核对明细与流水并自动恢复已修复差异。
*/
private void reconcileSingle(WalletRechargeApplyEntity candidate) {
if (List.of(WalletReserveExecStatusEnum.PENDING.getCode(), WalletReserveExecStatusEnum.EXECUTING.getCode(),
WalletReserveExecStatusEnum.RETRY_WAIT.getCode()).contains(candidate.getExecStatus())) {
recordDiff(candidate.getId(), 0L, null, WalletRechargeCheckDiffTypeEnum.STUCK, null, null);
return;
}
if (ObjectUtil.equals(candidate.getExecStatus(), WalletReserveExecStatusEnum.FAILED.getCode())) {
recordDiff(candidate.getId(), 0L, null, WalletRechargeCheckDiffTypeEnum.RETRY_EXHAUSTED, null, null);
}
// 已到终态(执行成功或耗尽)后卡死差异可自动恢复
autoResolveDiff(candidate.getId(), 0L, WalletRechargeCheckDiffTypeEnum.STUCK.getCode());
selfCheckApply(candidate.getId());
}
/**
* 自检单笔申请:每条导入明细在钱包流水中应有且仅有一条对应记录且金额一致。
*/
private void selfCheckApply(Long applyId) {
try {
WalletRechargeApplyEntity detail = applyService.getById(applyId);
if (ObjectUtil.isEmpty(detail)) {
return;
}
List<WalletRechargeImportEntity> importList = rechargeImportService.list(
new LambdaQueryWrapper<WalletRechargeImportEntity>().eq(WalletRechargeImportEntity::getApplyId, applyId));
if (ObjectUtil.isEmpty(importList)) {
return;
}
boolean reset = ObjectUtil.equals(detail.getBizType(), WalletBizRechargeEnum.RESET.getCode());
String expectBizType = reset ? WalletBizEnum.RESET.getCode()
: (ObjectUtil.equal(WalletBizRechargeEnum.RETURN_CANTEEN.getCode(), detail.getBizType())
? WalletBizEnum.RETURN_CANTEEN.getCode() : WalletBizEnum.BATCH.getCode());
List<String> bizIds = importList.stream().map(v -> String.valueOf(v.getId())).toList();
Map<String, List<WalletTransactionEntity>> txGroup = walletTransactionService.list(
new LambdaQueryWrapper<WalletTransactionEntity>()
.in(WalletTransactionEntity::getBizId, bizIds)
.eq(WalletTransactionEntity::getBizType, expectBizType))
.stream().collect(Collectors.groupingBy(WalletTransactionEntity::getBizId));
List<Long> okImportIds = new java.util.ArrayList<>();
for (WalletRechargeImportEntity item : importList) {
Integer expectPrice = reset ? null
: (WalletBizRechargeEnum.reductionCodes().contains(detail.getBizType()) ? -1 * Math.abs(item.getPrice()) : item.getPrice());
List<WalletTransactionEntity> txList = txGroup.get(String.valueOf(item.getId()));
if (ObjectUtil.isEmpty(txList)) {
recordDiff(applyId, item.getId(), item.getUserId(), WalletRechargeCheckDiffTypeEnum.MISSING_TX, expectPrice, null);
} else if (txList.size() > 1) {
recordDiff(applyId, item.getId(), item.getUserId(), WalletRechargeCheckDiffTypeEnum.DUPLICATE_TX, expectPrice, txList.get(0).getPrice());
} else if (expectPrice != null && !ObjectUtil.equals(txList.get(0).getPrice(), expectPrice)) {
recordDiff(applyId, item.getId(), item.getUserId(), WalletRechargeCheckDiffTypeEnum.AMOUNT_MISMATCH, expectPrice, txList.get(0).getPrice());
} else {
okImportIds.add(item.getId());
}
}
// 本次校验通过的明细,历史待处理差异自动恢复
for (Long importId : okImportIds) {
for (WalletRechargeCheckDiffTypeEnum diffType : List.of(WalletRechargeCheckDiffTypeEnum.MISSING_TX,
WalletRechargeCheckDiffTypeEnum.DUPLICATE_TX, WalletRechargeCheckDiffTypeEnum.AMOUNT_MISMATCH)) {
autoResolveDiff(applyId, importId, diffType.getCode());
}
}
} catch (Exception exception) {
log.error("|{}|预约单自检异常,applyId:{},error:{}", TAG, applyId, exception.getMessage(), exception);
}
}
/**
* 幂等落差异:同键待处理记录刷新检查时间,已自动恢复的重新置待处理,人工已处理的不翻旧账;
* 数据库唯一键兜底并发插入。
*/
private void recordDiff(Long applyId, Long importId, Long userId, WalletRechargeCheckDiffTypeEnum diffType, Integer expectedPrice, Integer actualPrice) {
WalletRechargeExecCheckEntity existing = execCheckService.getOne(new LambdaQueryWrapper<WalletRechargeExecCheckEntity>()
.eq(WalletRechargeExecCheckEntity::getApplyId, applyId)
.eq(WalletRechargeExecCheckEntity::getImportId, importId)
.eq(WalletRechargeExecCheckEntity::getDiffType, diffType.getCode())
.last("limit 1"));
if (existing != null) {
if (ObjectUtil.equals(existing.getStatus(), WalletRechargeCheckStatusEnum.PENDING.getCode())) {
WalletRechargeExecCheckEntity refresh = new WalletRechargeExecCheckEntity();
refresh.setId(existing.getId());
refresh.setCheckTime(LocalDateTime.now());
refresh.setActualPrice(actualPrice);
execCheckService.updateById(refresh);
} else if (ObjectUtil.equals(existing.getStatus(), WalletRechargeCheckStatusEnum.AUTO_RECOVERED.getCode())) {
WalletRechargeExecCheckEntity reopen = new WalletRechargeExecCheckEntity();
reopen.setId(existing.getId());
reopen.setStatus(WalletRechargeCheckStatusEnum.PENDING.getCode());
reopen.setCheckTime(LocalDateTime.now());
reopen.setActualPrice(actualPrice);
execCheckService.updateById(reopen);
log.warn("|{}|对账差异重新出现,applyId:{},importId:{},diffType:{}", TAG, applyId, importId, diffType.getCode());
}
return;
}
try {
WalletRechargeExecCheckEntity insertObj = WalletRechargeExecCheckEntity.builder()
.applyId(applyId)
.importId(importId)
.userId(userId)
.diffType(diffType.getCode())
.expectedPrice(expectedPrice)
.actualPrice(actualPrice)
.checkTime(LocalDateTime.now())
.status(WalletRechargeCheckStatusEnum.PENDING.getCode())
.build();
execCheckService.save(insertObj);
log.warn("|{}|发现对账差异,applyId:{},importId:{},diffType:{},expected:{},actual:{}", TAG, applyId, importId, diffType.getDesc(), expectedPrice, actualPrice);
} catch (DuplicateKeyException exception) {
// 并发复核同键差异时唯一键兜底,忽略
}
}
/**
* 自动恢复:对应差异已不存在(本次校验通过)时,把待处理差异置为已自动恢复。
*/
private void autoResolveDiff(Long applyId, Long importId, String diffType) {
execCheckService.update(new LambdaUpdateWrapper<WalletRechargeExecCheckEntity>()
.set(WalletRechargeExecCheckEntity::getStatus, WalletRechargeCheckStatusEnum.AUTO_RECOVERED.getCode())
.set(WalletRechargeExecCheckEntity::getHandleTime, LocalDateTime.now())
.eq(WalletRechargeExecCheckEntity::getApplyId, applyId)
.eq(WalletRechargeExecCheckEntity::getImportId, importId)
.eq(WalletRechargeExecCheckEntity::getDiffType, diffType)
.eq(WalletRechargeExecCheckEntity::getStatus, WalletRechargeCheckStatusEnum.PENDING.getCode()));
}
/**
* 失败退避分钟数:第 1、2 次分别为 10、20 分钟,之后固定 30 分钟。
*/
static int backoffMinutes(int execCount) {
if (execCount <= 0) {
return RETRY_BACKOFF_MINUTES[0];
}
if (execCount > RETRY_BACKOFF_MINUTES.length) {
return RETRY_BACKOFF_MINUTES[RETRY_BACKOFF_MINUTES.length - 1];
}
return RETRY_BACKOFF_MINUTES[execCount - 1];
}
}
@@ -23,6 +23,14 @@
a.tenant_id,
a.apply_time,
a.is_authorization,
a.reserve_flag,
a.reserve_time,
a.exec_status,
a.exec_time,
a.exec_count,
a.next_exec_time,
a.last_error,
a.claim_time,
</sql>
<sql id="whereA">
@@ -0,0 +1,31 @@
package com.bsd.pjcloud.wallet.service.impl.recharge;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 预约执行失败退避策略测试:退避节奏与 10 分钟扫描粒度对齐,防止重试风暴。
*/
class WalletReserveExecBackoffTest {
/**
* 第 1、2 次失败分别退避 10、20 分钟,之后固定 30 分钟。
*/
@Test
void backoffMinutesShouldFollowScanAlignedSteps() {
assertEquals(10, WalletReserveExecServiceImpl.backoffMinutes(1));
assertEquals(20, WalletReserveExecServiceImpl.backoffMinutes(2));
assertEquals(30, WalletReserveExecServiceImpl.backoffMinutes(3));
assertEquals(30, WalletReserveExecServiceImpl.backoffMinutes(9));
}
/**
* 非法尝试次数按最短退避处理,保证永远存在下一次可执行时间。
*/
@Test
void backoffMinutesShouldClampInvalidCount() {
assertEquals(10, WalletReserveExecServiceImpl.backoffMinutes(0));
assertEquals(10, WalletReserveExecServiceImpl.backoffMinutes(-1));
}
}