From 8b6f20f6bce5b155bd746d4b6ab5d1f9a4fb1a20 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Mon, 28 Sep 2026 15:25:41 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=89=93=E5=8D=B0=E5=8F=91?= =?UTF-8?q?=E7=A5=A8=E4=BF=AE=E6=94=B9=E5=8F=91=E7=A5=A8=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=20feat=EF=BC=9A=E6=8A=8Ahard=E7=9A=84=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=BF=81=E7=A7=BB=E5=88=B0=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260928_hard模块定时任务迁移sys_job.txt | 219 +++++++++++++++ .../api/feign/RemoteHardScheduleService.java | 90 +++++++ .../hard/api/utils/EscPosReceiptCommand.java | 21 +- .../hard/api/utils/ReceiptRenderer.java | 10 +- .../hard/api/utils/ThermalReceiptImage.java | 32 ++- .../java/com/bsd/pjcloud/hard/HardApp.java | 3 +- .../schedule/HardScheduleInnerController.java | 160 +++++++++++ .../bsd/pjcloud/hard/job/DeleteLogJob.java | 35 --- .../hard/job/HardAutoRefundTaskJob.java | 57 ---- .../pjcloud/hard/job/HardCoffeeNotifyJob.java | 36 --- .../hard/job/HardMessageOutboxJob.java | 49 ---- .../pjcloud/hard/job/HardPrintTaskJob.java | 32 --- .../HardAggregateOrderPayCompensator.java} | 41 ++- .../service/HardAutoRefundTaskService.java | 11 + .../hard/service/HardXfjLogService.java | 9 + .../service/impl/HardInvoiceServiceImpl.java | 130 ++++++--- .../order/HardAutoRefundTaskServiceImpl.java | 30 +++ .../impl/xfj/HardXfjLogServiceImpl.java | 26 +- .../api/utils/EscPosReceiptCommandTest.java | 42 ++- .../api/utils/ThermalReceiptImageTest.java | 44 +-- .../hard/job/HardAutoRefundTaskJobTest.java | 62 ----- ...HardAggregateOrderPayCompensatorTest.java} | 16 +- .../impl/HardInvoiceServiceImplTest.java | 76 ++++-- pjcloud-visual/pjcloud-daemon-quartz/pom.xml | 6 + .../daemon/quartz/task/HardScheduleTask.java | 254 ++++++++++++++++++ 25 files changed, 1082 insertions(+), 409 deletions(-) create mode 100644 db/20260928_hard模块定时任务迁移sys_job.txt create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardScheduleService.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/schedule/HardScheduleInnerController.java delete mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/DeleteLogJob.java delete mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java delete mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java delete mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardMessageOutboxJob.java delete mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java rename pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/{job/HardAggregateOrderPayJob.java => service/HardAggregateOrderPayCompensator.java} (80%) delete mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java rename pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/{job/HardAggregateOrderPayJobTest.java => service/HardAggregateOrderPayCompensatorTest.java} (82%) create mode 100644 pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/HardScheduleTask.java diff --git a/db/20260928_hard模块定时任务迁移sys_job.txt b/db/20260928_hard模块定时任务迁移sys_job.txt new file mode 100644 index 00000000..0e5c55d8 --- /dev/null +++ b/db/20260928_hard模块定时任务迁移sys_job.txt @@ -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 即可,其余任务不受影响。 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardScheduleService.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardScheduleService.java new file mode 100644 index 00000000..afa80697 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardScheduleService.java @@ -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 契约。 + * + *

Hardware 原本地 {@code @Scheduled} 任务已全部迁移到 daemon-quartz 统一调度,本契约对应 + * HardScheduleInnerController 暴露的内部端点;各任务内部由分布式订单锁、任务行锁、 + * Outbox 条件更新或轮询协调器退避门控保证重复触发幂等安全。

