Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-09-28 15:29:34 +08:00
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 即可,其余任务不受影响。
@@ -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();
}
@@ -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()
@@ -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);
/**
@@ -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);
@@ -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);
@@ -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());
}
}
@@ -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)
);
}
}
}
@@ -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());
}
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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);
@@ -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);
}
@@ -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();
}
@@ -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;
}
@@ -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 和尝试日志共同使用的稳定退款业务号。
*
@@ -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;
}
}
@@ -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");
@@ -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");
@@ -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());
}
}
@@ -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());
@@ -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>
@@ -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();
}
}