diff --git a/db/20260918_hard_coffee_h5_api.md b/db/20260918_hard_coffee_h5_api.md index f1b730ee5..7ecd85bbb 100644 --- a/db/20260918_hard_coffee_h5_api.md +++ b/db/20260918_hard_coffee_h5_api.md @@ -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`)。 diff --git a/db/20260918_hard_coffee_pay_flow.svg b/db/20260918_hard_coffee_pay_flow.svg index d13e69fdd..41dc5e364 100644 --- a/db/20260918_hard_coffee_pay_flow.svg +++ b/db/20260918_hard_coffee_pay_flow.svg @@ -74,7 +74,7 @@ 6 本地落单(并发锁 + 幂等) 机器号定位设备(type=13) → TenantBroker 切设备租户 - 建 hard_order(CREATE)+扩展表 · Redis 支付窗口 5min + 建 hard_order(CREATE)+扩展表 · Redis 支付窗口 2min 生命周期日志 ①GET_ORDER ②CREATE_ORDER @@ -91,7 +91,7 @@ 9 POST /hard-coffee/wallet-pay {orderId}(登录态) - 校验 5 分钟窗口 · 支付并发锁 + 校验 2 分钟窗口 · 支付并发锁 10 diff --git a/db/20260924_add_wallet_recharge_apply_reserve_exec.sql b/db/20260924_add_wallet_recharge_apply_reserve_exec.sql new file mode 100644 index 000000000..32f326c24 --- /dev/null +++ b/db/20260924_add_wallet_recharge_apply_reserve_exec.sql @@ -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; diff --git a/db/20260924_钱包预约执行定时任务.txt b/db/20260924_钱包预约执行定时任务.txt new file mode 100644 index 000000000..b2e82ce21 --- /dev/null +++ b/db/20260924_钱包预约执行定时任务.txt @@ -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`; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java index 7d33512be..552c080a9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/coffee/HardCoffeeController.java @@ -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") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java index 388e59503..2befcfe01 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/coffee/HardCoffeeServiceImpl.java @@ -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); } } diff --git a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml index 1bb34e64c..184880c3c 100644 --- a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml +++ b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml @@ -26,6 +26,12 @@ pjcloud-daemon-quartz-api 5.7.0 + + + com.bsd + pjcloud-wallet-api + 5.7.0 + com.alibaba.cloud diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/WalletReserveTask.java b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/WalletReserveTask.java new file mode 100644 index 000000000..aa4657434 --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/WalletReserveTask.java @@ -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 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 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(); + } +} diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/task/WalletReserveTaskTest.java b/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/task/WalletReserveTaskTest.java new file mode 100644 index 000000000..b072e3cf7 --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/test/java/com/bsd/pjcloud/daemon/quartz/task/WalletReserveTaskTest.java @@ -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)); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletRechargeCheckDiffTypeEnum.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletRechargeCheckDiffTypeEnum.java new file mode 100644 index 000000000..137ae5317 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletRechargeCheckDiffTypeEnum.java @@ -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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletRechargeCheckStatusEnum.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletRechargeCheckStatusEnum.java new file mode 100644 index 000000000..6a72af747 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletRechargeCheckStatusEnum.java @@ -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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletReserveExecStatusEnum.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletReserveExecStatusEnum.java new file mode 100644 index 000000000..191be6978 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/constant/WalletReserveExecStatusEnum.java @@ -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); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeExecCheckDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeExecCheckDto.java new file mode 100644 index 000000000..84644f6a8 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeExecCheckDto.java @@ -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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeApplyEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeApplyEntity.java index b42e97e22..85e7bb19f 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeApplyEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeApplyEntity.java @@ -101,6 +101,54 @@ public class WalletRechargeApplyEntity extends Model @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; + /** * 备注 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeExecCheckEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeExecCheckEntity.java new file mode 100644 index 000000000..66f5bc513 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeExecCheckEntity.java @@ -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 { + + /** + * 主键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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeApplyService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeApplyService.java new file mode 100644 index 000000000..aa7580deb --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeApplyService.java @@ -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 executeDueReserveApplies(); + + /** + * 复核近几天预约申请的明细与钱包流水一致性,差异落对账差异表。 + * + * @return 本轮复核的申请数量 + */ + @NoToken + @PostMapping("/wallet-recharge-apply/inner/reconcile") + R reconcileReserveApplies(); +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeExecCheckVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeExecCheckVo.java new file mode 100644 index 000000000..ecfe88efc --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeExecCheckVo.java @@ -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; +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java index ba5490579..7bd73696c 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeApplyController.java @@ -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 executeDueReserveApplies() { + return R.ok(walletReserveExecService.executeDueReserveApplies()); + } + + /** + * 定时任务入口:复核近期预约申请明细与钱包流水一致性(daemon-quartz Feign 调用)。 + */ + @Inner + @PostMapping("/inner/reconcile") + public R 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) diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeExecCheckController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeExecCheckController.java new file mode 100644 index 000000000..89b1881a2 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeExecCheckController.java @@ -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)); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapper.java index 9df902489..4651509a7 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeApplyMapper.java @@ -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 listTotalPriceByApplyIds(@Param("applyIds") List 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 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 listReserveReconcileCandidates(@Param("checkBefore") LocalDateTime checkBefore, + @Param("stuckBefore") LocalDateTime stuckBefore, + @Param("limit") int limit); + default Page scopePageList(Page page, WalletRechargeApplyDto dto) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(ObjectUtil.isNotEmpty(dto.getBizType()), WalletRechargeApplyEntity::getBizType, dto.getBizType()) diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeExecCheckMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeExecCheckMapper.java new file mode 100644 index 000000000..955a51469 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeExecCheckMapper.java @@ -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 { + +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeExecCheckService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeExecCheckService.java new file mode 100644 index 000000000..bf352154b --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeExecCheckService.java @@ -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 { + + /** + * 分页查询对账差异,并补齐申请的充值类型与饭卡类型名称。 + * + * @param page 分页参数 + * @param dto 差异类型、处理状态与检查时间筛选条件 + * @return 已装配申请信息的差异分页 + */ + Page pageList(Page page, WalletRechargeExecCheckDto dto); + + /** + * 人工标记差异已处理。 + * + * @param id 差异主键 + * @param handleRemark 处理备注 + * @return 仅待处理状态更新成功时返回 {@code true} + */ + Boolean handle(Long id, String handleRemark); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletReserveExecService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletReserveExecService.java new file mode 100644 index 000000000..7b223c4f2 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletReserveExecService.java @@ -0,0 +1,36 @@ +package com.bsd.pjcloud.wallet.service; + +/** + * 充值/扣款申请预约执行器与对账复核服务。 + * + *