+ */ +@FeignClient(contextId = "remoteHardScheduleService", value = ServiceNameConstants.HARDWARE_SERVICE) +public interface RemoteHardScheduleService { + + /** + * 高频补偿最近十五分钟内仍处于创建状态的聚合支付订单;空闲时由轮询协调器退避门控直接空转返回。 + * + * @return 固定返回成功标记,本轮是否实际扫描由 Hardware 实例内协调器决定 + */ + @NoToken + @PostMapping("/hard-schedule/inner/aggregate-pay/settle-pending") + R settlePendingAggregateOrders(); + + /** + * 低频核对十五分钟至一小时内的遗留聚合支付订单,并原子取消超过一小时的订单。 + * + * @return 固定返回成功标记,逐单结果由订单锁与状态条件更新保证 + */ + @NoToken + @PostMapping("/hard-schedule/inner/aggregate-pay/settle-stale") + R settleStaleAggregateOrders(); + + /** + * 扫描到期的自动退款任务并按任务租户恢复上下文后执行。 + * + * @return 本轮扫描到并尝试执行的任务数量 + */ + @NoToken + @PostMapping("/hard-schedule/inner/auto-refund/retry-due") + R retryDueAutoRefundTasks(); + + /** + * 重试通知失败的咖啡机支付回调与退款同步缺口。 + * + * @return 固定返回成功标记,失败订单按通知次数上限熔断 + */ + @NoToken + @PostMapping("/hard-schedule/inner/coffee-notify/retry-failures") + R retryCoffeeNotifyFailures(); + + /** + * 扫描到期的核销消息 Outbox 事件并投递,条件更新领取保证重复触发幂等。 + * + * @return 本轮处理的事件数量 + */ + @NoToken + @PostMapping("/hard-schedule/inner/outbox/process-due") + R processOutboxDueEvents(); + + /** + * 分批清理超过保留期的终态核销消息 Outbox。 + * + * @return 本轮清理的事件数量 + */ + @NoToken + @PostMapping("/hard-schedule/inner/outbox/cleanup") + R cleanupOutboxExpiredEvents(); + + /** + * 扫描待生成的持久化打印任务并补偿下发待打印任务。 + * + * @return 本轮处理的打印任务数量 + */ + @NoToken + @PostMapping("/hard-schedule/inner/print/process-due") + R processPrintDueTasks(); + + /** + * 清理创建时间超过三十天的消费机日志,低于阈值时不执行删除。 + * + * @return 本轮删除的日志行数,未达阈值时为零 + */ + @NoToken + @PostMapping("/hard-schedule/inner/xfj-log/cleanup") + R cleanupXfjLog(); +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java index c2d5e847..9e1cf4ac 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java @@ -175,9 +175,9 @@ public class EscPosReceiptCommand implements ReceiptRenderer { * 渲染兑换券商品卡片,数量为整数份数。 */ @Override - public EscPosReceiptCommand voucherProduct(String name, int quantity, String unitPrice, String subtotal, - boolean combo, List 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 componentItems) { + return voucherProduct(name, subName, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems); } /** @@ -185,12 +185,15 @@ public class EscPosReceiptCommand implements ReceiptRenderer { * *

与图片版卡片逐项对应:框线对应描边矩形;组合商品标签为 GS B 反色黑底(对应图片 * fillRect 黑底白字),单品标签为制表符描边小框(对应图片 drawRect 描边框);卡片内部 - * 压缩行距让左右竖线行间衔接不断线,各区块间距按图片版像素值(1px≈1 点)走纸。

+ * 压缩行距让左右竖线行间衔接不断线,各区块间距按图片版像素值(1px≈1 点)走纸。 + * subName 非空时在倍宽主名后输出单倍宽副行,对应图片版主名下方的小字号商品名。

*/ @Override - public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal, - boolean combo, List componentItems) { + public EscPosReceiptCommand voucherProduct(String name, String subName, String quantity, String unitPrice, + String subtotal, boolean combo, List 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 safeComponents = componentItems == null ? List.of() : componentItems.stream() diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java index c453d658..defd366e 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java @@ -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 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 componentItems); /** diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java index d8d52ca1..c5fc810e 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java @@ -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 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 componentItems) { + return voucherProduct(name, subName, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems); } /** * 绘制兑换券商品卡片,数量为已格式化文本,供兑换券与发票共用同一卡片版式。 * - *

数量文本由调用方按业务格式化,例如按份数的“x2”或称重的“0.5/kg”,绘制时原样展示。

+ *

数量文本由调用方按业务格式化,例如按份数的“x2”或称重的“0.5/kg”,绘制时原样展示。 + * 主名保持大号粗体;subName 非空时在主名与价格行之间以正文字号渲染副行小字。

* - * @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 componentItems) { + public ThermalReceiptImage voucherProduct(String name, String subName, String quantity, String unitPrice, + String subtotal, boolean combo, List 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 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); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java index ce83ad1c..4a546467 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java @@ -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); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/schedule/HardScheduleInnerController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/schedule/HardScheduleInnerController.java new file mode 100644 index 00000000..b2c69fcf --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/schedule/HardScheduleInnerController.java @@ -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 触发。 + * + *

原本地 {@code @Scheduled} 任务已删除,执行逻辑平移到对应领域服务;本控制器只做触发转发, + * 集群重复触发由各任务内部的分布式锁、条件更新或退避门控保证幂等。

+ */ +@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 settlePendingAggregateOrders() { + aggregateOrderPayCompensator.settlePendingAggregateOrders(); + return R.ok(); + } + + /** + * 低频核对遗留聚合支付订单并取消超时订单,必须携带内部调用标识。 + * + * @return 成功标记;逐单结算与取消由订单锁和状态条件更新保证 + */ + @Operation(summary = "内部触发聚合支付遗留核对与超时取消") + @Inner + @NoToken + @PostMapping("/inner/aggregate-pay/settle-stale") + public R settleStaleAggregateOrders() { + aggregateOrderPayCompensator.settleStaleAggregateOrders(); + return R.ok(); + } + + /** + * 扫描到期自动退款任务并逐个恢复租户上下文执行,必须携带内部调用标识。 + * + * @return 本轮扫描到并尝试执行的任务数量 + */ + @Operation(summary = "内部触发自动退款到期任务补偿") + @Inner + @NoToken + @PostMapping("/inner/auto-refund/retry-due") + public R retryDueAutoRefundTasks() { + return R.ok(autoRefundTaskService.retryDueTasks(AUTO_REFUND_SCAN_BATCH_SIZE)); + } + + /** + * 重试咖啡机通知失败的支付回调与退款同步缺口,必须携带内部调用标识。 + * + * @return 成功标记;失败订单按通知次数上限熔断后需人工介入 + */ + @Operation(summary = "内部触发咖啡机通知失败补偿") + @Inner + @NoToken + @PostMapping("/inner/coffee-notify/retry-failures") + public R retryCoffeeNotifyFailures() { + hardCoffeeService.retryNotifyFailures(); + return R.ok(); + } + + /** + * 扫描到期核销消息 Outbox 事件并投递,必须携带内部调用标识。 + * + * @return 本轮处理的事件数量 + */ + @Operation(summary = "内部触发核销消息Outbox到期投递") + @Inner + @NoToken + @PostMapping("/inner/outbox/process-due") + public R processOutboxDueEvents() { + return R.ok(messageOutboxService.processDueEvents()); + } + + /** + * 清理超过保留期的终态核销消息 Outbox,必须携带内部调用标识。 + * + * @return 本轮清理的事件数量 + */ + @Operation(summary = "内部触发核销消息Outbox过期清理") + @Inner + @NoToken + @PostMapping("/inner/outbox/cleanup") + public R cleanupOutboxExpiredEvents() { + return R.ok(messageOutboxService.cleanupExpiredEvents()); + } + + /** + * 扫描并补偿下发持久化打印任务,必须携带内部调用标识。 + * + * @return 本轮处理的打印任务数量 + */ + @Operation(summary = "内部触发打印任务补偿扫描") + @Inner + @NoToken + @PostMapping("/inner/print/process-due") + public R processPrintDueTasks() { + return R.ok(hardInvoiceService.processDueTasks()); + } + + /** + * 清理创建时间超过三十天的消费机日志,必须携带内部调用标识。 + * + * @return 本轮删除的日志行数,累计数量未达阈值时为零 + */ + @Operation(summary = "内部触发消费机日志过期清理") + @Inner + @NoToken + @PostMapping("/inner/xfj-log/cleanup") + public R cleanupXfjLog() { + return R.ok(hardXfjLogService.cleanupExpiredLogs()); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/DeleteLogJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/DeleteLogJob.java deleted file mode 100644 index 9dd5bac5..00000000 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/DeleteLogJob.java +++ /dev/null @@ -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() - .le(HardXfjLogEntity::getCreateTime, before) - ); - if (count >= 10000) { - hardXfjLogMapper.delete(new LambdaQueryWrapper() - .le(HardXfjLogEntity::getCreateTime, before) - ); - } - } - -} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java deleted file mode 100644 index 2a6ae931..00000000 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java +++ /dev/null @@ -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 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()); - } - } - } -} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java deleted file mode 100644 index decc9fac..00000000 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java +++ /dev/null @@ -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); - } - } -} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardMessageOutboxJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardMessageOutboxJob.java deleted file mode 100644 index 784aa8a2..00000000 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardMessageOutboxJob.java +++ /dev/null @@ -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); - } - } -} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java deleted file mode 100644 index 449a6f65..00000000 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java +++ /dev/null @@ -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); - } - } -} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensator.java similarity index 80% rename from pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java rename to pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensator.java index 5d601c53..2da9af6f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensator.java @@ -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 逻辑平移)。 * - *

