Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
33c93c3e18
25 files changed
+1082
-409
No files matched your search
@@ -0,0 +1,219 @@
|
||||
========================================================================
|
||||
hardware 定时任务迁移 daemon-quartz - sys_job 手工添加清单 (2026-09-28)
|
||||
========================================================================
|
||||
|
||||
说明:以下内容需要管理员在对应环境手工添加(Quartz 任务管理页或直接执行 SQL)。
|
||||
SQL 幂等,可重复执行。
|
||||
注意库名:sys_job 在 `pjcloudx_job` 库(与 qrtz_* / sys_job_log 同库)。
|
||||
|
||||
迁移背景:
|
||||
hardware 原本地 @Scheduled 定时任务(6 个类共 8 个方法)已全部迁移到 daemon-quartz
|
||||
统一调度,集群部署下避免各实例重复触发,且可在任务管理页暂停/改cron/手动执行/看执行日志。
|
||||
原 job 包已整体删除:纯转发型 job(咖啡机/Outbox/打印)由 HardScheduleInnerController
|
||||
直调领域服务;聚合支付结算补偿编排平移到 HardAggregateOrderPayCompensator;
|
||||
自动退款扫描编排平移进 HardAutoRefundTaskService.retryDueTasks;消费机日志清理平移进
|
||||
HardXfjLogService.cleanupExpiredLogs。内部端点统一挂在 /hard-schedule/inner/** 下,
|
||||
由 daemon 的 hardScheduleTask bean(class_name=hardScheduleTask)通过 Feign 反射调用。
|
||||
HardApp 已移除 @EnableScheduling,hardware 不再有任何本地周期调度。
|
||||
|
||||
部署顺序(硬前提,顺序不能颠倒):
|
||||
1. 部署 pjcloud-hardware-biz(新增内部端点、已移除本地 @Scheduled 与 @EnableScheduling);
|
||||
2. 部署 pjcloud-daemon-quartz(新增 hardScheduleTask bean 与 hardware-api 依赖);
|
||||
3. 执行下方 8 条 sys_job INSERT;
|
||||
4. 重启 daemon-quartz 自动加载(或调 POST /sys-job/refresh-jobs,权限 job_sys_job_refresh_job)。
|
||||
|
||||
空窗期说明(hardware 已部署、任务未生效期间补偿停摆的影响):
|
||||
- 聚合支付结算:终端主动轮询仍是实时结算入口,补偿只是兜底;恢复后 1 小时内订单
|
||||
均会被遗留核对扫到,不丢单;
|
||||
- 自动退款 / Outbox / 打印任务:均为持久化任务表,恢复后按到期时间补执行;
|
||||
- 咖啡机通知:失败计数持久化(wuli_notify_count),恢复后继续重试;
|
||||
- 消费机日志清理:每日一次,无影响。
|
||||
结论:分钟级空窗只延迟补偿、不丢数据;但仍应尽快按顺序完成部署。
|
||||
|
||||
行为说明:
|
||||
原 fixedDelay 节奏改为固定 cron 节奏;Quartz 任务执行入口 PjcloudQuartzFactory 带
|
||||
@DisallowConcurrentExecution,单轮未结束时下一触发自动顺延,不会重叠。
|
||||
集群重复触发/多节点安全性:
|
||||
- 聚合支付结算:分布式订单锁 + 订单状态条件更新保证幂等;近期扫描在 hardware
|
||||
实例内还有轮询协调器退避门控,空闲时触发进来直接空转,不扫库。退避门控是实例内
|
||||
状态,多实例下负载均衡会把触发分摊到各实例,新订单唤醒后最迟 N×5 秒内会被扫到,
|
||||
实时结算仍以终端主动轮询为主、本任务只做兜底,不受影响;
|
||||
- 自动退款:订单 Redis 锁 + 任务行锁最终串行;
|
||||
- Outbox 投递/清理:条件更新(状态 CAS)领取,天然幂等;
|
||||
- 打印/咖啡机/消费机日志:任务表状态流转或条件删除,重复执行安全。
|
||||
misfire_policy 统一选 '3'(错过的触发不补偿执行,等下个周期),避免服务恢复后
|
||||
高频补偿任务雪崩式补跑。
|
||||
|
||||
------------------------------------------------------------------------
|
||||
一、sys_job:聚合支付近期结算补偿(每5秒,对应原 fixedDelay=5s)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `pjcloudx_job`.`sys_job`
|
||||
(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
|
||||
`method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
|
||||
`job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
|
||||
SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 1,
|
||||
'聚合支付近期结算补偿', 'HARDWARE', '1', '2', NULL, 'hardScheduleTask', 'settlePendingAggregateOrders',
|
||||
NULL, '0/5 * * * * ?', '3', '1', '2',
|
||||
'0', 'system', NOW(), 'system', NOW(), 1, '每5秒补偿近15分钟未结算的聚合支付订单;空闲时硬件侧退避门控空转'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `pjcloudx_job`.`sys_job`
|
||||
WHERE `job_name` = '聚合支付近期结算补偿' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------
|
||||
二、sys_job:聚合支付遗留核对与超时取消(每5分钟,对应原 fixedDelay=5min)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `pjcloudx_job`.`sys_job`
|
||||
(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
|
||||
`method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
|
||||
`job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
|
||||
SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 2,
|
||||
'聚合支付遗留核对与超时取消', 'HARDWARE', '2', '2', NULL, 'hardScheduleTask', 'settleStaleAggregateOrders',
|
||||
NULL, '0 0/5 * * * ?', '3', '1', '2',
|
||||
'0', 'system', NOW(), 'system', NOW(), 1, '每5分钟核对15-60分钟遗留订单并原子取消超1小时订单'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `pjcloudx_job`.`sys_job`
|
||||
WHERE `job_name` = '聚合支付遗留核对与超时取消' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------
|
||||
三、sys_job:自动退款到期任务补偿(每30秒,对应原 fixedDelay=30s)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `pjcloudx_job`.`sys_job`
|
||||
(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
|
||||
`method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
|
||||
`job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
|
||||
SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 3,
|
||||
'自动退款到期任务补偿', 'HARDWARE', '3', '2', NULL, 'hardScheduleTask', 'retryDueAutoRefundTasks',
|
||||
NULL, '0/30 * * * * ?', '3', '1', '2',
|
||||
'0', 'system', NOW(), 'system', NOW(), 1, '每30秒扫描到期/暂停自动退款任务,订单Redis锁+任务行锁串行'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `pjcloudx_job`.`sys_job`
|
||||
WHERE `job_name` = '自动退款到期任务补偿' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------
|
||||
四、sys_job:咖啡机通知失败补偿(每分钟,对应原 fixedDelay=60s)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `pjcloudx_job`.`sys_job`
|
||||
(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
|
||||
`method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
|
||||
`job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
|
||||
SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 4,
|
||||
'咖啡机通知失败补偿', 'HARDWARE', '4', '2', NULL, 'hardScheduleTask', 'retryCoffeeNotifyFailures',
|
||||
NULL, '0 * * * * ?', '3', '1', '2',
|
||||
'0', 'system', NOW(), 'system', NOW(), 1, '每分钟重试咖啡机支付回调/退款同步失败订单,按通知次数上限熔断'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `pjcloudx_job`.`sys_job`
|
||||
WHERE `job_name` = '咖啡机通知失败补偿' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------
|
||||
五、sys_job:核销消息Outbox到期投递(每10秒,对应原 fixedDelay=10s)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `pjcloudx_job`.`sys_job`
|
||||
(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
|
||||
`method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
|
||||
`job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
|
||||
SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 5,
|
||||
'核销消息Outbox到期投递', 'HARDWARE', '5', '2', NULL, 'hardScheduleTask', 'processOutboxDueEvents',
|
||||
NULL, '0/10 * * * * ?', '3', '1', '2',
|
||||
'0', 'system', NOW(), 'system', NOW(), 1, '每10秒扫描到期核销消息Outbox事件,条件更新领取幂等'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `pjcloudx_job`.`sys_job`
|
||||
WHERE `job_name` = '核销消息Outbox到期投递' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------
|
||||
六、sys_job:核销消息Outbox过期清理(每日 02:30,与原本地任务一致)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `pjcloudx_job`.`sys_job`
|
||||
(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
|
||||
`method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
|
||||
`job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
|
||||
SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 6,
|
||||
'核销消息Outbox过期清理', 'HARDWARE', '6', '2', NULL, 'hardScheduleTask', 'cleanupOutboxExpiredEvents',
|
||||
NULL, '0 30 2 * * ?', '3', '1', '2',
|
||||
'0', 'system', NOW(), 'system', NOW(), 1, '每日02:30分批清理超保留期终态核销Outbox'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `pjcloudx_job`.`sys_job`
|
||||
WHERE `job_name` = '核销消息Outbox过期清理' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------
|
||||
七、sys_job:打印任务补偿扫描(每10秒,对应原 fixedDelay=10s)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `pjcloudx_job`.`sys_job`
|
||||
(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
|
||||
`method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
|
||||
`job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
|
||||
SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 7,
|
||||
'打印任务补偿扫描', 'HARDWARE', '7', '2', NULL, 'hardScheduleTask', 'processPrintDueTasks',
|
||||
NULL, '0/10 * * * * ?', '3', '1', '2',
|
||||
'0', 'system', NOW(), 'system', NOW(), 1, '每10秒扫描待生成/待下发持久化打印任务,恢复MQTT离线期遗留'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `pjcloudx_job`.`sys_job`
|
||||
WHERE `job_name` = '打印任务补偿扫描' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------
|
||||
八、sys_job:消费机日志过期清理(每日 00:00,与原本地任务一致)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO `pjcloudx_job`.`sys_job`
|
||||
(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
|
||||
`method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
|
||||
`job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
|
||||
SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 8,
|
||||
'消费机日志过期清理', 'HARDWARE', '8', '2', NULL, 'hardScheduleTask', 'cleanupXfjLog',
|
||||
NULL, '0 0 0 * * ?', '3', '1', '2',
|
||||
'0', 'system', NOW(), 'system', NOW(), 1, '每日00:00删除30天前消费机日志;累计不足1万条时跳过'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `pjcloudx_job`.`sys_job`
|
||||
WHERE `job_name` = '消费机日志过期清理' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
|
||||
);
|
||||
|
||||
------------------------------------------------------------------------
|
||||
九、内部端点与任务对应关系(勿对外暴露,@Inner + @NoToken 仅服务间调用)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
hardScheduleTask.settlePendingAggregateOrders:
|
||||
POST /hard-schedule/inner/aggregate-pay/settle-pending 聚合支付近期结算补偿
|
||||
hardScheduleTask.settleStaleAggregateOrders:
|
||||
POST /hard-schedule/inner/aggregate-pay/settle-stale 聚合支付遗留核对与超时取消
|
||||
hardScheduleTask.retryDueAutoRefundTasks:
|
||||
POST /hard-schedule/inner/auto-refund/retry-due 自动退款到期任务补偿
|
||||
hardScheduleTask.retryCoffeeNotifyFailures:
|
||||
POST /hard-schedule/inner/coffee-notify/retry-failures 咖啡机通知失败补偿
|
||||
hardScheduleTask.processOutboxDueEvents:
|
||||
POST /hard-schedule/inner/outbox/process-due 核销消息Outbox到期投递
|
||||
hardScheduleTask.cleanupOutboxExpiredEvents:
|
||||
POST /hard-schedule/inner/outbox/cleanup 核销消息Outbox过期清理
|
||||
hardScheduleTask.processPrintDueTasks:
|
||||
POST /hard-schedule/inner/print/process-due 打印任务补偿扫描
|
||||
hardScheduleTask.cleanupXfjLog:
|
||||
POST /hard-schedule/inner/xfj-log/cleanup 消费机日志过期清理
|
||||
|
||||
------------------------------------------------------------------------
|
||||
十、回滚说明
|
||||
------------------------------------------------------------------------
|
||||
1. 停用任务:将 8 条 sys_job 的 job_status 改为 '3'(暂停)或 '4'(删除)并重启
|
||||
daemon-quartz(或调 POST /sys-job/refresh-jobs);
|
||||
2. 恢复本地调度:回滚整个迁移提交(恢复 job 包 6 个类与 HardApp 的
|
||||
@EnableScheduling)并重新部署 hardware;
|
||||
3. 只想临时停某个补偿任务:单独暂停对应一条 sys_job 即可,其余任务不受影响。
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.bsd.pjcloud.hard.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 定时触发 Hardware 各补偿任务的内部 Feign 契约。
|
||||
*
|
||||
* <p>Hardware 原本地 {@code @Scheduled} 任务已全部迁移到 daemon-quartz 统一调度,本契约对应
|
||||
* HardScheduleInnerController 暴露的内部端点;各任务内部由分布式订单锁、任务行锁、
|
||||
* Outbox 条件更新或轮询协调器退避门控保证重复触发幂等安全。</p>
|
||||
*/
|
||||
@FeignClient(contextId = "remoteHardScheduleService", value = ServiceNameConstants.HARDWARE_SERVICE)
|
||||
public interface RemoteHardScheduleService {
|
||||
|
||||
/**
|
||||
* 高频补偿最近十五分钟内仍处于创建状态的聚合支付订单;空闲时由轮询协调器退避门控直接空转返回。
|
||||
*
|
||||
* @return 固定返回成功标记,本轮是否实际扫描由 Hardware 实例内协调器决定
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/hard-schedule/inner/aggregate-pay/settle-pending")
|
||||
R<Void> settlePendingAggregateOrders();
|
||||
|
||||
/**
|
||||
* 低频核对十五分钟至一小时内的遗留聚合支付订单,并原子取消超过一小时的订单。
|
||||
*
|
||||
* @return 固定返回成功标记,逐单结果由订单锁与状态条件更新保证
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/hard-schedule/inner/aggregate-pay/settle-stale")
|
||||
R<Void> settleStaleAggregateOrders();
|
||||
|
||||
/**
|
||||
* 扫描到期的自动退款任务并按任务租户恢复上下文后执行。
|
||||
*
|
||||
* @return 本轮扫描到并尝试执行的任务数量
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/hard-schedule/inner/auto-refund/retry-due")
|
||||
R<Integer> retryDueAutoRefundTasks();
|
||||
|
||||
/**
|
||||
* 重试通知失败的咖啡机支付回调与退款同步缺口。
|
||||
*
|
||||
* @return 固定返回成功标记,失败订单按通知次数上限熔断
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/hard-schedule/inner/coffee-notify/retry-failures")
|
||||
R<Void> retryCoffeeNotifyFailures();
|
||||
|
||||
/**
|
||||
* 扫描到期的核销消息 Outbox 事件并投递,条件更新领取保证重复触发幂等。
|
||||
*
|
||||
* @return 本轮处理的事件数量
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/hard-schedule/inner/outbox/process-due")
|
||||
R<Integer> processOutboxDueEvents();
|
||||
|
||||
/**
|
||||
* 分批清理超过保留期的终态核销消息 Outbox。
|
||||
*
|
||||
* @return 本轮清理的事件数量
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/hard-schedule/inner/outbox/cleanup")
|
||||
R<Integer> cleanupOutboxExpiredEvents();
|
||||
|
||||
/**
|
||||
* 扫描待生成的持久化打印任务并补偿下发待打印任务。
|
||||
*
|
||||
* @return 本轮处理的打印任务数量
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/hard-schedule/inner/print/process-due")
|
||||
R<Integer> processPrintDueTasks();
|
||||
|
||||
/**
|
||||
* 清理创建时间超过三十天的消费机日志,低于阈值时不执行删除。
|
||||
*
|
||||
* @return 本轮删除的日志行数,未达阈值时为零
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/hard-schedule/inner/xfj-log/cleanup")
|
||||
R<Integer> cleanupXfjLog();
|
||||
}
|
||||
+15
-6
@@ -175,9 +175,9 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
|
||||
* 渲染兑换券商品卡片,数量为整数份数。
|
||||
*/
|
||||
@Override
|
||||
public EscPosReceiptCommand voucherProduct(String name, int quantity, String unitPrice, String subtotal,
|
||||
boolean combo, List<String> componentItems) {
|
||||
return voucherProduct(name, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
|
||||
public EscPosReceiptCommand voucherProduct(String name, String subName, int quantity, String unitPrice,
|
||||
String subtotal, boolean combo, List<String> componentItems) {
|
||||
return voucherProduct(name, subName, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,12 +185,15 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
|
||||
*
|
||||
* <p>与图片版卡片逐项对应:框线对应描边矩形;组合商品标签为 GS B 反色黑底(对应图片
|
||||
* fillRect 黑底白字),单品标签为制表符描边小框(对应图片 drawRect 描边框);卡片内部
|
||||
* 压缩行距让左右竖线行间衔接不断线,各区块间距按图片版像素值(1px≈1 点)走纸。</p>
|
||||
* 压缩行距让左右竖线行间衔接不断线,各区块间距按图片版像素值(1px≈1 点)走纸。
|
||||
* subName 非空时在倍宽主名后输出单倍宽副行,对应图片版主名下方的小字号商品名。</p>
|
||||
*/
|
||||
@Override
|
||||
public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal,
|
||||
boolean combo, List<String> componentItems) {
|
||||
public EscPosReceiptCommand voucherProduct(String name, String subName, String quantity, String unitPrice,
|
||||
String subtotal, boolean combo, List<String> componentItems) {
|
||||
String safeName = name == null || name.isBlank() ? "未命名商品" : name;
|
||||
String safeSubName = subName == null ? null : subName.trim();
|
||||
if (safeSubName != null && safeSubName.isBlank()) safeSubName = null;
|
||||
String priceText = "单价 ¥" + (unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice)
|
||||
+ " " + (quantity == null || quantity.isBlank() ? "x0" : quantity);
|
||||
String subtotalText = "小计 ¥" + (subtotal == null || subtotal.isBlank() ? "--" : subtotal);
|
||||
@@ -208,6 +211,12 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
|
||||
for (String nameLine : wrapByWidth(safeName, scaledInner)) {
|
||||
printScaledNameInBox(nameLine, scaledInner - displayWidth(nameLine));
|
||||
}
|
||||
// 副行商品名:单倍宽不加粗,对应图片版小字号,ESC/POS 无缩小字号指令故与价格行同档。
|
||||
if (safeSubName != null) {
|
||||
for (String subLine : wrapByWidth(" " + safeSubName, BOX_INNER_WIDTH)) {
|
||||
boxLine(subLine, false);
|
||||
}
|
||||
}
|
||||
boxLineTwoColumns(priceText, subtotalText, false);
|
||||
|
||||
List<String> safeComponents = componentItems == null ? List.of() : componentItems.stream()
|
||||
|
||||
+6
-4
@@ -92,7 +92,8 @@ public interface ReceiptRenderer {
|
||||
/**
|
||||
* 渲染兑换券商品卡片,数量为整数份数。
|
||||
*
|
||||
* @param name 商品快照名称
|
||||
* @param name 主商品名(套餐名优先);空值由实现兜底
|
||||
* @param subName 主名下方的副行名称(商品名);null 或空白时不渲染副行
|
||||
* @param quantity 剩余可履约数量
|
||||
* @param unitPrice 商品快照单价,单位元且不含货币符号
|
||||
* @param subtotal 剩余可履约数量对应的小计,单位元且不含货币符号
|
||||
@@ -100,13 +101,14 @@ public interface ReceiptRenderer {
|
||||
* @param componentItems 组合商品组成说明,每项应已包含对应数量;可为空
|
||||
* @return 当前渲染器
|
||||
*/
|
||||
ReceiptRenderer voucherProduct(String name, int quantity, String unitPrice, String subtotal,
|
||||
ReceiptRenderer voucherProduct(String name, String subName, int quantity, String unitPrice, String subtotal,
|
||||
boolean combo, List<String> componentItems);
|
||||
|
||||
/**
|
||||
* 渲染兑换券商品卡片,数量为已格式化文本(份数“x2”或称重“0.5/kg”)。
|
||||
*
|
||||
* @param name 商品快照名称
|
||||
* @param name 主商品名(套餐名优先);空值由实现兜底
|
||||
* @param subName 主名下方的副行名称(商品名);null 或空白时不渲染副行
|
||||
* @param quantity 数量展示文本
|
||||
* @param unitPrice 商品快照单价,单位元且不含货币符号
|
||||
* @param subtotal 商品小计,单位元且不含货币符号
|
||||
@@ -114,7 +116,7 @@ public interface ReceiptRenderer {
|
||||
* @param componentItems 组合商品组成说明,每项应已包含对应数量;可为空
|
||||
* @return 当前渲染器
|
||||
*/
|
||||
ReceiptRenderer voucherProduct(String name, String quantity, String unitPrice, String subtotal,
|
||||
ReceiptRenderer voucherProduct(String name, String subName, String quantity, String unitPrice, String subtotal,
|
||||
boolean combo, List<String> componentItems);
|
||||
|
||||
/**
|
||||
|
||||
+24
-8
@@ -292,17 +292,19 @@ public class ThermalReceiptImage implements ReceiptRenderer {
|
||||
* @param componentItems 组合商品组成说明,每项应已包含对应数量;单品或无组成时可为空
|
||||
* @return 当前绘制器
|
||||
*/
|
||||
public ThermalReceiptImage voucherProduct(String name, int quantity, String unitPrice, String subtotal,
|
||||
boolean combo, List<String> componentItems) {
|
||||
return voucherProduct(name, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
|
||||
public ThermalReceiptImage voucherProduct(String name, String subName, int quantity, String unitPrice,
|
||||
String subtotal, boolean combo, List<String> componentItems) {
|
||||
return voucherProduct(name, subName, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制兑换券商品卡片,数量为已格式化文本,供兑换券与发票共用同一卡片版式。
|
||||
*
|
||||
* <p>数量文本由调用方按业务格式化,例如按份数的“x2”或称重的“0.5/kg”,绘制时原样展示。</p>
|
||||
* <p>数量文本由调用方按业务格式化,例如按份数的“x2”或称重的“0.5/kg”,绘制时原样展示。
|
||||
* 主名保持大号粗体;subName 非空时在主名与价格行之间以正文字号渲染副行小字。</p>
|
||||
*
|
||||
* @param name 商品快照名称;空值显示“未命名商品”
|
||||
* @param name 主商品名(套餐名优先);空值显示“未命名商品”
|
||||
* @param subName 主名下方的副行名称(商品名);null 或空白时不渲染副行
|
||||
* @param quantity 数量展示文本,例如“x2”或“0.5/kg”
|
||||
* @param unitPrice 商品快照单价,单位元且不含货币符号
|
||||
* @param subtotal 商品小计,单位元且不含货币符号
|
||||
@@ -310,10 +312,12 @@ public class ThermalReceiptImage implements ReceiptRenderer {
|
||||
* @param componentItems 组合商品组成说明,每项应已包含对应数量;单品或无组成时可为空
|
||||
* @return 当前绘制器
|
||||
*/
|
||||
public ThermalReceiptImage voucherProduct(String name, String quantity, String unitPrice, String subtotal,
|
||||
boolean combo, List<String> componentItems) {
|
||||
public ThermalReceiptImage voucherProduct(String name, String subName, String quantity, String unitPrice,
|
||||
String subtotal, boolean combo, List<String> componentItems) {
|
||||
String typeLabel = combo ? "组合商品" : "单品";
|
||||
String safeName = name == null || name.isBlank() ? "未命名商品" : name;
|
||||
String safeSubName = subName == null ? null : subName.trim();
|
||||
if (safeSubName != null && safeSubName.isBlank()) safeSubName = null;
|
||||
String safeQuantity = quantity == null || quantity.isBlank() ? "x0" : quantity;
|
||||
String safeUnitPrice = unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice;
|
||||
String safeSubtotal = subtotal == null || subtotal.isBlank() ? "--" : subtotal;
|
||||
@@ -335,6 +339,10 @@ public class ThermalReceiptImage implements ReceiptRenderer {
|
||||
int nameLineHeight = nameMetrics.getHeight() + 4;
|
||||
|
||||
setFont(Font.PLAIN, priceFontSize);
|
||||
FontMetrics subNameMetrics = graphics.getFontMetrics();
|
||||
List<String> subNameLines = safeSubName == null ? List.of() : wrap(safeSubName, innerWidth, subNameMetrics);
|
||||
int subNameLineHeight = subNameMetrics.getHeight() + 3;
|
||||
|
||||
FontMetrics priceMetrics = graphics.getFontMetrics();
|
||||
String priceAndQuantity = "单价 ¥" + safeUnitPrice + " " + safeQuantity;
|
||||
String subtotalText = "小计 ¥" + safeSubtotal;
|
||||
@@ -352,7 +360,8 @@ public class ThermalReceiptImage implements ReceiptRenderer {
|
||||
int componentHeight = componentLines.stream().mapToInt(List::size).sum() * componentLineHeight;
|
||||
int comboSectionHeight = combo && !componentLines.isEmpty()
|
||||
? 18 + componentLineHeight + componentHeight : 0;
|
||||
int cardHeight = PRODUCT_CARD_PADDING + tagHeight + 10 + nameLines.size() * nameLineHeight + 8
|
||||
int cardHeight = PRODUCT_CARD_PADDING + tagHeight + 10 + nameLines.size() * nameLineHeight
|
||||
+ subNameLines.size() * subNameLineHeight + 8
|
||||
+ (splitPriceLine ? priceLineHeight * 2 : priceLineHeight) + comboSectionHeight
|
||||
+ PRODUCT_CARD_PADDING;
|
||||
ensureCapacity(cardHeight + PRODUCT_CARD_GAP);
|
||||
@@ -382,6 +391,13 @@ public class ThermalReceiptImage implements ReceiptRenderer {
|
||||
graphics.drawString(line, contentX, contentY + nameMetrics.getAscent());
|
||||
contentY += nameLineHeight;
|
||||
}
|
||||
if (!subNameLines.isEmpty()) {
|
||||
setFont(Font.PLAIN, priceFontSize);
|
||||
for (String line : subNameLines) {
|
||||
graphics.drawString(line, contentX, contentY + subNameMetrics.getAscent());
|
||||
contentY += subNameLineHeight;
|
||||
}
|
||||
}
|
||||
contentY += 8;
|
||||
|
||||
setFont(Font.PLAIN, priceFontSize);
|
||||
|
||||
+1
-2
@@ -6,7 +6,6 @@ import com.bsd.pjcloud.common.swagger.annotation.EnableOpenApi;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* @author archetype auto
|
||||
@@ -17,8 +16,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
@EnablePjcloudFeignClients
|
||||
@EnableDiscoveryClient
|
||||
@EnablePjcloudResourceServer
|
||||
// 周期调度已统一迁移到 daemon-quartz(sys_job 注册 hardScheduleTask,经内部端点触发),集群下避免各实例重复触发
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class HardApp {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(HardApp.class, args);
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package com.bsd.pjcloud.hard.controller.schedule;
|
||||
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.feign.annotation.NoToken;
|
||||
import com.bsd.pjcloud.common.security.annotation.Inner;
|
||||
import com.bsd.pjcloud.hard.service.HardAggregateOrderPayCompensator;
|
||||
import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
|
||||
import com.bsd.pjcloud.hard.service.HardCoffeeService;
|
||||
import com.bsd.pjcloud.hard.service.HardInvoiceService;
|
||||
import com.bsd.pjcloud.hard.service.HardMessageOutboxService;
|
||||
import com.bsd.pjcloud.hard.service.HardXfjLogService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Hardware 补偿任务内部调度接口,供 daemon-quartz 定时任务通过 Feign 触发。
|
||||
*
|
||||
* <p>原本地 {@code @Scheduled} 任务已删除,执行逻辑平移到对应领域服务;本控制器只做触发转发,
|
||||
* 集群重复触发由各任务内部的分布式锁、条件更新或退避门控保证幂等。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/hard-schedule")
|
||||
@Tag(description = "Hardware 内部调度触发端点,仅限服务间调用", name = "硬件定时任务内部调度")
|
||||
public class HardScheduleInnerController {
|
||||
|
||||
/**
|
||||
* 自动退款单轮跨租户最多扫描任务数量,避免配置和钱包服务突发压力。
|
||||
*/
|
||||
private static final int AUTO_REFUND_SCAN_BATCH_SIZE = 100;
|
||||
|
||||
/** 消费机聚合订单支付结算补偿编排器。 */
|
||||
private final HardAggregateOrderPayCompensator aggregateOrderPayCompensator;
|
||||
|
||||
/** 自动退款任务状态机服务。 */
|
||||
private final HardAutoRefundTaskService autoRefundTaskService;
|
||||
|
||||
/** 咖啡机订单服务。 */
|
||||
private final HardCoffeeService hardCoffeeService;
|
||||
|
||||
/** 核销消息 Outbox 处理服务。 */
|
||||
private final HardMessageOutboxService messageOutboxService;
|
||||
|
||||
/** 打印任务服务。 */
|
||||
private final HardInvoiceService hardInvoiceService;
|
||||
|
||||
/** 消费机日志服务。 */
|
||||
private final HardXfjLogService hardXfjLogService;
|
||||
|
||||
/**
|
||||
* 高频补偿近期待结算聚合支付订单,必须携带内部调用标识。
|
||||
*
|
||||
* @return 成功标记;本轮是否真正扫描由实例内轮询协调器退避门控决定
|
||||
*/
|
||||
@Operation(summary = "内部触发聚合支付近期结算补偿")
|
||||
@Inner
|
||||
@NoToken
|
||||
@PostMapping("/inner/aggregate-pay/settle-pending")
|
||||
public R<Void> settlePendingAggregateOrders() {
|
||||
aggregateOrderPayCompensator.settlePendingAggregateOrders();
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 低频核对遗留聚合支付订单并取消超时订单,必须携带内部调用标识。
|
||||
*
|
||||
* @return 成功标记;逐单结算与取消由订单锁和状态条件更新保证
|
||||
*/
|
||||
@Operation(summary = "内部触发聚合支付遗留核对与超时取消")
|
||||
@Inner
|
||||
@NoToken
|
||||
@PostMapping("/inner/aggregate-pay/settle-stale")
|
||||
public R<Void> settleStaleAggregateOrders() {
|
||||
aggregateOrderPayCompensator.settleStaleAggregateOrders();
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描到期自动退款任务并逐个恢复租户上下文执行,必须携带内部调用标识。
|
||||
*
|
||||
* @return 本轮扫描到并尝试执行的任务数量
|
||||
*/
|
||||
@Operation(summary = "内部触发自动退款到期任务补偿")
|
||||
@Inner
|
||||
@NoToken
|
||||
@PostMapping("/inner/auto-refund/retry-due")
|
||||
public R<Integer> retryDueAutoRefundTasks() {
|
||||
return R.ok(autoRefundTaskService.retryDueTasks(AUTO_REFUND_SCAN_BATCH_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试咖啡机通知失败的支付回调与退款同步缺口,必须携带内部调用标识。
|
||||
*
|
||||
* @return 成功标记;失败订单按通知次数上限熔断后需人工介入
|
||||
*/
|
||||
@Operation(summary = "内部触发咖啡机通知失败补偿")
|
||||
@Inner
|
||||
@NoToken
|
||||
@PostMapping("/inner/coffee-notify/retry-failures")
|
||||
public R<Void> retryCoffeeNotifyFailures() {
|
||||
hardCoffeeService.retryNotifyFailures();
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描到期核销消息 Outbox 事件并投递,必须携带内部调用标识。
|
||||
*
|
||||
* @return 本轮处理的事件数量
|
||||
*/
|
||||
@Operation(summary = "内部触发核销消息Outbox到期投递")
|
||||
@Inner
|
||||
@NoToken
|
||||
@PostMapping("/inner/outbox/process-due")
|
||||
public R<Integer> processOutboxDueEvents() {
|
||||
return R.ok(messageOutboxService.processDueEvents());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理超过保留期的终态核销消息 Outbox,必须携带内部调用标识。
|
||||
*
|
||||
* @return 本轮清理的事件数量
|
||||
*/
|
||||
@Operation(summary = "内部触发核销消息Outbox过期清理")
|
||||
@Inner
|
||||
@NoToken
|
||||
@PostMapping("/inner/outbox/cleanup")
|
||||
public R<Integer> cleanupOutboxExpiredEvents() {
|
||||
return R.ok(messageOutboxService.cleanupExpiredEvents());
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描并补偿下发持久化打印任务,必须携带内部调用标识。
|
||||
*
|
||||
* @return 本轮处理的打印任务数量
|
||||
*/
|
||||
@Operation(summary = "内部触发打印任务补偿扫描")
|
||||
@Inner
|
||||
@NoToken
|
||||
@PostMapping("/inner/print/process-due")
|
||||
public R<Integer> processPrintDueTasks() {
|
||||
return R.ok(hardInvoiceService.processDueTasks());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理创建时间超过三十天的消费机日志,必须携带内部调用标识。
|
||||
*
|
||||
* @return 本轮删除的日志行数,累计数量未达阈值时为零
|
||||
*/
|
||||
@Operation(summary = "内部触发消费机日志过期清理")
|
||||
@Inner
|
||||
@NoToken
|
||||
@PostMapping("/inner/xfj-log/cleanup")
|
||||
public R<Integer> cleanupXfjLog() {
|
||||
return R.ok(hardXfjLogService.cleanupExpiredLogs());
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
package com.bsd.pjcloud.hard.job;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.bsd.pjcloud.hard.api.entity.xfj.HardXfjLogEntity;
|
||||
import com.bsd.pjcloud.hard.mapper.xfj.HardXfjLogMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
@AllArgsConstructor
|
||||
public class DeleteLogJob {
|
||||
|
||||
private final HardXfjLogMapper hardXfjLogMapper;
|
||||
|
||||
// 一天删除一次
|
||||
@Scheduled(cron = "0 0 0 * * ?")
|
||||
public void deleteXfjLog() {
|
||||
LocalDateTime before = LocalDate.now().minusDays(30).atStartOfDay();
|
||||
// 删除30天前的数据
|
||||
Long count = hardXfjLogMapper.selectCount(new LambdaQueryWrapper<HardXfjLogEntity>()
|
||||
.le(HardXfjLogEntity::getCreateTime, before)
|
||||
);
|
||||
if (count >= 10000) {
|
||||
hardXfjLogMapper.delete(new LambdaQueryWrapper<HardXfjLogEntity>()
|
||||
.le(HardXfjLogEntity::getCreateTime, before)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
package com.bsd.pjcloud.hard.job;
|
||||
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
|
||||
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
|
||||
import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 自动退款任务补偿调度器。多实例允许同时扫描,订单 Redis 锁和任务行锁负责最终串行。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HardAutoRefundTaskJob {
|
||||
|
||||
/**
|
||||
* 调度器触发间隔,单位毫秒,固定为 30 秒。
|
||||
*/
|
||||
private static final long SCAN_INTERVAL_MILLIS = 30_000L;
|
||||
|
||||
/**
|
||||
* 单轮跨租户最多扫描任务数量,避免配置和钱包服务突发压力。
|
||||
*/
|
||||
private static final int SCAN_BATCH_SIZE = 100;
|
||||
|
||||
/**
|
||||
* 自动退款任务服务。
|
||||
*/
|
||||
private final HardAutoRefundTaskService taskService;
|
||||
|
||||
/**
|
||||
* 每 30 秒扫描到期或暂停任务,并按任务持久化租户恢复 Feign 和数据库上下文。
|
||||
*/
|
||||
@Scheduled(fixedDelay = SCAN_INTERVAL_MILLIS, initialDelay = SCAN_INTERVAL_MILLIS)
|
||||
public void retryDueTasks() {
|
||||
List<HardAutoRefundTaskEntity> tasks = taskService.listScanCandidates(SCAN_BATCH_SIZE);
|
||||
for (HardAutoRefundTaskEntity task : tasks) {
|
||||
if (task.getTenantId() == null) {
|
||||
log.warn("自动退款任务缺少租户ID,taskId:{}", task.getId());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
TenantBroker.runAs(task.getTenantId(), tenantId ->
|
||||
taskService.execute(task.getId(), HardAutoRefundTriggerTypeEnum.SCHEDULED, null));
|
||||
} catch (Exception exception) {
|
||||
log.warn("自动退款调度执行异常,taskId:{},tenantId:{},exceptionType:{}", task.getId(), task.getTenantId(),
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
package com.bsd.pjcloud.hard.job;
|
||||
|
||||
import com.bsd.pjcloud.hard.service.HardCoffeeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 咖啡机补偿调度器: 重试通知失败的支付结果回调(callback)和退款同步缺口(refund)。
|
||||
* 单轮批量受限,失败订单按 wuli_notify_count 上限熔断,超过后需人工在管理端介入。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HardCoffeeNotifyJob {
|
||||
|
||||
/**
|
||||
* 调度间隔,单位毫秒,固定为 60 秒
|
||||
*/
|
||||
private static final long SCAN_INTERVAL_MILLIS = 60_000L;
|
||||
|
||||
private final HardCoffeeService hardCoffeeService;
|
||||
|
||||
/**
|
||||
* 定时扫描通知失败与退款同步缺口的咖啡机订单
|
||||
*/
|
||||
@Scheduled(fixedDelay = SCAN_INTERVAL_MILLIS, initialDelay = SCAN_INTERVAL_MILLIS)
|
||||
public void retryNotifyFailures() {
|
||||
try {
|
||||
hardCoffeeService.retryNotifyFailures();
|
||||
} catch (Exception e) {
|
||||
log.error("咖啡机补偿调度异常", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
package com.bsd.pjcloud.hard.job;
|
||||
|
||||
import com.bsd.pjcloud.hard.service.HardMessageOutboxService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Hardware核销消息Outbox调度器,周期触发投递恢复和保留期清理。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HardMessageOutboxJob {
|
||||
|
||||
/** Hardware核销Outbox处理服务。 */
|
||||
private final HardMessageOutboxService outboxService;
|
||||
|
||||
/**
|
||||
* 每十秒扫描一次到期事件,实际并发领取由Outbox条件更新控制。
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10000L, initialDelay = 15000L)
|
||||
public void processDueEvents() {
|
||||
try {
|
||||
int processed = outboxService.processDueEvents();
|
||||
if (processed > 0) {
|
||||
log.info("核销消息Outbox扫描完成,processed:{}", processed);
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.error("核销消息Outbox扫描异常", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日凌晨分批清理超过保留期的终态Outbox。
|
||||
*/
|
||||
@Scheduled(cron = "0 30 2 * * ?")
|
||||
public void cleanupExpiredEvents() {
|
||||
try {
|
||||
int deleted = outboxService.cleanupExpiredEvents();
|
||||
if (deleted > 0) {
|
||||
log.info("核销消息Outbox到期清理完成,deleted:{}", deleted);
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.error("核销消息Outbox到期清理异常", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
package com.bsd.pjcloud.hard.job;
|
||||
|
||||
import com.bsd.pjcloud.hard.service.HardInvoiceService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 持久化打印任务补偿调度器,恢复进程退出或 MQTT 离线期间遗留的任务。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HardPrintTaskJob {
|
||||
|
||||
/** 打印任务服务。 */
|
||||
private final HardInvoiceService hardInvoiceService;
|
||||
|
||||
/**
|
||||
* 每十秒扫描待生成任务,并按固定间隔补偿下发待打印任务。
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10000L, initialDelay = 20000L)
|
||||
public void processDueTasks() {
|
||||
try {
|
||||
int processed = hardInvoiceService.processDueTasks();
|
||||
if (processed > 0) log.info("打印任务补偿扫描完成,processed:{}", processed);
|
||||
} catch (Exception exception) {
|
||||
log.error("打印任务补偿扫描异常", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-26
@@ -1,4 +1,4 @@
|
||||
package com.bsd.pjcloud.hard.job;
|
||||
package com.bsd.pjcloud.hard.service;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
@@ -6,26 +6,27 @@ import com.bsd.pjcloud.common.data.tenant.TenantBroker;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
|
||||
import com.bsd.pjcloud.hard.service.HardAggregatePayPollingCoordinator;
|
||||
import com.bsd.pjcloud.hard.service.HardOrderService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 消费机聚合订单码支付业务结算补偿任务。
|
||||
* 消费机聚合订单码支付业务结算补偿编排器(原 HardAggregateOrderPayJob 逻辑平移)。
|
||||
*
|
||||
* <p>终端主动轮询仍是实时结算入口;本任务负责覆盖收银弹窗提前关闭、终端断网或进程退出后,
|
||||
* 第三方已经支付但 Hardware 业务订单尚未结算的场景。</p>
|
||||
* <p>终端主动轮询仍是实时结算入口;本编排器负责覆盖收银弹窗提前关闭、终端断网或进程退出后,
|
||||
* 第三方已经支付但 Hardware 业务订单尚未结算的场景。由 daemon-quartz 经
|
||||
* HardScheduleInnerController 内部端点触发,不再依赖本地 {@code @Scheduled}。</p>
|
||||
*
|
||||
* <p>逐单结算必须经 {@link HardOrderService} 代理调用(目标方法带 {@code @Lock4j} 与事务),
|
||||
* 因此编排逻辑独立成组件而不并入 HardOrderServiceImpl,避免自调用丢失代理语义。</p>
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HardAggregateOrderPayJob {
|
||||
public class HardAggregateOrderPayCompensator {
|
||||
|
||||
/**
|
||||
* 单轮最多处理的待结算订单数,避免跨服务查单长期占用调度线程。
|
||||
@@ -37,16 +38,6 @@ public class HardAggregateOrderPayJob {
|
||||
*/
|
||||
private static final long ACTIVE_ORDER_WINDOW_MINUTES = 15L;
|
||||
|
||||
/**
|
||||
* 超出近期窗口但尚未达到自动取消时间的订单低频核对间隔,单位毫秒。
|
||||
*/
|
||||
private static final long STALE_RECONCILIATION_INTERVAL_MILLIS = 300000L;
|
||||
|
||||
/**
|
||||
* 服务启动后首次核对遗留订单的等待时间,单位毫秒,避免与启动恢复扫描同时争用资源。
|
||||
*/
|
||||
private static final long STALE_INITIAL_DELAY_MILLIS = 60000L;
|
||||
|
||||
/**
|
||||
* Hardware 订单服务,复用带订单锁和事务的聚合支付状态确认及结算流程。
|
||||
*/
|
||||
@@ -60,12 +51,10 @@ public class HardAggregateOrderPayJob {
|
||||
/**
|
||||
* 高频补偿最近十五分钟内仍处于创建状态且已绑定聚合会话的 Hardware 订单。
|
||||
*
|
||||
* <p>调度线程无租户上下文时先跨租户读取候选记录,再按记录持久化的租户 ID 恢复上下文;
|
||||
* 具体结算继续由业务服务的分布式订单锁和状态条件保证幂等。方法每五秒轻量触发一次,连续空结果时
|
||||
* 真正的数据库扫描按十秒、二十秒、四十秒退避到最长一分钟。</p>
|
||||
* <p>daemon-quartz 每五秒触发一次内部端点调用本方法;调度线程无租户上下文时先跨租户读取候选记录,
|
||||
* 再按记录持久化的租户 ID 恢复上下文;具体结算继续由业务服务的分布式订单锁和状态条件保证幂等。
|
||||
* 连续空结果时真正的数据库扫描按十秒、二十秒、四十秒退避到最长一分钟,未到扫描时间的实例直接返回。</p>
|
||||
*/
|
||||
@Scheduled(fixedDelay = HardAggregatePayPollingCoordinator.TRIGGER_INTERVAL_MILLIS,
|
||||
initialDelay = HardAggregatePayPollingCoordinator.TRIGGER_INTERVAL_MILLIS)
|
||||
public void settlePendingAggregateOrders() {
|
||||
if (!pollingCoordinator.tryStartPolling()) {
|
||||
return;
|
||||
@@ -86,11 +75,11 @@ public class HardAggregateOrderPayJob {
|
||||
/**
|
||||
* 低频核对十五分钟至一小时内的遗留订单,并取消超过一小时的订单。
|
||||
*
|
||||
* <p>一小时以内仍保留服务异常恢复能力;超过一小时后不再调用 Thirdparty 查单,而是把仍处于创建状态
|
||||
* <p>daemon-quartz 每五分钟触发一次内部端点调用本方法。一小时以内仍保留服务异常恢复能力;
|
||||
* 超过一小时后不再调用 Thirdparty 查单,而是把仍处于创建状态
|
||||
* 的 Hardware 订单原子更新为未支付取消,避免异常记录被永久轮询。第三方若在此后报告延迟支付,
|
||||
* 由线下渠道核对和协商,不再自动改变 Hardware 库存及订单状态。</p>
|
||||
*/
|
||||
@Scheduled(fixedDelay = STALE_RECONCILIATION_INTERVAL_MILLIS, initialDelay = STALE_INITIAL_DELAY_MILLIS)
|
||||
public void settleStaleAggregateOrders() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime activeSince = now.minusMinutes(ACTIVE_ORDER_WINDOW_MINUTES);
|
||||
+11
@@ -153,4 +153,15 @@ public interface HardAutoRefundTaskService extends IService<HardAutoRefundTaskEn
|
||||
* @return 待复查任务列表
|
||||
*/
|
||||
List<HardAutoRefundTaskEntity> listScanCandidates(int limit);
|
||||
|
||||
/**
|
||||
* daemon-quartz 定时补偿入口:跨租户扫描到期或暂停任务,并按任务持久化租户恢复上下文后逐个执行。
|
||||
*
|
||||
* <p>多实例或集群重复触发安全,订单 Redis 锁和任务行锁负责最终串行;单条执行失败只记日志,
|
||||
* 不中断其余任务,也不使整轮补偿判定为失败。</p>
|
||||
*
|
||||
* @param limit 单轮最多扫描任务数量,避免配置和钱包服务突发压力
|
||||
* @return 本轮扫描到并尝试执行的任务数量
|
||||
*/
|
||||
int retryDueTasks(int limit);
|
||||
}
|
||||
+9
@@ -10,4 +10,13 @@ public interface HardXfjLogService extends IService<HardXfjLogEntity> {
|
||||
|
||||
Page<HardXfjLogVo> pageList(Page page, HardXfjLogDto dto);
|
||||
|
||||
/**
|
||||
* 清理创建时间超过三十天的消费机日志(原 DeleteLogJob 逻辑平移)。
|
||||
*
|
||||
* <p>超期行数累计不足一万条时跳过删除,避免天天执行小批量清理;由 daemon-quartz 每日零点
|
||||
* 经 HardScheduleInnerController 内部端点触发,条件删除重复执行安全。</p>
|
||||
*
|
||||
* @return 本轮删除的日志行数,未达删除阈值时为零
|
||||
*/
|
||||
int cleanupExpiredLogs();
|
||||
}
|
||||
+91
-39
@@ -293,7 +293,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
if (StrUtil.isNotBlank(walletTypeName)) renderer.keyValue("钱包类型", walletTypeName);
|
||||
appendVoucherUser(renderer, user);
|
||||
appendInvoiceOrderBlock(renderer, order);
|
||||
Map<Long, String> printNames = loadPrintProductNames(carList);
|
||||
Map<Long, PrintProductName> printNames = loadPrintProductNames(carList);
|
||||
appendInvoiceProducts(renderer, carList, metadata, showComboItems, printNames);
|
||||
if (order.getExtraPrice() != null && order.getExtraPrice() != 0) {
|
||||
renderer.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
|
||||
@@ -330,8 +330,8 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
/**
|
||||
* 按兑换券商品卡片版式渲染发票商品明细,数量支持份数与称重重量。
|
||||
*
|
||||
* <p>发票是支付凭证,小计使用下单时的商品快照总额;商品名称打印时实时按套餐名优先解析,
|
||||
* 套餐标签与组合内容沿用兑换券元数据和显示开关。</p>
|
||||
* <p>发票是支付凭证,小计使用下单时的商品快照总额;商品名称打印时实时解析为
|
||||
* 套餐名主行加商品名小字副行,套餐标签与组合内容沿用兑换券元数据和显示开关。</p>
|
||||
*
|
||||
* @param renderer 当前渲染器
|
||||
* @param cars 订单商品快照
|
||||
@@ -341,7 +341,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
*/
|
||||
private static void appendInvoiceProducts(ReceiptRenderer renderer, List<HardCarVo> cars,
|
||||
Map<Long, ProductMetadata> metadata, boolean showComboItems,
|
||||
Map<Long, String> printNames) {
|
||||
Map<Long, PrintProductName> printNames) {
|
||||
renderer.divider().sectionTitle("商品明细");
|
||||
boolean hasProduct = false;
|
||||
for (HardCarVo car : cars) {
|
||||
@@ -351,8 +351,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
boolean combo = product != null && product.combo();
|
||||
List<String> comboItems = combo && showComboItems
|
||||
? componentItemTexts(product.items(), invoiceComboQuantity(car)) : List.of();
|
||||
renderer.voucherProduct(resolvePrintName(car, printNames), invoiceQuantityText(car), money(voucherUnitPrice(car)),
|
||||
money(car.getTotalPrice()), combo, comboItems);
|
||||
PrintProductName printName = resolvePrintName(car, printNames);
|
||||
renderer.voucherProduct(printName.mainName(), printName.subName(), invoiceQuantityText(car),
|
||||
money(voucherUnitPrice(car)), money(car.getTotalPrice()), combo, comboItems);
|
||||
}
|
||||
if (!hasProduct) renderer.item("无商品明细", "x0", false, false);
|
||||
}
|
||||
@@ -951,7 +952,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
carsPerOrder.add(cars);
|
||||
allCars.addAll(cars);
|
||||
}
|
||||
Map<Long, String> printNames = loadPrintProductNames(allCars);
|
||||
Map<Long, PrintProductName> printNames = loadPrintProductNames(allCars);
|
||||
List<VoucherOrderProducts> result = new ArrayList<>(voucherOrders.size());
|
||||
for (int index = 0; index < voucherOrders.size(); index++) {
|
||||
HardOrderEntity voucherOrder = voucherOrders.get(index);
|
||||
@@ -964,7 +965,8 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
// 历史购物车允许商品 ID 为空,Map.of() 不接受空键查询,因此空 ID 直接按单品降级。
|
||||
ProductMetadata product = car.getProductId() == null ? null : metadata.get(car.getProductId());
|
||||
boolean combo = product != null && product.combo();
|
||||
products.add(new VoucherProductLine(car.getProductId(), resolvePrintName(car, printNames), count,
|
||||
PrintProductName printName = resolvePrintName(car, printNames);
|
||||
products.add(new VoucherProductLine(car.getProductId(), printName.comboName(), printName.name(), count,
|
||||
voucherUnitPrice(car), voucherSubtotal(car), combo,
|
||||
combo && showComboItems ? product.items() : List.of()));
|
||||
}
|
||||
@@ -1018,11 +1020,33 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
*/
|
||||
private static void appendVoucherProducts(ReceiptRenderer renderer, List<VoucherProductLine> products) {
|
||||
for (VoucherProductLine product : products) {
|
||||
renderer.voucherProduct(product.productName(), product.quantity(), money(product.unitPrice()),
|
||||
money(product.subtotal()), product.combo(), voucherComponentItems(product));
|
||||
renderer.voucherProduct(printMainName(product), printSubName(product), product.quantity(),
|
||||
money(product.unitPrice()), money(product.subtotal()), product.combo(),
|
||||
voucherComponentItems(product));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算商品行主名:套餐名优先,为空时商品名沿用主行大字样式。
|
||||
*
|
||||
* @param product 当前兑换券商品
|
||||
* @return 卡片主行名称
|
||||
*/
|
||||
private static String printMainName(VoucherProductLine product) {
|
||||
return StrUtil.isNotBlank(product.comboName()) ? product.comboName() : product.name();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算商品行副名:仅有套餐名且与商品名不同时输出商品名小字副行,相同则不重复显示。
|
||||
*
|
||||
* @param product 当前兑换券商品
|
||||
* @return 卡片副行名称;无需副行时返回 null
|
||||
*/
|
||||
private static String printSubName(VoucherProductLine product) {
|
||||
return StrUtil.isNotBlank(product.comboName()) && !product.comboName().equals(product.name())
|
||||
? product.name() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将套餐组成快照转换为票据说明,并按套餐商品的剩余总份数放大组成数量。
|
||||
*
|
||||
@@ -1053,7 +1077,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
/**
|
||||
* 合并多订单中业务快照完全一致的商品,并保持商品第一次出现的顺序。
|
||||
*
|
||||
* <p>商品 ID、快照名称、单价和套餐类型共同作为合并键,避免同一菜品改价或改名后被错误归并;
|
||||
* <p>商品 ID、套餐名、商品名、单价和套餐类型共同作为合并键,避免同一菜品改价或改名后被错误归并;
|
||||
* 历史商品 ID 为空时,自然按名称、单价和套餐类型合并。数量与小计均使用各原始行求和。</p>
|
||||
*
|
||||
* @param orderProducts 按订单分组的商品快照
|
||||
@@ -1063,8 +1087,8 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
Map<VoucherProductKey, VoucherProductLine> merged = new LinkedHashMap<>();
|
||||
for (VoucherOrderProducts group : orderProducts) {
|
||||
for (VoucherProductLine product : group.products()) {
|
||||
VoucherProductKey key = new VoucherProductKey(product.productId(), product.productName(),
|
||||
product.unitPrice(), product.combo());
|
||||
VoucherProductKey key = new VoucherProductKey(product.productId(), product.comboName(),
|
||||
product.name(), product.unitPrice(), product.combo());
|
||||
VoucherProductLine current = merged.get(key);
|
||||
if (current == null) {
|
||||
merged.put(key, product);
|
||||
@@ -1072,7 +1096,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
}
|
||||
List<OnlineSpuComboItemVO> comboItems = current.comboItems().isEmpty()
|
||||
? product.comboItems() : current.comboItems();
|
||||
merged.put(key, new VoucherProductLine(current.productId(), current.productName(),
|
||||
merged.put(key, new VoucherProductLine(current.productId(), current.comboName(), current.name(),
|
||||
current.quantity() + product.quantity(), current.unitPrice(),
|
||||
current.subtotal() + product.subtotal(), current.combo(), comboItems));
|
||||
}
|
||||
@@ -1210,27 +1234,27 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量加载打印凭证商品名称:食堂商品套餐名(comboName)优先、商品名(name)兜底。
|
||||
* 批量加载打印凭证商品名称:食堂商品套餐名(comboName)与商品名(name)分别保留。
|
||||
*
|
||||
* <p>商品 ID 兼容 canteen_spu.id(线下/每日菜谱单)和 canteen_online_spu.id(App 线上预约单,
|
||||
* 服务端经 linkSpuId 关联取名);非食堂商品、已删除商品或名称全为空的记录不进入结果,
|
||||
* 调用方降级购物车快照名。打印是核销后的关键闭环,查询失败时记录警告并整体降级,不阻断出票。</p>
|
||||
*
|
||||
* @param cars 票据涉及的商品快照
|
||||
* @return 按商品 ID 索引的打印名称;查询失败时返回空 Map
|
||||
* @return 按商品 ID 索引的双名结构;查询失败时返回空 Map
|
||||
*/
|
||||
private Map<Long, String> loadPrintProductNames(List<HardCarVo> cars) {
|
||||
private Map<Long, PrintProductName> loadPrintProductNames(List<HardCarVo> cars) {
|
||||
List<Long> productIds = cars.stream().map(HardCarVo::getProductId).filter(Objects::nonNull).distinct().toList();
|
||||
if (productIds.isEmpty()) return Map.of();
|
||||
try {
|
||||
R<List<SpuPrintNameVo>> result = remoteCanteenSpuService.getInnerSpuPrintNames(productIds);
|
||||
if (result == null || !result.isOk() || result.getData() == null) return Map.of();
|
||||
Map<Long, String> names = new LinkedHashMap<>();
|
||||
Map<Long, PrintProductName> names = new LinkedHashMap<>();
|
||||
for (SpuPrintNameVo vo : result.getData()) {
|
||||
if (vo == null || vo.getProductId() == null) continue;
|
||||
String comboName = StrUtil.trimToNull(vo.getComboName());
|
||||
String printName = comboName != null ? comboName : StrUtil.trimToNull(vo.getName());
|
||||
if (printName != null) names.put(vo.getProductId(), printName);
|
||||
PrintProductName printName = new PrintProductName(
|
||||
StrUtil.trimToNull(vo.getComboName()), StrUtil.trimToNull(vo.getName()));
|
||||
if (printName.mainName() != null) names.put(vo.getProductId(), printName);
|
||||
}
|
||||
return names;
|
||||
} catch (Exception exception) {
|
||||
@@ -1240,15 +1264,41 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单个购物车行的打印名称:命中实时名称时使用套餐名优先的结果,否则降级下单快照名。
|
||||
* 解析单个购物车行的打印名称:命中实时名称时保留套餐名/商品名双字段,否则降级下单快照名。
|
||||
*
|
||||
* @param car 订单商品快照;历史数据允许商品 ID 为空
|
||||
* @param printNames 按商品 ID 索引的实时打印名称
|
||||
* @return 该商品打印在票据上的名称
|
||||
* @return 该商品的主/副打印名称结构
|
||||
*/
|
||||
private static String resolvePrintName(HardCarVo car, Map<Long, String> printNames) {
|
||||
if (car.getProductId() == null) return car.getProductName();
|
||||
return printNames.getOrDefault(car.getProductId(), car.getProductName());
|
||||
private static PrintProductName resolvePrintName(HardCarVo car, Map<Long, PrintProductName> printNames) {
|
||||
PrintProductName live = car.getProductId() == null ? null : printNames.get(car.getProductId());
|
||||
return live != null ? live : new PrintProductName(null, StrUtil.trimToNull(car.getProductName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印凭证商品名称:套餐名(comboName)优先、商品名(name)兜底。
|
||||
*
|
||||
* <p>主行取套餐名,套餐名为空时商品名沿用主行大字样式;副行仅在有套餐名且与商品名不同时
|
||||
* 输出商品名小字,两个名称完全相同则不重复显示。</p>
|
||||
*
|
||||
* @param comboName 套餐名;可为空
|
||||
* @param name 商品名称;可为空
|
||||
*/
|
||||
private record PrintProductName(String comboName, String name) {
|
||||
|
||||
/**
|
||||
* @return 卡片主行名称;两者皆空时返回 null,由渲染器兜底“未命名商品”
|
||||
*/
|
||||
String mainName() {
|
||||
return comboName != null ? comboName : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 主行下方的小字副行名称;无套餐名或与商品名相同时返回 null
|
||||
*/
|
||||
String subName() {
|
||||
return comboName != null && !comboName.equals(name) ? name : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1484,26 +1534,28 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
* 可直接绘制或参与多订单聚合的兑换券商品行。
|
||||
*
|
||||
* @param productId 商品主键;历史快照允许为空
|
||||
* @param productName 打印名称:套餐名实时优先,未命中时为下单商品名称快照
|
||||
* @param comboName 套餐名实时结果;未命中实时查询或单品为空
|
||||
* @param name 商品名称;未命中实时查询时为下单快照名
|
||||
* @param quantity 剩余可履约数量
|
||||
* @param unitPrice 下单时单价快照,单位分
|
||||
* @param subtotal 当前商品行小计,单位分
|
||||
* @param combo 是否为组合商品
|
||||
* @param comboItems 套餐组成快照;显示开关关闭时为空
|
||||
*/
|
||||
record VoucherProductLine(Long productId, String productName, int quantity, long unitPrice, long subtotal,
|
||||
boolean combo, List<OnlineSpuComboItemVO> comboItems) {
|
||||
record VoucherProductLine(Long productId, String comboName, String name, int quantity, long unitPrice,
|
||||
long subtotal, boolean combo, List<OnlineSpuComboItemVO> comboItems) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 多订单商品合并键。
|
||||
*
|
||||
* @param productId 商品主键;为空时依靠其余快照字段区分
|
||||
* @param productName 商品名称快照
|
||||
* @param comboName 套餐名;为空时依靠商品名区分
|
||||
* @param name 商品名称
|
||||
* @param unitPrice 商品单价快照,单位分
|
||||
* @param combo 是否为组合商品
|
||||
*/
|
||||
private record VoucherProductKey(Long productId, String productName, long unitPrice, boolean combo) {
|
||||
private record VoucherProductKey(Long productId, String comboName, String name, long unitPrice, boolean combo) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1578,18 +1630,18 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReceiptRenderer voucherProduct(String name, int quantity, String unitPrice, String subtotal,
|
||||
boolean combo, List<String> componentItems) {
|
||||
image.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems);
|
||||
command.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems);
|
||||
public ReceiptRenderer voucherProduct(String name, String subName, int quantity, String unitPrice,
|
||||
String subtotal, boolean combo, List<String> componentItems) {
|
||||
image.voucherProduct(name, subName, quantity, unitPrice, subtotal, combo, componentItems);
|
||||
command.voucherProduct(name, subName, quantity, unitPrice, subtotal, combo, componentItems);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReceiptRenderer voucherProduct(String name, String quantity, String unitPrice, String subtotal,
|
||||
boolean combo, List<String> componentItems) {
|
||||
image.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems);
|
||||
command.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems);
|
||||
public ReceiptRenderer voucherProduct(String name, String subName, String quantity, String unitPrice,
|
||||
String subtotal, boolean combo, List<String> componentItems) {
|
||||
image.voucherProduct(name, subName, quantity, unitPrice, subtotal, combo, componentItems);
|
||||
command.voucherProduct(name, subName, quantity, unitPrice, subtotal, combo, componentItems);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -12,6 +12,7 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
|
||||
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
|
||||
import com.bsd.pjcloud.common.core.util.BeanUtils;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
|
||||
@@ -1586,6 +1587,35 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* daemon-quartz 定时补偿入口(原 HardAutoRefundTaskJob 逻辑平移):跨租户扫描到期或暂停任务,
|
||||
* 按任务持久化租户恢复 Feign 和数据库上下文后逐个执行。
|
||||
*
|
||||
* <p>本方法不开启事务,逐条执行的锁与事务边界由 {@link #execute} 及其内部的
|
||||
* {@code getSelf()} 代理调用自行管理;集群重复触发由订单 Redis 锁和任务行锁最终串行。</p>
|
||||
*
|
||||
* @param limit 单轮最多扫描任务数量,避免配置和钱包服务突发压力
|
||||
* @return 本轮扫描到并尝试执行的任务数量
|
||||
*/
|
||||
@Override
|
||||
public int retryDueTasks(int limit) {
|
||||
List<HardAutoRefundTaskEntity> tasks = listScanCandidates(limit);
|
||||
for (HardAutoRefundTaskEntity task : tasks) {
|
||||
if (task.getTenantId() == null) {
|
||||
log.warn("自动退款任务缺少租户ID,taskId:{}", task.getId());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
TenantBroker.runAs(task.getTenantId(), tenantId ->
|
||||
execute(task.getId(), HardAutoRefundTriggerTypeEnum.SCHEDULED, null));
|
||||
} catch (Exception exception) {
|
||||
log.warn("自动退款调度执行异常,taskId:{},tenantId:{},exceptionType:{}", task.getId(), task.getTenantId(),
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
return tasks.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 Wallet 和尝试日志共同使用的稳定退款业务号。
|
||||
*
|
||||
|
||||
+25
-1
@@ -1,5 +1,6 @@
|
||||
package com.bsd.pjcloud.hard.service.impl.xfj;
|
||||
|
||||
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.hard.api.dto.xfj.HardXfjLogDto;
|
||||
@@ -12,6 +13,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 消费机日志
|
||||
*/
|
||||
@@ -26,4 +30,24 @@ public class HardXfjLogServiceImpl extends ServiceImpl<HardXfjLogMapper, HardXfj
|
||||
return baseMapper.pageList(page, dto);
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* 清理创建时间超过三十天的消费机日志。
|
||||
*
|
||||
* <p>先统计超期行数,累计不足一万条时跳过删除,避免每天小批量清理;达到阈值后按同一时间条件
|
||||
* 物理删除。统计与删除之间存在窗口,行数略有出入不影响正确性;重复触发时第二次删除自然为空。</p>
|
||||
*
|
||||
* @return 本轮删除的日志行数,未达删除阈值时为零
|
||||
*/
|
||||
@Override
|
||||
public int cleanupExpiredLogs() {
|
||||
LocalDateTime before = LocalDate.now().minusDays(30).atStartOfDay();
|
||||
// 删除30天前的数据;低于阈值时直接跳过
|
||||
Long count = count(new LambdaQueryWrapper<HardXfjLogEntity>().le(HardXfjLogEntity::getCreateTime, before));
|
||||
if (count >= 10000) {
|
||||
return baseMapper.delete(new LambdaQueryWrapper<HardXfjLogEntity>()
|
||||
.le(HardXfjLogEntity::getCreateTime, before));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
}
|
||||
+38
-4
@@ -35,7 +35,7 @@ class EscPosReceiptCommandTest {
|
||||
.title("预约核销兑换券")
|
||||
.banner("第 1 次打印 · 2 笔预约")
|
||||
.keyValue("商户", "青鸟同文")
|
||||
.voucherProduct("A套餐-糖醋排骨", 2, "12.00", "24.00", true, List.of("糖醋排骨 x2"))
|
||||
.voucherProduct("A套餐-糖醋排骨", null, 2, "12.00", "24.00", true, List.of("糖醋排骨 x2"))
|
||||
.voucherTotal("24.00")
|
||||
.toBase64();
|
||||
byte[] bytes = java.util.Base64.getDecoder().decode(base64);
|
||||
@@ -167,7 +167,7 @@ class EscPosReceiptCommandTest {
|
||||
@Test
|
||||
void voucherProductRendersBoxFrameWithReverseTypeTag() {
|
||||
byte[] bytes = decode(new EscPosReceiptCommand()
|
||||
.voucherProduct("A套餐-糖醋排骨", "x2", "12.00", "24.00", true, List.of("糖醋排骨 x2")));
|
||||
.voucherProduct("A套餐-糖醋排骨", null, "x2", "12.00", "24.00", true, List.of("糖醋排骨 x2")));
|
||||
List<String> lines = visibleLines(bytes);
|
||||
|
||||
assertTrue(contains(bytes, 0x1D, 0x42, 1), "组合类型标签应使用 GS B 反色指令");
|
||||
@@ -185,6 +185,40 @@ class EscPosReceiptCommandTest {
|
||||
assertTrue(lines.stream().anyMatch(line -> line.contains("组合内容")), "组合内容标题缺失");
|
||||
}
|
||||
|
||||
/** 套餐名与商品名双行:主名保持倍宽加粗,副行商品名单倍宽输出在价格行之前。 */
|
||||
@Test
|
||||
void voucherProductSubNameRendersPlainLineInsideBox() {
|
||||
byte[] bytes = decode(new EscPosReceiptCommand()
|
||||
.voucherProduct("A套餐", "A套餐-红烧肉", "x2", "12.00", "24.00", true, List.of()));
|
||||
List<String> lines = visibleLines(bytes);
|
||||
|
||||
assertTrue(contains(bytes, 0x1D, 0x21, 0x11), "主名仍应倍宽放大");
|
||||
assertTrue(lines.stream().anyMatch(line -> line.contains("A套餐") && line.startsWith("│")),
|
||||
"倍宽主名应在卡片框内");
|
||||
int mainIndex = -1;
|
||||
int subIndex = -1;
|
||||
for (int index = 0; index < lines.size(); index++) {
|
||||
if (lines.get(index).contains("红烧肉")) subIndex = index;
|
||||
if (lines.get(index).contains("单价")) mainIndex = index;
|
||||
}
|
||||
assertTrue(subIndex >= 0, "副行商品名必须输出");
|
||||
assertTrue(mainIndex > subIndex, "副行应位于主名与价格行之间");
|
||||
assertTrue(lines.get(subIndex).startsWith("│") && lines.get(subIndex).endsWith("│"),
|
||||
"副行应在卡片框内以单倍宽展示");
|
||||
}
|
||||
|
||||
/** 无套餐名时商品名沿用倍宽主名样式,不输出多余副行。 */
|
||||
@Test
|
||||
void voucherProductWithoutSubNameKeepsSingleScaledName() {
|
||||
byte[] bytes = decode(new EscPosReceiptCommand()
|
||||
.voucherProduct("红糖发糕", null, "x1", "1.60", "1.60", false, List.of()));
|
||||
List<String> lines = visibleLines(bytes);
|
||||
|
||||
assertTrue(contains(bytes, 0x1D, 0x21, 0x11), "商品名应保持倍宽主名样式");
|
||||
assertEquals(1, lines.stream().filter(line -> line.contains("红糖发糕")).count(),
|
||||
"无副行时商品名只出现一次");
|
||||
}
|
||||
|
||||
/** 显示宽度与折行:中文 2 列、ASCII 1 列,单个超宽字符仍独占一行。 */
|
||||
@Test
|
||||
void displayWidthAndWrapFollowChineseAwareColumns() {
|
||||
@@ -214,8 +248,8 @@ class EscPosReceiptCommandTest {
|
||||
.keyValue("订单类型", "线下购买")
|
||||
.keyValue("下单时间", "2026-09-14 12:03:01")
|
||||
.divider().sectionTitle("商品明细")
|
||||
.voucherProduct("A套餐-糖醋排骨", "x1", "17.00", "17.00", true, List.of("糖醋排骨 x1", "米饭 x1"))
|
||||
.voucherProduct("红糖发糕", "x1", "1.60", "1.60", false, List.of())
|
||||
.voucherProduct("A套餐-糖醋排骨", null, "x1", "17.00", "17.00", true, List.of("糖醋排骨 x1", "米饭 x1"))
|
||||
.voucherProduct("红糖发糕", null, "x1", "1.60", "1.60", false, List.of())
|
||||
.keyValue("额外支付金额", "¥0.50")
|
||||
.voucherTotal("18.60")
|
||||
.keyValue("打印时间", "2026-09-14 12:03:05");
|
||||
|
||||
+28
-16
@@ -39,7 +39,7 @@ class ThermalReceiptImageTest {
|
||||
.title("发票").invoiceItem("独立单品", "2", "¥12.00").toPngBytes()));
|
||||
BufferedImage voucher = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
|
||||
.title("兑换券").banner("第 1 次打印")
|
||||
.voucherProduct("独立单品", 2, "6.00", "12.00", false, List.of()).toPngBytes()));
|
||||
.voucherProduct("独立单品", null, 2, "6.00", "12.00", false, List.of()).toPngBytes()));
|
||||
|
||||
assertEquals(576, invoice.getWidth());
|
||||
assertEquals(576, voucher.getWidth());
|
||||
@@ -65,9 +65,9 @@ class ThermalReceiptImageTest {
|
||||
String longComponentName = "组合内容中的超长菜品名称".repeat(10) + " x12";
|
||||
ThermalReceiptImage receipt = new ThermalReceiptImage()
|
||||
.title("预约核销兑换券")
|
||||
.voucherProduct(longProductName, 2, "12.00", "24.00", true,
|
||||
.voucherProduct(longProductName, null, 2, "12.00", "24.00", true,
|
||||
List.of(longComponentName, "红糖发糕 x4"))
|
||||
.voucherProduct("独立单品", 3, "2.50", "7.50", false, List.of())
|
||||
.voucherProduct("独立单品", null, 3, "2.50", "7.50", false, List.of())
|
||||
.voucherTotal("31.50");
|
||||
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(receipt.toPngBytes()));
|
||||
@@ -84,8 +84,8 @@ class ThermalReceiptImageTest {
|
||||
void invoiceProductCardAcceptsWeightQuantityText() throws Exception {
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
|
||||
.title("智慧食堂发票")
|
||||
.voucherProduct("精品肋排", "0.5/kg", "36.00", "18.00", false, List.of())
|
||||
.voucherProduct("A套餐-糖醋排骨", "x2", "12.00", "24.00", true, List.of("糖醋排骨 x2"))
|
||||
.voucherProduct("精品肋排", null, "0.5/kg", "36.00", "18.00", false, List.of())
|
||||
.voucherProduct("A套餐-糖醋排骨", null, "x2", "12.00", "24.00", true, List.of("糖醋排骨 x2"))
|
||||
.voucherTotal("42.00").toPngBytes()));
|
||||
|
||||
assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, image.getWidth());
|
||||
@@ -113,9 +113,9 @@ class ThermalReceiptImageTest {
|
||||
@Test
|
||||
void comboProductCardHasStrongerVisualWeightThanSingleProductCard() throws Exception {
|
||||
BufferedImage combo = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
|
||||
.voucherProduct("测试商品", 1, "10.00", "10.00", true, List.of()).toPngBytes()));
|
||||
.voucherProduct("测试商品", null, 1, "10.00", "10.00", true, List.of()).toPngBytes()));
|
||||
BufferedImage single = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
|
||||
.voucherProduct("测试商品", 1, "10.00", "10.00", false, List.of()).toPngBytes()));
|
||||
.voucherProduct("测试商品", null, 1, "10.00", "10.00", false, List.of()).toPngBytes()));
|
||||
|
||||
assertTrue(countDarkPixels(combo) > countDarkPixels(single),
|
||||
"组合商品的黑底标签和粗边框应产生更多深色像素");
|
||||
@@ -125,15 +125,27 @@ class ThermalReceiptImageTest {
|
||||
@Test
|
||||
void comboProductCardOmitsEntireComponentSectionWhenItemsAreHidden() throws Exception {
|
||||
BufferedImage hidden = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
|
||||
.voucherProduct("测试套餐", 1, "10.00", "10.00", true, List.of()).toPngBytes()));
|
||||
.voucherProduct("测试套餐", null, 1, "10.00", "10.00", true, List.of()).toPngBytes()));
|
||||
BufferedImage visible = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
|
||||
.voucherProduct("测试套餐", 1, "10.00", "10.00", true,
|
||||
.voucherProduct("测试套餐", null, 1, "10.00", "10.00", true,
|
||||
List.of("糖醋排骨 x1", "红糖发糕 x1")).toPngBytes()));
|
||||
|
||||
assertTrue(hidden.getHeight() < visible.getHeight(),
|
||||
"隐藏套餐组成时不应保留分隔线、组合内容标题或明细列表的空白高度");
|
||||
}
|
||||
|
||||
/** 套餐名与商品名双行显示:主名保持大字,副行商品名以小字增加卡片高度。 */
|
||||
@Test
|
||||
void voucherProductSubNameRendersExtraSmallLine() throws Exception {
|
||||
BufferedImage single = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
|
||||
.voucherProduct("A套餐", null, 1, "10.00", "10.00", true, List.of()).toPngBytes()));
|
||||
BufferedImage dual = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
|
||||
.voucherProduct("A套餐", "A套餐-红烧肉", 1, "10.00", "10.00", true, List.of()).toPngBytes()));
|
||||
|
||||
assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, dual.getWidth());
|
||||
assertTrue(dual.getHeight() > single.getHeight(), "副行商品名小字应增加卡片高度");
|
||||
}
|
||||
|
||||
/** 多订单合并版式应完整绘制每笔订单摘要和一份合并商品区。 */
|
||||
@Test
|
||||
void mergedVoucherLayoutDrawsCompactOrderSummariesAndProducts() throws Exception {
|
||||
@@ -145,7 +157,7 @@ class ThermalReceiptImageTest {
|
||||
.voucherOrderSummary(3, 3, "2096780000000000003", "2026-09-07", "午餐")
|
||||
.divider()
|
||||
.sectionTitle("合并商品明细")
|
||||
.voucherProduct("A套餐-糖醋排骨", 3, "12.00", "36.00", true, List.of())
|
||||
.voucherProduct("A套餐-糖醋排骨", null, 3, "12.00", "36.00", true, List.of())
|
||||
.voucherTotal("36.00");
|
||||
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(receipt.toPngBytes()));
|
||||
@@ -179,8 +191,8 @@ class ThermalReceiptImageTest {
|
||||
.keyValue("预约日期", "2026-09-07")
|
||||
.keyValue("餐段", "午餐")
|
||||
.sectionTitle("剩余可履约商品")
|
||||
.voucherProduct("A套餐-糖醋排骨", 1, "12.00", "12.00", true, List.of())
|
||||
.voucherProduct("红糖发糕", 1, "3.00", "3.00", false, List.of());
|
||||
.voucherProduct("A套餐-糖醋排骨", null, 1, "12.00", "12.00", true, List.of())
|
||||
.voucherProduct("红糖发糕", null, 1, "3.00", "3.00", false, List.of());
|
||||
}
|
||||
ThermalReceiptImage merged = new ThermalReceiptImage().title("预约核销兑换券")
|
||||
.divider().sectionTitle("订单信息(3笔)")
|
||||
@@ -188,8 +200,8 @@ class ThermalReceiptImageTest {
|
||||
.voucherOrderSummary(2, 3, "2096780000000000002", "2026-09-07", "午餐")
|
||||
.voucherOrderSummary(3, 3, "2096780000000000003", "2026-09-07", "午餐")
|
||||
.divider().sectionTitle("合并商品明细")
|
||||
.voucherProduct("A套餐-糖醋排骨", 3, "12.00", "36.00", true, List.of())
|
||||
.voucherProduct("红糖发糕", 3, "3.00", "9.00", false, List.of());
|
||||
.voucherProduct("A套餐-糖醋排骨", null, 3, "12.00", "36.00", true, List.of())
|
||||
.voucherProduct("红糖发糕", null, 3, "3.00", "9.00", false, List.of());
|
||||
|
||||
BufferedImage groupedImage = ImageIO.read(new ByteArrayInputStream(grouped.toPngBytes()));
|
||||
BufferedImage mergedImage = ImageIO.read(new ByteArrayInputStream(merged.toPngBytes()));
|
||||
@@ -216,8 +228,8 @@ class ThermalReceiptImageTest {
|
||||
.keyValue("订单类型", "线下购买")
|
||||
.keyValue("下单时间", "2026-09-14 12:03:01")
|
||||
.divider().sectionTitle("商品明细")
|
||||
.voucherProduct("A套餐-糖醋排骨", "x1", "17.00", "17.00", true, List.of("糖醋排骨 x1", "米饭 x1"))
|
||||
.voucherProduct("红糖发糕", "x1", "1.60", "1.60", false, List.of())
|
||||
.voucherProduct("A套餐-糖醋排骨", null, "x1", "17.00", "17.00", true, List.of("糖醋排骨 x1", "米饭 x1"))
|
||||
.voucherProduct("红糖发糕", null, "x1", "1.60", "1.60", false, List.of())
|
||||
.keyValue("额外支付金额", "¥0.50")
|
||||
.voucherTotal("18.60")
|
||||
.keyValue("打印时间", "2026-09-14 12:03:05");
|
||||
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
package com.bsd.pjcloud.hard.job;
|
||||
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
|
||||
import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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 java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 自动退款补偿调度器的批量上限和租户上下文恢复测试。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HardAutoRefundTaskJobTest {
|
||||
|
||||
/**
|
||||
* 自动退款任务服务 Mock。
|
||||
*/
|
||||
@Mock
|
||||
private HardAutoRefundTaskService taskService;
|
||||
|
||||
/**
|
||||
* 被测自动退款调度器。
|
||||
*/
|
||||
@InjectMocks
|
||||
private HardAutoRefundTaskJob job;
|
||||
|
||||
/**
|
||||
* 清理测试线程租户上下文,避免影响同进程其他用例。
|
||||
*/
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TenantContextHolder.setTenantId(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单轮应最多请求一百条任务,并在任务租户上下文中触发定时重试。
|
||||
*/
|
||||
@Test
|
||||
void scheduledScanShouldRestoreTaskTenantBeforeExecution() {
|
||||
HardAutoRefundTaskEntity task = new HardAutoRefundTaskEntity();
|
||||
task.setId(10L);
|
||||
task.setTenantId(66L);
|
||||
when(taskService.listScanCandidates(100)).thenReturn(List.of(task));
|
||||
|
||||
job.retryDueTasks();
|
||||
|
||||
verify(taskService).listScanCandidates(100);
|
||||
verify(taskService).execute(10L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
|
||||
assertNull(TenantContextHolder.getTenantId());
|
||||
}
|
||||
}
|
||||
+7
-9
@@ -1,12 +1,10 @@
|
||||
package com.bsd.pjcloud.hard.job;
|
||||
package com.bsd.pjcloud.hard.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
|
||||
import com.bsd.pjcloud.hard.service.HardAggregatePayPollingCoordinator;
|
||||
import com.bsd.pjcloud.hard.service.HardOrderService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -24,10 +22,10 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Hardware 聚合订单支付补偿任务测试。
|
||||
* Hardware 聚合订单支付结算补偿编排器测试(由原 HardAggregateOrderPayJobTest 平移)。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HardAggregateOrderPayJobTest {
|
||||
class HardAggregateOrderPayCompensatorTest {
|
||||
|
||||
/**
|
||||
* Hardware 订单服务 Mock,用于返回历史订单并验证带锁取消入口。
|
||||
@@ -42,10 +40,10 @@ class HardAggregateOrderPayJobTest {
|
||||
private HardAggregatePayPollingCoordinator pollingCoordinator;
|
||||
|
||||
/**
|
||||
* 被测 Hardware 聚合订单支付补偿任务。
|
||||
* 被测 Hardware 聚合订单支付结算补偿编排器。
|
||||
*/
|
||||
@InjectMocks
|
||||
private HardAggregateOrderPayJob job;
|
||||
private HardAggregateOrderPayCompensator compensator;
|
||||
|
||||
/**
|
||||
* 清理测试期间由 TenantBroker 设置的线程租户上下文。
|
||||
@@ -59,7 +57,7 @@ class HardAggregateOrderPayJobTest {
|
||||
* 验证超过一小时的订单只进入带锁取消,不再调用 Thirdparty 状态查询流程。
|
||||
*/
|
||||
@Test
|
||||
void staleJobShouldCancelOrderOlderThanOneHourWithoutQueryingPaymentStatus() {
|
||||
void staleCompensationShouldCancelOrderOlderThanOneHourWithoutQueryingPaymentStatus() {
|
||||
HardOrderEntity order = new HardOrderEntity();
|
||||
order.setId(101L);
|
||||
order.setTenantId(66L);
|
||||
@@ -69,7 +67,7 @@ class HardAggregateOrderPayJobTest {
|
||||
order.setCreateTime(LocalDateTime.now().minusHours(2));
|
||||
when(hardOrderService.list(any(Wrapper.class))).thenReturn(List.of(order), List.of());
|
||||
|
||||
job.settleStaleAggregateOrders();
|
||||
compensator.settleStaleAggregateOrders();
|
||||
|
||||
verify(hardOrderService).cancelExpiredAggregateOrder(eq(order.getId()), any(LocalDateTime.class));
|
||||
verify(hardOrderService, never()).getAggregateOrderCodePayStatus(any(), any());
|
||||
+53
-23
@@ -36,12 +36,14 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Answers.CALLS_REAL_METHODS;
|
||||
@@ -426,16 +428,16 @@ class HardInvoiceServiceImplTest {
|
||||
component.setName("糖醋排骨");
|
||||
component.setQuantity(1);
|
||||
HardInvoiceServiceImpl.VoucherProductLine firstCombo =
|
||||
new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 1, 1200L, 1200L,
|
||||
new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", "A套餐-红烧肉", 1, 1200L, 1200L,
|
||||
true, List.of(component));
|
||||
HardInvoiceServiceImpl.VoucherProductLine single =
|
||||
new HardInvoiceServiceImpl.VoucherProductLine(601L, "红糖发糕", 1, 300L, 300L,
|
||||
new HardInvoiceServiceImpl.VoucherProductLine(601L, null, "红糖发糕", 1, 300L, 300L,
|
||||
false, List.of());
|
||||
HardInvoiceServiceImpl.VoucherProductLine repeatedCombo =
|
||||
new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 2, 1200L, 2400L,
|
||||
new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", "A套餐-红烧肉", 2, 1200L, 2400L,
|
||||
true, List.of(component));
|
||||
HardInvoiceServiceImpl.VoucherProductLine changedPrice =
|
||||
new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 1, 1300L, 1300L,
|
||||
new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", "A套餐-红烧肉", 1, 1300L, 1300L,
|
||||
true, List.of(component));
|
||||
HardOrderEntity firstOrder = order();
|
||||
HardOrderEntity secondOrder = order();
|
||||
@@ -447,8 +449,9 @@ class HardInvoiceServiceImplTest {
|
||||
List<HardInvoiceServiceImpl.VoucherProductLine> merged =
|
||||
HardInvoiceServiceImpl.mergeVoucherProducts(orders);
|
||||
|
||||
assertEquals(List.of("A套餐", "红糖发糕", "A套餐"),
|
||||
merged.stream().map(HardInvoiceServiceImpl.VoucherProductLine::productName).toList());
|
||||
assertEquals(Arrays.asList("A套餐", null, "A套餐"),
|
||||
merged.stream().map(HardInvoiceServiceImpl.VoucherProductLine::comboName).toList());
|
||||
assertEquals("红糖发糕", merged.get(1).name());
|
||||
assertEquals(3, merged.get(0).quantity());
|
||||
assertEquals(3600L, merged.get(0).subtotal());
|
||||
assertSame(component, merged.get(0).comboItems().get(0));
|
||||
@@ -456,21 +459,25 @@ class HardInvoiceServiceImplTest {
|
||||
assertEquals(1300L, merged.get(2).unitPrice());
|
||||
}
|
||||
|
||||
/** 打印商品名称必须套餐名优先,且两侧空白被去除后才参与展示。 */
|
||||
/** 打印商品名称必须套餐名、商品名分开保留,且两侧空白被去除后才参与展示。 */
|
||||
@Test
|
||||
void printProductNamesPreferTrimmedComboNameOverName() {
|
||||
void printProductNamesKeepTrimmedComboAndPlainName() {
|
||||
HardCarVo car = new HardCarVo();
|
||||
car.setProductId(501L);
|
||||
when(remoteCanteenSpuService.getInnerSpuPrintNames(List.of(501L))).thenReturn(R.ok(List.of(
|
||||
SpuPrintNameVo.builder().productId(501L).comboName(" 15元套餐 ").name("15元套餐-红烧肉").build())));
|
||||
SpuPrintNameVo.builder().productId(501L).comboName(" 15元套餐 ").name(" 15元套餐-红烧肉 ").build())));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, String> names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
|
||||
Map<Long, Object> names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
|
||||
|
||||
assertEquals(Map.of(501L, "15元套餐"), names);
|
||||
Object printName = names.get(501L);
|
||||
assertEquals("15元套餐", ReflectionTestUtils.invokeMethod(printName, "comboName"));
|
||||
assertEquals("15元套餐-红烧肉", ReflectionTestUtils.invokeMethod(printName, "name"));
|
||||
assertEquals("15元套餐", ReflectionTestUtils.invokeMethod(printName, "mainName"));
|
||||
assertEquals("15元套餐-红烧肉", ReflectionTestUtils.invokeMethod(printName, "subName"));
|
||||
}
|
||||
|
||||
/** 套餐名为空串(非套餐商品保存时被清空)时必须降级商品名。 */
|
||||
/** 套餐名为空串(非套餐商品保存时被清空)时主名必须降级商品名且不输出副行。 */
|
||||
@Test
|
||||
void printProductNamesFallBackToNameWhenComboBlank() {
|
||||
HardCarVo car = new HardCarVo();
|
||||
@@ -479,9 +486,28 @@ class HardInvoiceServiceImplTest {
|
||||
SpuPrintNameVo.builder().productId(502L).comboName("").name("糖醋排骨").build())));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, String> names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
|
||||
Map<Long, Object> names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
|
||||
|
||||
assertEquals(Map.of(502L, "糖醋排骨"), names);
|
||||
Object printName = names.get(502L);
|
||||
assertNull(ReflectionTestUtils.invokeMethod(printName, "comboName"));
|
||||
assertEquals("糖醋排骨", ReflectionTestUtils.invokeMethod(printName, "mainName"));
|
||||
assertNull(ReflectionTestUtils.invokeMethod(printName, "subName"));
|
||||
}
|
||||
|
||||
/** 套餐名与商品名完全相同时副行不重复显示,主名保持套餐名。 */
|
||||
@Test
|
||||
void printProductNamesSkipSubNameWhenIdentical() {
|
||||
HardCarVo car = new HardCarVo();
|
||||
car.setProductId(505L);
|
||||
when(remoteCanteenSpuService.getInnerSpuPrintNames(List.of(505L))).thenReturn(R.ok(List.of(
|
||||
SpuPrintNameVo.builder().productId(505L).comboName("A套餐").name("A套餐").build())));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, Object> names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
|
||||
|
||||
Object printName = names.get(505L);
|
||||
assertEquals("A套餐", ReflectionTestUtils.invokeMethod(printName, "mainName"));
|
||||
assertNull(ReflectionTestUtils.invokeMethod(printName, "subName"));
|
||||
}
|
||||
|
||||
/** 商品已删除或非食堂商品时不在返回结果中,渲染层由快照名兜底。 */
|
||||
@@ -493,7 +519,7 @@ class HardInvoiceServiceImplTest {
|
||||
SpuPrintNameVo.builder().productId(504L).comboName("B套餐").name("B套餐-主菜").build())));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, String> names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
|
||||
Map<Long, Object> names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
|
||||
|
||||
assertFalse(names.containsKey(503L));
|
||||
}
|
||||
@@ -507,12 +533,12 @@ class HardInvoiceServiceImplTest {
|
||||
.thenThrow(new RuntimeException("canteen service unavailable"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, String> names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
|
||||
Map<Long, Object> names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
|
||||
|
||||
assertTrue(names.isEmpty());
|
||||
}
|
||||
|
||||
/** 未命中实时名称或历史购物车商品 ID 为空时,打印名必须回落到下单快照名。 */
|
||||
/** 未命中实时名称或历史购物车商品 ID 为空时,打印名必须回落到下单快照名并保持大字主行。 */
|
||||
@Test
|
||||
void resolvePrintNameFallsBackToSnapshot() {
|
||||
HardCarVo named = new HardCarVo();
|
||||
@@ -522,12 +548,16 @@ class HardInvoiceServiceImplTest {
|
||||
legacy.setProductId(null);
|
||||
legacy.setProductName("历史无ID商品");
|
||||
|
||||
assertEquals("实时套餐名", ReflectionTestUtils.invokeMethod(service, "resolvePrintName",
|
||||
named, Map.of(501L, "实时套餐名")));
|
||||
assertEquals("下单快照名", ReflectionTestUtils.invokeMethod(service, "resolvePrintName",
|
||||
named, Map.of()));
|
||||
assertEquals("历史无ID商品", ReflectionTestUtils.invokeMethod(service, "resolvePrintName",
|
||||
legacy, Map.of()));
|
||||
Object snapshot = ReflectionTestUtils.invokeMethod(service, "resolvePrintName", named, Map.of());
|
||||
Object fromLive = ReflectionTestUtils.invokeMethod(service, "resolvePrintName",
|
||||
named, Map.of(501L, snapshot));
|
||||
Object resolvedLegacy = ReflectionTestUtils.invokeMethod(service, "resolvePrintName", legacy, Map.of());
|
||||
|
||||
assertSame(snapshot, fromLive);
|
||||
assertEquals("下单快照名", ReflectionTestUtils.invokeMethod(snapshot, "mainName"));
|
||||
assertNull(ReflectionTestUtils.invokeMethod(snapshot, "subName"));
|
||||
assertEquals("历史无ID商品", ReflectionTestUtils.invokeMethod(resolvedLegacy, "mainName"));
|
||||
assertNull(ReflectionTestUtils.invokeMethod(resolvedLegacy, "subName"));
|
||||
}
|
||||
|
||||
private static HardOrderEntity order() {
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
<artifactId>pjcloud-wallet-api</artifactId>
|
||||
<version>5.7.0</version>
|
||||
</dependency>
|
||||
<!-- hardware 补偿任务迁移后只依赖 hardware Feign 契约,不依赖其业务实现。 -->
|
||||
<dependency>
|
||||
<groupId>com.bsd</groupId>
|
||||
<artifactId>pjcloud-hardware-api</artifactId>
|
||||
<version>5.7.0</version>
|
||||
</dependency>
|
||||
<!--注册中心客户端-->
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
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.hard.api.feign.RemoteHardScheduleService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Hardware 补偿任务统一调度入口。sys_job 通过 spring bean 反射调用(job_type=2,
|
||||
* class_name=hardScheduleTask),bean 内走 Feign 调 hardware 内部端点执行,
|
||||
* 各任务由分布式订单锁、任务行锁、Outbox 条件更新或轮询协调器退避门控保证重复触发幂等。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("hardScheduleTask")
|
||||
@RequiredArgsConstructor
|
||||
public class HardScheduleTask {
|
||||
|
||||
/** hardware 内部调度 Feign 契约。 */
|
||||
private final RemoteHardScheduleService remoteHardScheduleService;
|
||||
|
||||
/**
|
||||
* 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
|
||||
*/
|
||||
public String settlePendingAggregateOrders() {
|
||||
return settlePendingAggregateOrders(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 高频补偿近期待结算聚合支付订单,每 5 秒触发一次;空闲时硬件侧轮询协调器退避门控直接空转。
|
||||
*/
|
||||
@SneakyThrows
|
||||
public String settlePendingAggregateOrders(String para) {
|
||||
log.info("聚合支付近期结算补偿开始:{},输入参数:{}", LocalDateTime.now(), para);
|
||||
try {
|
||||
R<Void> result = remoteHardScheduleService.settlePendingAggregateOrders();
|
||||
if (!result.isOk()) {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
|
||||
*/
|
||||
public String settleStaleAggregateOrders() {
|
||||
return settleStaleAggregateOrders(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 低频核对遗留聚合支付订单并取消超过一小时的订单,每 5 分钟触发一次。
|
||||
*/
|
||||
@SneakyThrows
|
||||
public String settleStaleAggregateOrders(String para) {
|
||||
log.info("聚合支付遗留核对与超时取消开始:{},输入参数:{}", LocalDateTime.now(), para);
|
||||
try {
|
||||
R<Void> result = remoteHardScheduleService.settleStaleAggregateOrders();
|
||||
if (!result.isOk()) {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
|
||||
*/
|
||||
public String retryDueAutoRefundTasks() {
|
||||
return retryDueAutoRefundTasks(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描到期的自动退款任务并逐个恢复租户上下文执行,每 30 秒触发一次。
|
||||
*/
|
||||
@SneakyThrows
|
||||
public String retryDueAutoRefundTasks(String para) {
|
||||
log.info("自动退款到期任务补偿开始:{},输入参数:{}", LocalDateTime.now(), para);
|
||||
try {
|
||||
R<Integer> result = remoteHardScheduleService.retryDueAutoRefundTasks();
|
||||
if (result.isOk()) {
|
||||
if (result.getData() != null && result.getData() > 0) {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
|
||||
*/
|
||||
public String retryCoffeeNotifyFailures() {
|
||||
return retryCoffeeNotifyFailures(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试咖啡机通知失败的支付回调与退款同步缺口,每分钟触发一次。
|
||||
*/
|
||||
@SneakyThrows
|
||||
public String retryCoffeeNotifyFailures(String para) {
|
||||
log.info("咖啡机通知失败补偿开始:{},输入参数:{}", LocalDateTime.now(), para);
|
||||
try {
|
||||
R<Void> result = remoteHardScheduleService.retryCoffeeNotifyFailures();
|
||||
if (!result.isOk()) {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
|
||||
*/
|
||||
public String processOutboxDueEvents() {
|
||||
return processOutboxDueEvents(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描到期核销消息 Outbox 事件并投递,每 10 秒触发一次;条件更新领取保证重复触发幂等。
|
||||
*/
|
||||
@SneakyThrows
|
||||
public String processOutboxDueEvents(String para) {
|
||||
log.info("核销消息Outbox到期投递开始:{},输入参数:{}", LocalDateTime.now(), para);
|
||||
try {
|
||||
R<Integer> result = remoteHardScheduleService.processOutboxDueEvents();
|
||||
if (result.isOk()) {
|
||||
if (result.getData() != null && result.getData() > 0) {
|
||||
log.info("核销消息Outbox到期投递完成,本轮处理:{}条", result.getData());
|
||||
}
|
||||
} else {
|
||||
log.error("核销消息Outbox到期投递失败,code:{},msg:{}", result.getCode(), result.getMsg());
|
||||
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("核销消息Outbox到期投递错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
|
||||
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
|
||||
}
|
||||
return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
|
||||
}
|
||||
|
||||
/**
|
||||
* 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
|
||||
*/
|
||||
public String cleanupOutboxExpiredEvents() {
|
||||
return cleanupOutboxExpiredEvents(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理超过保留期的终态核销消息 Outbox,每日 02:30 触发一次。
|
||||
*/
|
||||
@SneakyThrows
|
||||
public String cleanupOutboxExpiredEvents(String para) {
|
||||
log.info("核销消息Outbox过期清理开始:{},输入参数:{}", LocalDateTime.now(), para);
|
||||
try {
|
||||
R<Integer> result = remoteHardScheduleService.cleanupOutboxExpiredEvents();
|
||||
if (result.isOk()) {
|
||||
if (result.getData() != null && result.getData() > 0) {
|
||||
log.info("核销消息Outbox过期清理完成,本轮清理:{}条", result.getData());
|
||||
}
|
||||
} else {
|
||||
log.error("核销消息Outbox过期清理失败,code:{},msg:{}", result.getCode(), result.getMsg());
|
||||
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("核销消息Outbox过期清理错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
|
||||
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
|
||||
}
|
||||
return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
|
||||
}
|
||||
|
||||
/**
|
||||
* 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
|
||||
*/
|
||||
public String processPrintDueTasks() {
|
||||
return processPrintDueTasks(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描并补偿下发持久化打印任务,每 10 秒触发一次。
|
||||
*/
|
||||
@SneakyThrows
|
||||
public String processPrintDueTasks(String para) {
|
||||
log.info("打印任务补偿扫描开始:{},输入参数:{}", LocalDateTime.now(), para);
|
||||
try {
|
||||
R<Integer> result = remoteHardScheduleService.processPrintDueTasks();
|
||||
if (result.isOk()) {
|
||||
if (result.getData() != null && result.getData() > 0) {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
|
||||
*/
|
||||
public String cleanupXfjLog() {
|
||||
return cleanupXfjLog(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理创建时间超过三十天的消费机日志,每日 00:00 触发一次;累计未达一万条时跳过删除。
|
||||
*/
|
||||
@SneakyThrows
|
||||
public String cleanupXfjLog(String para) {
|
||||
log.info("消费机日志过期清理开始:{},输入参数:{}", LocalDateTime.now(), para);
|
||||
try {
|
||||
R<Integer> result = remoteHardScheduleService.cleanupXfjLog();
|
||||
if (result.isOk()) {
|
||||
if (result.getData() != null && result.getData() > 0) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user