预约单的资金变动只允许从本执行器发生:审批回调只登记待执行状态, + * 到期由定时任务逐单 CAS 抢占后按明细幂等执行,配合对账复核保证资金一致性。

+ */ +public interface WalletReserveExecService { + + /** + * 扫描并执行到期的预约充值/扣款申请。 + * + *

跨租户扫描「已审批通过 + 预约时间已到 + 待执行/待重试」的申请, + * 先复活执行中卡死的申请,再按申请租户恢复上下文逐单执行。

+ * + * @return 本轮实际受理(抢占成功)的申请数量 + */ + int executeDueReserveApplies(); + + /** + * 执行单笔预约申请;自动扫描与手动兜底共用同一抢占与幂等逻辑。 + * + * @param id 申请主键 + * @param manual true 表示手动立即执行:忽略预约时间未到限制,并允许从重试耗尽终态挽回 + * @return 抢占成功且执行完成的申请返回 {@code true} + */ + boolean executeReserveApply(Long id, boolean manual); + + /** + * 复核近期预约申请的明细与钱包流水一致性,差异落对账差异表并自动恢复已修复差异。 + * + * @return 本轮复核的申请数量 + */ + int reconcileReserveApplies(); +} 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 3e90a21e5..2ddb0aa7b 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 @@ -154,6 +154,19 @@ public interface WalletTransactionService extends IService list); /** 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 68366d4fc..7f4adb750 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 @@ -718,6 +718,24 @@ public class WalletTransactionServiceImpl extends ServiceImpl implements WalletRechargeExecCheckService { + + private final WalletRechargeApplyService walletRechargeApplyService; + private final WalletTypeService walletTypeService; + + /** + * 分页查询对账差异并按当前页申请批量补齐充值类型与饭卡类型名称。 + */ + @Override + public Page pageList(Page page, WalletRechargeExecCheckDto dto) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 list = page(page, wrapper); + Page 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 records) { + if (CollectionUtils.isEmpty(records)) { + return; + } + List applyIds = records.stream().map(WalletRechargeExecCheckVo::getApplyId).distinct().toList(); + Map applyMap = walletRechargeApplyService.listByIds(applyIds).stream() + .collect(Collectors.toMap(WalletRechargeApplyEntity::getId, Function.identity(), (a, b) -> a)); + List walletTypeIds = applyMap.values().stream() + .map(WalletRechargeApplyEntity::getWalletTypeId) + .filter(ObjectUtil::isNotEmpty) + .distinct().toList(); + Map 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())); + } + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecServiceImpl.java new file mode 100644 index 000000000..92107b89a --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecServiceImpl.java @@ -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; + +/** + * 充值/扣款申请预约执行器与对账复核实现。 + * + *

资金安全设计:预约单的余额变动只从本执行器发生(审批回调只登记状态); + * 执行前按 exec_status 条件更新 CAS 抢占,多执行器并发时同一单只有一个赢家; + * 每条明细独立事务调用幂等动钱方法(钱包行锁 + 业务号查重),重试、断点续跑、 + * 卡死复活重跑都不会重复入账;无钱包用户不再静默跳过,而是落对账差异等待人工处理; + * 执行成功后立即自检,另有低频对账任务全量复核。

+ */ +@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 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() + .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 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 claimableStatus = manual + ? List.of(WalletReserveExecStatusEnum.PENDING.getCode(), WalletReserveExecStatusEnum.RETRY_WAIT.getCode(), WalletReserveExecStatusEnum.FAILED.getCode()) + : List.of(WalletReserveExecStatusEnum.PENDING.getCode(), WalletReserveExecStatusEnum.RETRY_WAIT.getCode()); + LambdaUpdateWrapper claimWrapper = new LambdaUpdateWrapper() + .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 importList = rechargeImportService.list( + new LambdaQueryWrapper().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() + .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 importList = rechargeImportService.list( + new LambdaQueryWrapper().eq(WalletRechargeImportEntity::getApplyId, detail.getId())); + List 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 importList = rechargeImportService.list( + new LambdaQueryWrapper().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 bizIds = importList.stream().map(v -> String.valueOf(v.getId())).toList(); + Map> txGroup = walletTransactionService.list( + new LambdaQueryWrapper() + .in(WalletTransactionEntity::getBizId, bizIds) + .eq(WalletTransactionEntity::getBizType, expectBizType)) + .stream().collect(Collectors.groupingBy(WalletTransactionEntity::getBizId)); + List 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 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() + .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() + .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]; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml index 240485a90..a11c67191 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeApplyMapper.xml @@ -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, diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecBackoffTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecBackoffTest.java new file mode 100644 index 000000000..6e0824476 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletReserveExecBackoffTest.java @@ -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)); + } +}