终端主动轮询仍是实时结算入口;本任务负责覆盖收银弹窗提前关闭、终端断网或进程退出后, - * 第三方已经支付但 Hardware 业务订单尚未结算的场景。

+ *

终端主动轮询仍是实时结算入口;本编排器负责覆盖收银弹窗提前关闭、终端断网或进程退出后, + * 第三方已经支付但 Hardware 业务订单尚未结算的场景。由 daemon-quartz 经 + * HardScheduleInnerController 内部端点触发,不再依赖本地 {@code @Scheduled}。

+ * + *

逐单结算必须经 {@link HardOrderService} 代理调用(目标方法带 {@code @Lock4j} 与事务), + * 因此编排逻辑独立成组件而不并入 HardOrderServiceImpl,避免自调用丢失代理语义。

*/ -@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 订单。 * - *

调度线程无租户上下文时先跨租户读取候选记录,再按记录持久化的租户 ID 恢复上下文; - * 具体结算继续由业务服务的分布式订单锁和状态条件保证幂等。方法每五秒轻量触发一次,连续空结果时 - * 真正的数据库扫描按十秒、二十秒、四十秒退避到最长一分钟。

+ *

daemon-quartz 每五秒触发一次内部端点调用本方法;调度线程无租户上下文时先跨租户读取候选记录, + * 再按记录持久化的租户 ID 恢复上下文;具体结算继续由业务服务的分布式订单锁和状态条件保证幂等。 + * 连续空结果时真正的数据库扫描按十秒、二十秒、四十秒退避到最长一分钟,未到扫描时间的实例直接返回。

*/ - @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 { /** * 低频核对十五分钟至一小时内的遗留订单,并取消超过一小时的订单。 * - *

一小时以内仍保留服务异常恢复能力;超过一小时后不再调用 Thirdparty 查单,而是把仍处于创建状态 + *

daemon-quartz 每五分钟触发一次内部端点调用本方法。一小时以内仍保留服务异常恢复能力; + * 超过一小时后不再调用 Thirdparty 查单,而是把仍处于创建状态 * 的 Hardware 订单原子更新为未支付取消,避免异常记录被永久轮询。第三方若在此后报告延迟支付, * 由线下渠道核对和协商,不再自动改变 Hardware 库存及订单状态。

*/ - @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); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java index 6e6e0fa0..e462a585 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java @@ -153,4 +153,15 @@ public interface HardAutoRefundTaskService extends IService listScanCandidates(int limit); + + /** + * daemon-quartz 定时补偿入口:跨租户扫描到期或暂停任务,并按任务持久化租户恢复上下文后逐个执行。 + * + *

多实例或集群重复触发安全,订单 Redis 锁和任务行锁负责最终串行;单条执行失败只记日志, + * 不中断其余任务,也不使整轮补偿判定为失败。

+ * + * @param limit 单轮最多扫描任务数量,避免配置和钱包服务突发压力 + * @return 本轮扫描到并尝试执行的任务数量 + */ + int retryDueTasks(int limit); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjLogService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjLogService.java index d613c4e7..ccbd5ec9 100755 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjLogService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjLogService.java @@ -10,4 +10,13 @@ public interface HardXfjLogService extends IService { Page pageList(Page page, HardXfjLogDto dto); + /** + * 清理创建时间超过三十天的消费机日志(原 DeleteLogJob 逻辑平移)。 + * + *

超期行数累计不足一万条时跳过删除,避免天天执行小批量清理;由 daemon-quartz 每日零点 + * 经 HardScheduleInnerController 内部端点触发,条件删除重复执行安全。

+ * + * @return 本轮删除的日志行数,未达删除阈值时为零 + */ + int cleanupExpiredLogs(); } \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java index 951408f3..bec7e874 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java @@ -293,7 +293,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl printNames = loadPrintProductNames(carList); + Map 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发票是支付凭证,小计使用下单时的商品快照总额;商品名称打印时实时按套餐名优先解析, - * 套餐标签与组合内容沿用兑换券元数据和显示开关。

+ *

发票是支付凭证,小计使用下单时的商品快照总额;商品名称打印时实时解析为 + * 套餐名主行加商品名小字副行,套餐标签与组合内容沿用兑换券元数据和显示开关。

* * @param renderer 当前渲染器 * @param cars 订单商品快照 @@ -341,7 +341,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl cars, Map metadata, boolean showComboItems, - Map printNames) { + Map printNames) { renderer.divider().sectionTitle("商品明细"); boolean hasProduct = false; for (HardCarVo car : cars) { @@ -351,8 +351,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl 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 printNames = loadPrintProductNames(allCars); + Map printNames = loadPrintProductNames(allCars); List 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 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商品 ID、快照名称、单价和套餐类型共同作为合并键,避免同一菜品改价或改名后被错误归并; + *

商品 ID、套餐名、商品名、单价和套餐类型共同作为合并键,避免同一菜品改价或改名后被错误归并; * 历史商品 ID 为空时,自然按名称、单价和套餐类型合并。数量与小计均使用各原始行求和。

* * @param orderProducts 按订单分组的商品快照 @@ -1063,8 +1087,8 @@ public class HardInvoiceServiceImpl extends ServiceImpl 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 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商品 ID 兼容 canteen_spu.id(线下/每日菜谱单)和 canteen_online_spu.id(App 线上预约单, * 服务端经 linkSpuId 关联取名);非食堂商品、已删除商品或名称全为空的记录不进入结果, * 调用方降级购物车快照名。打印是核销后的关键闭环,查询失败时记录警告并整体降级,不阻断出票。

* * @param cars 票据涉及的商品快照 - * @return 按商品 ID 索引的打印名称;查询失败时返回空 Map + * @return 按商品 ID 索引的双名结构;查询失败时返回空 Map */ - private Map loadPrintProductNames(List cars) { + private Map loadPrintProductNames(List cars) { List productIds = cars.stream().map(HardCarVo::getProductId).filter(Objects::nonNull).distinct().toList(); if (productIds.isEmpty()) return Map.of(); try { R> result = remoteCanteenSpuService.getInnerSpuPrintNames(productIds); if (result == null || !result.isOk() || result.getData() == null) return Map.of(); - Map names = new LinkedHashMap<>(); + Map 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 printNames) { - if (car.getProductId() == null) return car.getProductName(); - return printNames.getOrDefault(car.getProductId(), car.getProductName()); + private static PrintProductName resolvePrintName(HardCarVo car, Map printNames) { + PrintProductName live = car.getProductId() == null ? null : printNames.get(car.getProductId()); + return live != null ? live : new PrintProductName(null, StrUtil.trimToNull(car.getProductName())); + } + + /** + * 打印凭证商品名称:套餐名(comboName)优先、商品名(name)兜底。 + * + *

主行取套餐名,套餐名为空时商品名沿用主行大字样式;副行仅在有套餐名且与商品名不同时 + * 输出商品名小字,两个名称完全相同则不重复显示。

+ * + * @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 comboItems) { + record VoucherProductLine(Long productId, String comboName, String name, int quantity, long unitPrice, + long subtotal, boolean combo, List 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 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 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 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 componentItems) { + image.voucherProduct(name, subName, quantity, unitPrice, subtotal, combo, componentItems); + command.voucherProduct(name, subName, quantity, unitPrice, subtotal, combo, componentItems); return this; } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java index 11895dc8..35d1093a 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java @@ -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本方法不开启事务,逐条执行的锁与事务边界由 {@link #execute} 及其内部的 + * {@code getSelf()} 代理调用自行管理;集群重复触发由订单 Redis 锁和任务行锁最终串行。

+ * + * @param limit 单轮最多扫描任务数量,避免配置和钱包服务突发压力 + * @return 本轮扫描到并尝试执行的任务数量 + */ + @Override + public int retryDueTasks(int limit) { + List 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 和尝试日志共同使用的稳定退款业务号。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjLogServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjLogServiceImpl.java index 068d4089..5bf43202 100755 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjLogServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjLogServiceImpl.java @@ -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先统计超期行数,累计不足一万条时跳过删除,避免每天小批量清理;达到阈值后按同一时间条件 + * 物理删除。统计与删除之间存在窗口,行数略有出入不影响正确性;重复触发时第二次删除自然为空。

+ * + * @return 本轮删除的日志行数,未达删除阈值时为零 + */ + @Override + public int cleanupExpiredLogs() { + LocalDateTime before = LocalDate.now().minusDays(30).atStartOfDay(); + // 删除30天前的数据;低于阈值时直接跳过 + Long count = count(new LambdaQueryWrapper().le(HardXfjLogEntity::getCreateTime, before)); + if (count >= 10000) { + return baseMapper.delete(new LambdaQueryWrapper() + .le(HardXfjLogEntity::getCreateTime, before)); + } + return 0; + } + +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java index 16047ea5..0055333b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java @@ -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 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 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 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"); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java index 25b32d58..df6ab687 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java @@ -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"); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java deleted file mode 100644 index 3c743815..00000000 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java +++ /dev/null @@ -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()); - } -} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJobTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensatorTest.java similarity index 82% rename from pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJobTest.java rename to pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensatorTest.java index 0294a7e8..f72ebcca 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJobTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensatorTest.java @@ -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()); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java index 09839263..703365d9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java @@ -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 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 names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car)); + Map 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 names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car)); + Map 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 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 names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car)); + Map 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 names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car)); + Map 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() { diff --git a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml index 184880c3..9b1dc84a 100644 --- a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml +++ b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml @@ -32,6 +32,12 @@ pjcloud-wallet-api 5.7.0 + + + com.bsd + pjcloud-hardware-api + 5.7.0 + com.alibaba.cloud diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/HardScheduleTask.java b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/HardScheduleTask.java new file mode 100644 index 00000000..93c3c9c4 --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/HardScheduleTask.java @@ -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 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 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 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 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 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 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 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 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(); + } +}