Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-09-14 18:35:52 +08:00
commit c6f3a7e1fc
53 files changed
+3548 -226

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
-- 每日菜谱预约订单过期未核销自动核销。
-- 幂等迁移脚本:可反复执行。每个字段/索引先查 information_schema,已存在则跳过(执行 SELECT 1 占位),
-- 不存在才执行 DDL;部分应用过的库重跑不会报 Duplicate column / Duplicate key。
-- 前置条件:20260822(wallet 配置列、订单列)与 20260823(订单核销时间列)的锚点列需已存在,
-- 否则对应语句会因 AFTER 锚点缺失报错——这是有意的失败提示,请先补齐前置迁移。
-- 任务表部分兼容 20260822 任务表语句未成功落库(scene_biz_id 缺失)的环境,会自动补齐。
-- 权限复用 wallet_mchManage_setting、hard_hardAfterSale_view、hard_hardAfterSale_adminRefund,无需新增 sys_menu 记录。
-- 【可选诊断】任务表当前列与索引,执行后可对照确认脚本将补齐的内容。
SELECT COLUMN_NAME, COLUMN_TYPE, IFNULL(GENERATION_EXPRESSION, '') AS generation
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware'
AND TABLE_NAME = 'hard_auto_refund_task'
AND COLUMN_NAME IN ('task_type', 'trigger_time', 'restore_inventory', 'scene_biz_id')
ORDER BY ORDINAL_POSITION;
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS index_columns
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware'
AND TABLE_NAME = 'hard_auto_refund_task'
GROUP BY INDEX_NAME
ORDER BY INDEX_NAME;
-- 一、Wallet 配置列(三列各自独立判断)。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config` ADD COLUMN `auto_verify_enabled` char(1) NOT NULL DEFAULT ''0'' COMMENT ''过期未核销自动核销开关,0关闭,1开启'' AFTER `expired_reserve_refund_restore_inventory`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_auto_refund_config' AND COLUMN_NAME = 'auto_verify_enabled');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config` ADD COLUMN `auto_verify_days` int(11) DEFAULT NULL COMMENT ''支付后自动核销天数,允许1至365,开关开启时必填'' AFTER `auto_verify_enabled`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_auto_refund_config' AND COLUMN_NAME = 'auto_verify_days');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config` ADD COLUMN `auto_verify_restore_inventory` char(1) NOT NULL DEFAULT ''1'' COMMENT ''自动核销是否恢复线上池库存,0不恢复,1恢复'' AFTER `auto_verify_days`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_auto_refund_config' AND COLUMN_NAME = 'auto_verify_restore_inventory');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 二、订单快照与核销方式列(四列各自独立判断)。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD COLUMN `auto_verify_config_id` bigint(20) DEFAULT NULL COMMENT ''支付成功时命中的自动核销配置ID快照,为空表示不参与自动核销'' AFTER `expired_refund_restore_inventory`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND COLUMN_NAME = 'auto_verify_config_id');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD COLUMN `auto_verify_deadline` datetime DEFAULT NULL COMMENT ''支付时间加配置天数后的自动核销触发时间'' AFTER `auto_verify_config_id`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND COLUMN_NAME = 'auto_verify_deadline');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD COLUMN `auto_verify_restore_inventory` char(1) DEFAULT NULL COMMENT ''自动核销退库存快照,0不恢复,1恢复线上池库存'' AFTER `auto_verify_deadline`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND COLUMN_NAME = 'auto_verify_restore_inventory');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD COLUMN `verification_type` char(1) DEFAULT NULL COMMENT ''核销方式,1人工核销,2自动核销;历史已核销订单为空视为人工'' AFTER `verification_time`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND COLUMN_NAME = 'verification_type');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 三、任务表:兼容 20260822 任务表语句未落库的环境,先确保任务场景列存在(幂等 MODIFY 注释与空值语义)。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD COLUMN `task_type` char(10) NOT NULL DEFAULT ''10'' COMMENT ''任务场景,10用户免审售后,20过期预约退款,30过期未核销自动核销'' AFTER `after_sale_id`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND COLUMN_NAME = 'task_type');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD COLUMN `trigger_time` datetime DEFAULT NULL COMMENT ''过期退款和自动核销任务按订单快照触发时间,旧售后任务为空'' AFTER `eligibility_deadline`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND COLUMN_NAME = 'trigger_time');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD COLUMN `restore_inventory` char(1) DEFAULT NULL COMMENT ''任务退库存快照,0不恢复,1恢复'' AFTER `trigger_time`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND COLUMN_NAME = 'restore_inventory');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 幂等注释与空值语义修正(重复执行无副作用,兼为 20260822 未落库环境补齐)。
ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task`
MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期退款和自动核销任务为空',
MODIFY COLUMN `rule_type` varchar(32) DEFAULT NULL COMMENT '普通售后任务规则快照,过期预约任务为空',
MODIFY COLUMN `eligibility_deadline` datetime DEFAULT NULL COMMENT '普通售后免审资格截止时间,过期预约任务为空',
MODIFY COLUMN `task_type` char(10) NOT NULL DEFAULT '10'
COMMENT '任务场景,10用户免审售后,20过期预约退款,30过期未核销自动核销',
MODIFY COLUMN `status` char(10) NOT NULL
COMMENT '任务状态,10待执行,20等待重试,30开关关闭暂停,31配置不可用暂停,40重试耗尽,50成功,60订单已终态跳过';
-- scene_biz_id 生成列:缺失则新增(表达式含自动核销场景 30),已存在则 MODIFY 刷新表达式。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD COLUMN `scene_biz_id` bigint(20) GENERATED ALWAYS AS (CASE WHEN `task_type` IN (''20'', ''30'') THEN `order_id` ELSE `after_sale_id` END) STORED COMMENT ''任务场景业务唯一值,系统生成,禁止业务代码写入'' AFTER `restore_inventory`',
'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` MODIFY COLUMN `scene_biz_id` bigint(20) GENERATED ALWAYS AS (CASE WHEN `task_type` IN (''20'', ''30'') THEN `order_id` ELSE `after_sale_id` END) STORED COMMENT ''任务场景业务唯一值,系统生成,禁止业务代码写入''')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND COLUMN_NAME = 'scene_biz_id');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 新建场景唯一键前应确认本查询返回 0 行;存在重复说明历史任务数据冲突,必须先人工核对,禁止自动清理。
SELECT tenant_id, task_type, del_flag,
CASE WHEN task_type = '20' THEN order_id ELSE after_sale_id END AS scene_biz_id,
COUNT(*) AS duplicate_count
FROM pjcloudx_hardware.hard_auto_refund_task
GROUP BY tenant_id, task_type, del_flag,
CASE WHEN task_type = '20' THEN order_id ELSE after_sale_id END
HAVING COUNT(*) > 1;
-- 旧售后单唯一键:存在才删除,给场景唯一键让位。
SET @ddl = (SELECT IF(COUNT(*) > 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` DROP INDEX `uk_hard_auto_refund_task_after_sale`',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND INDEX_NAME = 'uk_hard_auto_refund_task_after_sale');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 场景唯一键:缺失才创建(存在重复数据时本语句会失败,先处理上方诊断结果)。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD UNIQUE KEY `uk_hard_auto_refund_task_scene_biz` (`tenant_id`, `task_type`, `scene_biz_id`, `del_flag`)',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND INDEX_NAME = 'uk_hard_auto_refund_task_scene_biz');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 扫描索引:缺失才创建。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD KEY `idx_hard_auto_refund_task_scan_type` (`tenant_id`, `task_type`, `status`, `next_retry_time`, `del_flag`)',
'SELECT 1')
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND INDEX_NAME = 'idx_hard_auto_refund_task_scan_type');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 四、尝试日志注释更新。幂等,任何状态都可执行。
ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_attempt_log`
MODIFY COLUMN `result_status` char(10) NOT NULL
COMMENT '尝试结果,10执行中,20成功,30失败,40因开关关闭跳过,50订单已终态跳过',
MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL
COMMENT '售后单ID,过期退款和自动核销任务为空';
-- 【验收】生成列表达式应包含 '20' 和 '30',且三张表的新列均已存在。
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, IFNULL(GENERATION_EXPRESSION, '') AS generation
FROM information_schema.COLUMNS
WHERE (TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND COLUMN_NAME = 'scene_biz_id')
OR (TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_auto_refund_config' AND COLUMN_NAME LIKE 'auto_verify_%')
OR (TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND COLUMN_NAME = 'verification_type');
SET @ddl = NULL;
@@ -0,0 +1,40 @@
-- 钱包开卡表抢卡死锁治理索引。
--
-- 适用接口:GET /wallet-manage/create-wallet(用户批量导入触发的并发创建钱包+自动绑卡)等 createAndActiveCard 路径。
-- 背景:抢卡 SQL 按 merchant_id = ? + user_id IS NULL 过滤并 ORDER BY card_sort LIMIT 1 FOR UPDATE;
-- 原表仅有主键和 (user_id) 单列索引,锁定读需经索引扫描全部空闲卡再 filesort,
-- 实际锁范围覆盖全表空闲卡直到事务提交,并与自动补卡的批量 INSERT 插入意向锁互相等待,
-- 大批量用户导入并发建钱包时产生集中死锁。
-- 索引效果:范围收敛为单商户空闲卡区间且天然按 card_sort 有序,FOR UPDATE 免排序、取满 LIMIT 1 即停止,
-- 每笔事务仅锁定队首一张卡;同时加速绑卡前的空闲卡余量 COUNT。
-- 执行影响:仅新增普通索引,不修改字段、约束和历史数据;建索引期间消耗 IO,建议业务低峰期执行。
-- 执行前建议:先备份数据库,并用文末查询核对生产库 wallet_active 实际已有索引。
SET @index_ddl = IF(
EXISTS (
SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'wallet_active'
AND index_name = 'idx_wallet_active_merchant_user_sort'
),
'SELECT ''idx_wallet_active_merchant_user_sort already exists'' AS message',
'ALTER TABLE `wallet_active` ADD INDEX `idx_wallet_active_merchant_user_sort` (`merchant_id`, `user_id`, `card_sort`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
-- 执行后核对新增索引;确认与既有 idx_dept_id(user_id) 无同前缀冗余。
SELECT table_name, index_name, seq_in_index, column_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'wallet_active'
ORDER BY table_name, index_name, seq_in_index;
-- 建议执行后更新统计信息,并用真实商户参数验证抢卡 SQL 计划不再 filesort:
-- ANALYZE TABLE wallet_active;
-- EXPLAIN SELECT id, ... FROM wallet_active
-- WHERE del_flag = '0' AND user_id IS NULL AND wallet_id IS NULL
-- AND merchant_id = ? AND is_active_hardware = '0' AND tenant_id = ?
-- ORDER BY card_sort ASC LIMIT 1 FOR UPDATE;
@@ -0,0 +1,2 @@
-- 钱包充值 Excel 导入模板新增"备注"列:行级备注先落库到导入明细,审批通过后写入钱包流水 remark
ALTER TABLE wallet_recharge_import ADD COLUMN remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符';
@@ -0,0 +1,46 @@
package com.bsd.pjcloud.hard.api.constant.order;
import lombok.Getter;
import java.util.Arrays;
/**
* 预约订单核销方式。编码持久化到 hard_order.verification_type,用于区分人工核销与系统自动核销;
* 字段引入前已核销的历史订单该列为空,展示和统计时按人工核销处理。
*/
@Getter
public enum HardOrderVerificationTypeEnum {
/** 人工核销:POS 扫码、App/管理端手动核销和批量核销等由工作人员主动触发的核销。 */
MANUAL("1", "人工核销"),
/** 自动核销:自动核销任务在支付后超过配置天数仍未核销时由系统静默完成。 */
AUTO("2", "自动核销");
/** 数据库存储的稳定核销方式编码。 */
private final String type;
/** 管理端展示名称。 */
private final String description;
/**
* 构造核销方式协议项。
*
* @param type 数据库存储编码
* @param description 展示名称
*/
HardOrderVerificationTypeEnum(String type, String description) {
this.type = type;
this.description = description;
}
/**
* 按持久化编码解析核销方式。
*
* @param type 数据库存储编码
* @return 匹配的核销方式;空值或未知编码返回 {@code null}
*/
public static HardOrderVerificationTypeEnum valueOfByType(String type) {
return Arrays.stream(values()).filter(item -> item.type.equals(type)).findFirst().orElse(null);
}
}
@@ -26,7 +26,12 @@ public enum HardAutoRefundAttemptResultEnum {
/**
* 执行前发现当前开关关闭,未调用资金接口且不消耗自动尝试额度。
*/
SWITCH_DISABLED("40", "因开关关闭跳过");
SWITCH_DISABLED("40", "因开关关闭跳过"),
/**
* 自动核销任务执行前发现订单已人工核销、已退款或已取消,本次未执行核销且任务进入终态。
*/
SKIPPED("50", "订单已终态跳过");
/**
* 数据库存储的稳定结果编码。
@@ -38,7 +38,12 @@ public enum HardAutoRefundTaskStatusEnum {
/**
* 钱包已幂等到账,售后和订单退款聚合状态均已完成更新。
*/
SUCCESS("50", "成功");
SUCCESS("50", "成功"),
/**
* 自动核销任务执行时订单已人工核销、已退款或已取消等终态,无需再核销;任务到此结束。
*/
SKIPPED("60", "订单已终态跳过");
/**
* 数据库和接口使用的稳定状态编码。
@@ -12,7 +12,10 @@ public enum HardAutoRefundTaskTypeEnum {
AFTER_SALE("10", "售后申请自动退款"),
/** 每日菜谱预约餐段结束后创建的系统过期退款任务。 */
EXPIRED_RESERVE("20", "过期预约自动退款");
EXPIRED_RESERVE("20", "过期预约自动退款"),
/** 每日菜谱预约订单支付后超过配置天数未核销时创建的系统自动核销任务。 */
AUTO_VERIFY("30", "过期未核销自动核销");
/** 数据库存储的稳定任务场景编码。 */
private final String type;
@@ -31,7 +31,12 @@ public enum HardAutoRefundTriggerTypeEnum {
/**
* 管理端按商户当前配置立即重扫历史订单后触发的自动退款,失败后继续进入既有自动重试状态机。
*/
EXPIRED_RESCAN("50", "后台立即重新扫描", true);
EXPIRED_RESCAN("50", "后台立即重新扫描", true),
/**
* 管理端业务人员在自动核销候选列表确认后逐单立即核销;核销动作仍走自动核销链路并标记为自动核销。
*/
AUTO_VERIFY_MANUAL("60", "后台逐单自动核销", false);
/**
* 数据库存储的稳定触发编码。
@@ -175,6 +175,18 @@ public class HardOrderEntity extends Model<HardOrderEntity> {
@Schema(description = "过期预约退款退库存快照")
private String expiredRefundRestoreInventory;
/** 自动核销配置 ID 快照;仅支付成功且当时开关开启的每日菜谱预约订单写入,为空表示不参与自动核销。 */
@Schema(description = "自动核销配置ID快照")
private Long autoVerifyConfigId;
/** 支付时间加配置天数后的自动核销触发时间;任务按该时间到期执行。 */
@Schema(description = "自动核销触发时间")
private LocalDateTime autoVerifyDeadline;
/** 自动核销是否恢复每日菜谱线上池库存的订单快照,1恢复、0不恢复。 */
@Schema(description = "自动核销退库存快照")
private String autoVerifyRestoreInventory;
/**
* 订单设备类型
*/
@@ -241,6 +253,13 @@ public class HardOrderEntity extends Model<HardOrderEntity> {
@Schema(description = "订单核销时间")
private LocalDateTime verificationTime;
/**
* 核销方式快照,对应 {@code HardOrderVerificationTypeEnum}:1人工核销、2自动核销;
* 字段引入前已核销的历史订单为空,按人工核销处理;退回核销前状态时清空。
*/
@Schema(description = "核销方式,1人工核销,2自动核销")
private String verificationType;
/**
* 订单取消时间(退款订单)
*/
@@ -56,6 +56,20 @@ public class ThermalReceiptImage {
/** 下一块内容的顶部 Y 坐标。 */
private int cursorY = VERTICAL_PADDING;
/** 紧凑版式开关;开启后全部文字比标准版式小一号,非商品区行距和分隔线间距收紧。 */
private boolean compactFonts = false;
/**
* 切换紧凑版式,发票与兑换券启用后全部文字小一号,且非商品区的行距、分隔线与文字间距收紧。
*
* @param enabled {@code true} 时使用紧凑字号与间距
* @return 当前绘制器
*/
public ThermalReceiptImage compactFonts(boolean enabled) {
this.compactFonts = enabled;
return this;
}
/**
* 创建 576px 宽的白底收据画布。
*/
@@ -75,8 +89,8 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage title(String text) {
setFont(Font.BOLD, 42);
drawCentered(text, 16);
setFont(Font.BOLD, compactFonts ? 31 : 42);
drawCentered(text, compactFonts ? 10 : 16);
return this;
}
@@ -87,12 +101,13 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage banner(String text) {
setFont(Font.BOLD, 44);
setFont(Font.BOLD, compactFonts ? 31 : 44);
FontMetrics metrics = graphics.getFontMetrics();
List<String> lines = wrap(text == null ? "" : text, contentWidth() - 32, metrics);
int lineHeight = metrics.getHeight() + 4;
int height = lines.size() * lineHeight + 26;
ensureCapacity(height + 20);
int height = lines.size() * lineHeight + (compactFonts ? 20 : 26);
int bottomGap = compactFonts ? 14 : 20;
ensureCapacity(height + bottomGap);
graphics.setStroke(new BasicStroke(3F));
graphics.drawRect(HORIZONTAL_PADDING, cursorY, contentWidth(), height);
for (int index = 0; index < lines.size(); index++) {
@@ -100,7 +115,7 @@ public class ThermalReceiptImage {
int x = HORIZONTAL_PADDING + Math.max(0, (contentWidth() - metrics.stringWidth(line)) / 2);
graphics.drawString(line, x, cursorY + 13 + index * lineHeight + metrics.getAscent());
}
cursorY += height + 20;
cursorY += height + bottomGap;
return this;
}
@@ -111,8 +126,8 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage verificationCode(String shortCode) {
setFont(Font.BOLD, 64);
drawCentered(shortCode == null || shortCode.isBlank() ? "--" : shortCode, 18);
setFont(Font.BOLD, compactFonts ? 48 : 64);
drawCentered(shortCode == null || shortCode.isBlank() ? "--" : shortCode, compactFonts ? 12 : 18);
return this;
}
@@ -124,7 +139,7 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage keyValue(String label, String value) {
setFont(Font.PLAIN, 33);
setFont(Font.PLAIN, compactFonts ? 24 : 33);
String safeLabel = label == null ? "" : label;
String safeValue = value == null || value.isBlank() ? "--" : value;
FontMetrics metrics = graphics.getFontMetrics();
@@ -134,7 +149,8 @@ public class ThermalReceiptImage {
int valueWidth = stackValue ? contentWidth() : Math.max(120, inlineValueWidth);
List<String> lines = wrap(safeValue, valueWidth, metrics);
int valueLineOffset = stackValue ? 1 : 0;
int lineHeight = metrics.getHeight() + 6;
int lineGap = compactFonts ? 3 : 6;
int lineHeight = metrics.getHeight() + lineGap;
ensureCapacity((lines.size() + valueLineOffset) * lineHeight + 10);
graphics.drawString(safeLabel + ":", HORIZONTAL_PADDING, cursorY + metrics.getAscent());
for (int index = 0; index < lines.size(); index++) {
@@ -142,7 +158,7 @@ public class ThermalReceiptImage {
graphics.drawString(lines.get(index), x,
cursorY + (index + valueLineOffset) * lineHeight + metrics.getAscent());
}
cursorY += (lines.size() + valueLineOffset) * lineHeight + 6;
cursorY += (lines.size() + valueLineOffset) * lineHeight + lineGap;
return this;
}
@@ -153,10 +169,10 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage sectionTitle(String text) {
setFont(Font.BOLD, 36);
setFont(Font.BOLD, compactFonts ? 26 : 36);
ensureCapacity(60);
graphics.drawString(text == null ? "" : text, HORIZONTAL_PADDING, cursorY + graphics.getFontMetrics().getAscent());
cursorY += graphics.getFontMetrics().getHeight() + 10;
cursorY += graphics.getFontMetrics().getHeight() + (compactFonts ? 6 : 10);
return this;
}
@@ -181,13 +197,17 @@ public class ThermalReceiptImage {
String safeOrderNo = orderNo == null || orderNo.isBlank() ? "--" : orderNo;
String summary = "订餐日期:" + (orderDate == null || orderDate.isBlank() ? "--" : orderDate)
+ " 餐段:" + (mealName == null || mealName.isBlank() ? "--" : mealName);
int tagFontSize = compactFonts ? 22 : 25;
int orderFontSize = compactFonts ? 26 : 31;
int summaryFontSize = compactFonts ? 24 : 29;
int headerGap = compactFonts ? 6 : 10;
setFont(Font.BOLD, 25);
setFont(Font.BOLD, tagFontSize);
FontMetrics tagMetrics = graphics.getFontMetrics();
int tagWidth = tagMetrics.stringWidth(tag) + 20;
int tagHeight = tagMetrics.getHeight() + 6;
setFont(Font.BOLD, 31);
setFont(Font.BOLD, orderFontSize);
FontMetrics orderMetrics = graphics.getFontMetrics();
int orderX = HORIZONTAL_PADDING + tagWidth + 14;
int orderWidth = PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - orderX;
@@ -195,30 +215,30 @@ public class ThermalReceiptImage {
int orderLineHeight = orderMetrics.getHeight() + 4;
int headerHeight = Math.max(tagHeight, orderLines.size() * orderLineHeight);
setFont(Font.PLAIN, 29);
setFont(Font.PLAIN, summaryFontSize);
FontMetrics summaryMetrics = graphics.getFontMetrics();
List<String> summaryLines = wrap(summary, contentWidth(), summaryMetrics);
int summaryLineHeight = summaryMetrics.getHeight() + 4;
int separatorHeight = safeIndex < safeTotal ? 18 : 8;
int blockHeight = headerHeight + 10 + summaryLines.size() * summaryLineHeight + separatorHeight;
int separatorHeight = safeIndex < safeTotal ? (compactFonts ? 12 : 18) : (compactFonts ? 6 : 8);
int blockHeight = headerHeight + headerGap + summaryLines.size() * summaryLineHeight + separatorHeight;
ensureCapacity(blockHeight);
int blockTop = cursorY;
graphics.setColor(Color.BLACK);
setFont(Font.BOLD, 25);
setFont(Font.BOLD, tagFontSize);
graphics.fillRect(HORIZONTAL_PADDING, blockTop, tagWidth, tagHeight);
graphics.setColor(Color.WHITE);
graphics.drawString(tag, HORIZONTAL_PADDING + 10, blockTop + 3 + tagMetrics.getAscent());
graphics.setColor(Color.BLACK);
setFont(Font.BOLD, 31);
setFont(Font.BOLD, orderFontSize);
for (int lineIndex = 0; lineIndex < orderLines.size(); lineIndex++) {
graphics.drawString(orderLines.get(lineIndex), orderX,
blockTop + lineIndex * orderLineHeight + orderMetrics.getAscent());
}
int summaryTop = blockTop + headerHeight + 10;
setFont(Font.PLAIN, 29);
int summaryTop = blockTop + headerHeight + headerGap;
setFont(Font.PLAIN, summaryFontSize);
for (int lineIndex = 0; lineIndex < summaryLines.size(); lineIndex++) {
graphics.drawString(summaryLines.get(lineIndex), HORIZONTAL_PADDING,
summaryTop + lineIndex * summaryLineHeight + summaryMetrics.getAscent());
@@ -259,10 +279,7 @@ public class ThermalReceiptImage {
}
/**
* 绘制兑换券商品卡片,使用文字标签和边框粗细同时区分组合商品与单品。
*
* <p>商品名称和组合内容均按真实像素宽度完整换行;价格区固定展示单价、数量和小计。
* 组合内容只解释套餐构成,不参与当前卡片金额计算。</p>
* 绘制兑换券商品卡片,数量为整数份数。
*
* @param name 商品快照名称;空值显示“未命名商品”
* @param quantity 部分退款后的剩余可履约数量
@@ -274,33 +291,55 @@ public class ThermalReceiptImage {
*/
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);
}
/**
* 绘制兑换券商品卡片,数量为已格式化文本,供兑换券与发票共用同一卡片版式。
*
* <p>数量文本由调用方按业务格式化,例如按份数的“x2”或称重的“0.5/kg”,绘制时原样展示。</p>
*
* @param name 商品快照名称;空值显示“未命名商品”
* @param quantity 数量展示文本,例如“x2”或“0.5/kg”
* @param unitPrice 商品快照单价,单位元且不含货币符号
* @param subtotal 商品小计,单位元且不含货币符号
* @param combo 是否为组合商品
* @param componentItems 组合商品组成说明,每项应已包含对应数量;单品或无组成时可为空
* @return 当前绘制器
*/
public ThermalReceiptImage voucherProduct(String name, String quantity, String unitPrice, String subtotal,
boolean combo, List<String> componentItems) {
String typeLabel = combo ? "组合商品" : "单品";
String safeName = name == null || name.isBlank() ? "未命名商品" : name;
String safeQuantity = quantity == null || quantity.isBlank() ? "x0" : quantity;
String safeUnitPrice = unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice;
String safeSubtotal = subtotal == null || subtotal.isBlank() ? "--" : subtotal;
List<String> safeComponents = componentItems == null ? List.of() : componentItems.stream()
.filter(item -> item != null && !item.isBlank()).toList();
int innerWidth = contentWidth() - PRODUCT_CARD_PADDING * 2;
int tagFontSize = compactFonts ? 25 : 28;
int nameFontSize = compactFonts ? 36 : 40;
int priceFontSize = compactFonts ? 26 : 29;
setFont(Font.BOLD, 28);
setFont(Font.BOLD, tagFontSize);
FontMetrics tagMetrics = graphics.getFontMetrics();
int tagHeight = tagMetrics.getHeight() + 6;
int tagWidth = tagMetrics.stringWidth(typeLabel) + 20;
setFont(Font.BOLD, 40);
setFont(Font.BOLD, nameFontSize);
FontMetrics nameMetrics = graphics.getFontMetrics();
List<String> nameLines = wrap(safeName, innerWidth, nameMetrics);
int nameLineHeight = nameMetrics.getHeight() + 4;
setFont(Font.PLAIN, 29);
setFont(Font.PLAIN, priceFontSize);
FontMetrics priceMetrics = graphics.getFontMetrics();
String priceAndQuantity = "单价 ¥" + safeUnitPrice + " x" + Math.max(0, quantity);
String priceAndQuantity = "单价 ¥" + safeUnitPrice + " " + safeQuantity;
String subtotalText = "小计 ¥" + safeSubtotal;
boolean splitPriceLine = priceMetrics.stringWidth(priceAndQuantity)
+ priceMetrics.stringWidth(subtotalText) + 24 > innerWidth;
int priceLineHeight = priceMetrics.getHeight() + 4;
setFont(Font.PLAIN, 29);
setFont(Font.PLAIN, priceFontSize);
FontMetrics componentMetrics = graphics.getFontMetrics();
int componentLineHeight = componentMetrics.getHeight() + 3;
List<List<String>> componentLines = new ArrayList<>();
@@ -322,7 +361,7 @@ public class ThermalReceiptImage {
graphics.setStroke(new BasicStroke(combo ? 4F : 2F));
graphics.drawRect(HORIZONTAL_PADDING, cardTop, contentWidth(), cardHeight);
setFont(Font.BOLD, 28);
setFont(Font.BOLD, tagFontSize);
if (combo) {
graphics.fillRect(contentX, contentY, tagWidth, tagHeight);
graphics.setColor(Color.WHITE);
@@ -335,14 +374,14 @@ public class ThermalReceiptImage {
}
contentY += tagHeight + 10;
setFont(Font.BOLD, 40);
setFont(Font.BOLD, nameFontSize);
for (String line : nameLines) {
graphics.drawString(line, contentX, contentY + nameMetrics.getAscent());
contentY += nameLineHeight;
}
contentY += 8;
setFont(Font.PLAIN, 29);
setFont(Font.PLAIN, priceFontSize);
graphics.drawString(priceAndQuantity, contentX, contentY + priceMetrics.getAscent());
if (splitPriceLine) {
contentY += priceLineHeight;
@@ -359,10 +398,10 @@ public class ThermalReceiptImage {
graphics.drawLine(contentX, contentY,
PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - PRODUCT_CARD_PADDING, contentY);
contentY += 10;
setFont(Font.BOLD, 29);
setFont(Font.BOLD, priceFontSize);
graphics.drawString("组合内容", contentX, contentY + componentMetrics.getAscent());
contentY += componentLineHeight;
setFont(Font.PLAIN, 29);
setFont(Font.PLAIN, priceFontSize);
for (List<String> lines : componentLines) {
for (String line : lines) {
graphics.drawString(line, contentX + CHILD_INDENT,
@@ -385,20 +424,21 @@ public class ThermalReceiptImage {
String safeAmount = totalAmount == null || totalAmount.isBlank() ? "--" : totalAmount;
String label = "商品合计";
String amount = "¥" + safeAmount;
setFont(Font.BOLD, 42);
setFont(Font.BOLD, compactFonts ? 31 : 42);
FontMetrics metrics = graphics.getFontMetrics();
int blockHeight = metrics.getHeight() + 30;
ensureCapacity(blockHeight + 14);
int blockHeight = metrics.getHeight() + (compactFonts ? 24 : 30);
int bottomGap = compactFonts ? 10 : 14;
ensureCapacity(blockHeight + bottomGap);
graphics.setColor(Color.BLACK);
graphics.setStroke(new BasicStroke(3F));
graphics.drawLine(HORIZONTAL_PADDING, cursorY,
PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY);
int baseline = cursorY + 15 + metrics.getAscent();
int baseline = cursorY + (compactFonts ? 12 : 15) + metrics.getAscent();
graphics.drawString(label, HORIZONTAL_PADDING, baseline);
graphics.drawString(amount, PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - metrics.stringWidth(amount), baseline);
graphics.drawLine(HORIZONTAL_PADDING, cursorY + blockHeight,
PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY + blockHeight);
cursorY += blockHeight + 14;
cursorY += blockHeight + bottomGap;
return this;
}
@@ -414,7 +454,8 @@ public class ThermalReceiptImage {
*/
private ThermalReceiptImage item(String name, String trailing, boolean child, boolean emphasized,
int trailingWidth) {
setFont(emphasized ? Font.BOLD : Font.PLAIN, child ? 30 : 34);
int fontSize = (child ? 30 : 34) - (compactFonts ? 3 : 0);
setFont(emphasized ? Font.BOLD : Font.PLAIN, fontSize);
FontMetrics metrics = graphics.getFontMetrics();
int indent = child ? CHILD_INDENT : 0;
String prefix = child ? "- " : "";
@@ -439,11 +480,12 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage divider() {
ensureCapacity(28);
int topGap = compactFonts ? 6 : 10;
ensureCapacity(topGap + 18);
graphics.setStroke(new BasicStroke(2F));
graphics.drawLine(HORIZONTAL_PADDING, cursorY + 10,
PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY + 10);
cursorY += 28;
graphics.drawLine(HORIZONTAL_PADDING, cursorY + topGap,
PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY + topGap);
cursorY += compactFonts ? 20 : 28;
return this;
}
@@ -0,0 +1,28 @@
package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 商户自动核销立即重扫结果,供管理端展示本轮快照刷新和任务补建数量。
*/
@Data
@Schema(description = "自动核销立即重扫结果")
public class HardAutoVerifyRescanResultVo {
/** 本轮读取的已支付、未核销且支付时间已超过配置天数的每日菜谱订单数量。 */
@Schema(description = "扫描订单数量")
private Integer scannedOrderCount;
/** 本轮首次补建的自动核销任务数量。 */
@Schema(description = "新建任务数量")
private Integer createdTaskCount;
/** 本轮按当前天数和退库存配置刷新触发时间的未尝试任务数量。 */
@Schema(description = "刷新任务数量")
private Integer refreshedTaskCount;
/** 因订单已终态、存在在途售后或任务已产生尝试而未同步的订单数量。 */
@Schema(description = "跳过订单数量")
private Integer skippedOrderCount;
}
@@ -0,0 +1,23 @@
package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
/**
* 预约菜谱日历地图的单日聚合:按用餐日统计未退款预约订单的核销情况。
*/
@Data
@Schema(description = "预约菜谱日历地图单日聚合")
public class WardMenuMapDayVo {
@Schema(description = "用餐日")
private LocalDate apptUseDate;
@Schema(description = "待核销预约单数(订单状态 5)")
private Integer pendingCount;
@Schema(description = "已核销预约单数(订单状态 20)")
private Integer verifiedCount;
}
@@ -10,10 +10,12 @@ import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoVerifyRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
import com.bsd.pjcloud.hard.service.HardAutoVerifyService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -49,6 +51,11 @@ public class HardAutoRefundTaskController {
*/
private final HardAutoRefundTaskService taskService;
/**
* 自动核销服务,负责商户维度的历史订单重扫补建任务。
*/
private final HardAutoVerifyService hardAutoVerifyService;
/**
* 售后服务,用于 App 查询任务前复用订单查看授权。
*/
@@ -126,6 +133,51 @@ public class HardAutoRefundTaskController {
return R.ok(true);
}
/**
* 按商户详情页传入的精确商户 ID 查询自动核销候选任务:包含尚未到期任务,订单仍处于待核销状态。
*
* @param page 分页参数
* @param dto 候选查询条件,其中商户 ID 必填
* @return 当前商户的待自动核销订单分页
*/
@GetMapping("/auto-verify-candidates")
@Operation(summary = "查询待自动核销订单")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
public R<Page<HardAutoRefundTaskVo>> autoVerifyCandidates(@ParameterObject Page<HardAutoRefundTaskEntity> page, @ParameterObject HardAutoRefundTaskPageDto dto) {
validateMerchantScope(dto.getMchId());
// 详情接口只能查询一个已校验商户,禁止客户端利用内部批量字段扩大查询范围。
dto.setMchIds(null);
return R.ok(taskService.pageVerifyCandidates(page, dto));
}
/**
* 按商户当前自动核销配置立即重扫历史待核销订单;开关关闭时只同步暂停任务,开关开启时执行已到期任务。
*
* @param mchId 商户详情页传入的精确商户 ID
* @return 本轮扫描、任务同步和跳过数量
*/
@PostMapping("/auto-verify-rescan")
@Operation(summary = "立即重新扫描自动核销订单")
@Idempotent(expireTime = 5, info = "正在重新扫描,请勿重复操作")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
public R<HardAutoVerifyRescanResultVo> autoVerifyRescan(@RequestParam Long mchId) {
validateMerchantScope(mchId);
return R.ok(hardAutoVerifyService.rescanAutoVerifyOrders(mchId, SecurityUtils.getUser().getId()));
}
/**
* 管理端确认后对自动核销候选任务逐单立即核销;核销方式仍标记为自动核销,且不允许到期前限制。
*/
@PostMapping("/{id}/auto-verify-now")
@Operation(summary = "自动核销候选订单立即核销")
@Idempotent(expireTime = 5, info = "正在核销,请勿重复操作")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
public R<Boolean> autoVerifyNow(@PathVariable Long id) {
validateTaskScope(id);
hardAutoVerifyService.manualAutoVerify(id, SecurityUtils.getUser().getId());
return R.ok(true);
}
/**
* 查询指定任务全部只读尝试日志。
*
@@ -45,6 +45,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo;
import com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO;
import com.bsd.pjcloud.hard.api.vo.order.WardMenuMapDayVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
@@ -70,6 +71,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -175,6 +177,55 @@ public class HardOrderController {
return R.ok(orders);
}
@Operation(summary = "app查询预约菜谱日历地图", description = "按日期范围聚合本人或被监护人每天的预约核销情况,整单退款的订单不展示")
@ApiCryptoAes
@GetMapping("/app/ward-menu-map")
public R<List<WardMenuMapDayVo>> wardMenuMap(@RequestParam(required = false) List<Long> dinerUserIds,
@RequestParam String startDate,
@RequestParam String endDate) {
HardOrderDto dto = new HardOrderDto();
PjcloudUser user = SecurityUtils.getUser();
dto.setUserIds(ObjectUtil.isEmpty(dinerUserIds)
? List.of(user.getId())
: hardwareOrderService.validateReserveDinerUserIds(user.getId(), dinerUserIds));
dto.setIsFindCarList(false);
dto.setIsFindApptList(true);
dto.setApptIdOrApptRuleIdNotNull(true);
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
// 只统计待核销(5)与已核销(20);退款中(10)、已取消(30)和未支付订单不进入地图。
dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()));
dto.setApptUseDateTimeList(new LocalDateTime[]{
LocalDate.parse(startDate).atStartOfDay(),
LocalDate.parse(endDate).atTime(23, 59, 59),
});
return R.ok(hardwareOrderService.wardMenuMap(dto));
}
@Operation(summary = "app查询预约菜谱某日订单明细", description = "查询某天本人或被监护人的预约订单明细,整单退款的订单不返回")
@ApiCryptoAes
@GetMapping("/app/ward-menu-day-orders")
public R<List<HardOrderVo>> wardMenuDayOrders(@RequestParam(required = false) List<Long> dinerUserIds,
@RequestParam String date) {
HardOrderDto dto = new HardOrderDto();
PjcloudUser user = SecurityUtils.getUser();
dto.setUserIds(ObjectUtil.isEmpty(dinerUserIds)
? List.of(user.getId())
: hardwareOrderService.validateReserveDinerUserIds(user.getId(), dinerUserIds));
dto.setIsFindCarList(true);
dto.setIsFindApptList(true);
dto.setApptIdOrApptRuleIdNotNull(true);
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
// 与日历地图同口径:只返回待核销(5)与已核销(20),整单退款在服务层过滤。
dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()));
dto.setApptUseDateTimeList(new LocalDateTime[]{
LocalDate.parse(date).atStartOfDay(),
LocalDate.parse(date).atTime(23, 59, 59),
});
List<HardOrderVo> orders = hardwareOrderService.wardMenuDayOrders(dto);
hardwareOrderService.applyAppPermissions(orders, user.getId());
return R.ok(orders);
}
@Operation(summary = "app查询订单列表(查询食堂、商城)", description = "app查询订单列表(查询食堂、商城)")
@ApiCryptoAes
@@ -67,6 +67,30 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper<HardAutoRefu
@Param("dto") HardAutoRefundTaskPageDto dto,
@Param("now") LocalDateTime now);
/**
* 按订单、父购物车状态联合过滤商户的自动核销候选任务:覆盖待执行、等待重试和暂停状态,
* 包含尚未到期的任务,订单必须仍处于待核销状态;已完成、跳过和重试耗尽任务不返回。
*
* @param page 分页参数
* @param dto 管理端筛选及服务端注入的商户范围
* @param now 当前服务端时间,仅用于过滤等待重试任务的到期条件
* @return 仍处于自动核销流程中的任务
*/
Page<HardAutoRefundTaskEntity> pageVerifyCandidates(Page<HardAutoRefundTaskEntity> page,
@Param("dto") HardAutoRefundTaskPageDto dto,
@Param("now") LocalDateTime now);
/**
* 查询指定商户仍处于已支付未核销、支付时间早于截止时间且没有处理中售后的每日菜谱钱包订单,
* 供自动核销立即重扫补建任务。部分退款完成的订单保留资格,不在此排除。
*
* @param mchId 精确商户 ID
* @param payTimeDeadline 支付时间截止点,通常为当前时间减去配置天数
* @return 按订单主键升序排列的可重扫订单
*/
List<HardOrderEntity> listAutoVerifyRescanOrders(@Param("mchId") Long mchId,
@Param("payTimeDeadline") LocalDateTime payTimeDeadline);
/**
* 查询指定商户仍处于已支付未核销状态且没有冲突售后的每日菜谱钱包订单,供立即重扫补建任务。
*
@@ -36,6 +36,14 @@ public interface HardAutoRefundTaskService extends IService<HardAutoRefundTaskEn
*/
HardAutoRefundTaskEntity createExpiredReserveTask(HardOrderEntity order);
/**
* 在每日菜谱订单支付成功事务内按订单自动核销快照创建任务;同一订单和场景保持幂等。
*
* @param order 已更新为支付成功且带自动核销快照的订单
* @return 已存在或新建的任务;订单不具备快照时返回空
*/
HardAutoRefundTaskEntity createAutoVerifyTask(HardOrderEntity order);
/**
* 注册事务提交后的同步首次执行;回调内部吞掉资金异常并持久化重试状态。
*
@@ -64,6 +72,15 @@ public interface HardAutoRefundTaskService extends IService<HardAutoRefundTaskEn
/** 查询已到期且仍可整单退款的每日菜谱预约候选任务。 */
Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto);
/**
* 分页查询商户的自动核销候选任务(含待执行、等待重试和暂停状态),供管理端跟踪核销进度。
*
* @param page 分页参数
* @param dto 服务端已注入商户数据范围的筛选条件
* @return 自动核销任务分页
*/
Page<HardAutoRefundTaskVo> pageVerifyCandidates(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto);
/**
* 按商户当前过期预约配置重扫历史已支付未核销订单,补建或刷新任务并立即执行已到期任务。
*
@@ -0,0 +1,48 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoVerifyRescanResultVo;
/**
* 过期未核销自动核销服务。负责任务尝试的业务执行(静默核销与可选退库存)和历史订单重扫补建任务;
* 任务状态机、调度扫描和重试仍由 {@link HardAutoRefundTaskService} 统一驱动。
*/
public interface HardAutoVerifyService {
/**
* 在任务状态机事务内执行一次自动核销:复用人工核销的订单行锁、履约校验、二维码消费和状态条件更新,
* 核销方式标记为自动;任务快照要求退库存时按剩余有效数量回补每日菜谱线上池。
* 本方法不创建售后单、不发生资金动作、不发推送与打印任务。
*
* @param task 已锁定且通过配置与订单预检的自动核销任务
*/
void completeAutoVerify(HardAutoRefundTaskEntity task);
/**
* completeAutoVerify 的触发时间开关版本:后台逐单立即核销允许提前关闭尚未到期订单,
* 此时跳过触发时间校验;自动调度仍必须校验,防止调度竞态提前核销。
*
* @param task 已锁定的自动核销任务
* @param enforceTriggerTime 是否强制校验任务触发时间已到期
*/
void completeAutoVerify(HardAutoRefundTaskEntity task, boolean enforceTriggerTime);
/**
* 管理端在自动核销候选列表逐单立即核销:核销动作仍走自动核销链路并标记为自动核销,
* 不校验触发时间(允许提前关闭尚未到期订单),也不受当前自动核销开关限制。
*
* @param taskId 自动核销任务 ID
* @param operatorUserId 当前后台操作用户 ID
*/
void manualAutoVerify(Long taskId, Long operatorUserId);
/**
* 按商户当前自动核销配置重扫历史已支付未核销订单,补建或刷新未尝试任务;
* 已到期任务立即进入核销状态机执行。部分退款完成的订单仍可自动核销剩余商品。
*
* @param mchId 精确商户 ID
* @param operatorUserId 发起重扫的后台用户 ID,用于核销尝试审计
* @return 本轮扫描、任务同步和跳过数量
*/
HardAutoVerifyRescanResultVo rescanAutoVerifyOrders(Long mchId, Long operatorUserId);
}
@@ -33,6 +33,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo;
import com.bsd.pjcloud.hard.api.vo.order.WardMenuMapDayVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
@@ -196,6 +197,16 @@ public interface HardOrderService extends IService<HardOrderEntity> {
*/
List<Long> validateReserveDinerUserIds(Long payerUserId, List<Long> dinerUserIds);
/**
* app 预约菜谱日历地图:按用餐日聚合未退款预约订单的待核销/已核销数量。
*/
List<WardMenuMapDayVo> wardMenuMap(HardOrderDto dto);
/**
* app 预约菜谱某日明细:返回当天未整单退款的待核销/已核销预约订单。
*/
List<HardOrderVo> wardMenuDayOrders(HardOrderDto dto);
/**
* app创建食堂外卖订单
*/
@@ -318,6 +329,16 @@ public interface HardOrderService extends IService<HardOrderEntity> {
*/
Boolean handCancelCode(HandCancelCodeDto dto);
/**
* 自动核销任务专用核销入口:复用人工核销的订单行锁、履约校验、二维码消费与状态条件更新,
* 核销方式标记为自动核销;整个过程静默执行,不发取餐广播、不建兑换券打印任务、不发核销成功推送。
* 只应由自动核销任务在支付后超期未核销时调用,不对外部接口暴露。
*
* @param orderId 待自动核销的每日菜谱预约订单主键
* @return 核销完成时返回 {@code true}
*/
Boolean autoVerifyReserveOrder(Long orderId);
/**
* 在同一个本地事务中核销一组预约订单,并在全部成功后创建一个合并兑换券任务。
*
@@ -4,7 +4,6 @@ import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -39,7 +38,6 @@ import com.bsd.pjcloud.hard.api.dto.HardPrintResultDto;
import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity;
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.utils.ThermalReceiptImage;
import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo;
@@ -256,45 +254,33 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
/**
* {@inheritDoc}
*
* <p>发票与兑换券保持一致的用户信息、订单识别、商品卡片和商品合计版式;发票另保留支付信息字段,
* 商品小计使用下单支付快照,不随部分退款变化。</p>
*/
@Override
public String generateInvoice(HardOrderEntity order, String code) {
List<HardCarEntity> carList = carService.list(new LambdaQueryWrapper<HardCarEntity>()
.eq(HardCarEntity::getParentCarId, order.getParentCarId()));
String username = "外部用户-未知用户";
String userPhone = "外部用户-未知手机号";
R<UserInfo> userResult = userService.getInnerByUserId(order.getUserId());
UserInfo user = userResult == null ? null : userResult.getData();
if (user != null && user.getSysUser() != null) {
username = user.getSysUser().getName();
userPhone = user.getSysUser().getPhone();
}
MchManageVo mch = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在");
String orderType = Optional.ofNullable(HardOrderTypeEnum.valueOfByType(order.getOrderType()))
.map(HardOrderTypeEnum::getDescription).orElse("--");
UserVO user = loadVoucherUser(order.getUserId());
List<HardCarVo> carList = Optional.ofNullable(carService.getByOrderId(order.getId())).orElseGet(List::of);
boolean showComboItems = shouldShowVoucherComboItems();
Map<Long, ProductMetadata> metadata = loadProductMetadata(carList, showComboItems);
String hardwareName = Optional.ofNullable(HardTypeEnum.valueOfByType(order.getSysType()))
.map(HardTypeEnum::getDescription).orElse("设备");
String walletTypeName = resolveWalletTypeName(order.getWalletId());
ThermalReceiptImage image = new ThermalReceiptImage().title(String.format("智慧%s发票", hardwareName))
.divider().keyValue("订单号", displayOrderNo(order)).keyValue("订单类型", orderType)
.keyValue("用户", StrUtil.blankToDefault(username, "--"))
.keyValue("手机号", StrUtil.blankToDefault(userPhone, "--"));
ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true)
.title(String.format("智慧%s发票", hardwareName))
.keyValue("商户", mch.getName());
String walletTypeName = resolveWalletTypeName(order.getWalletId());
if (StrUtil.isNotBlank(walletTypeName)) image.keyValue("钱包类型", walletTypeName);
image.keyValue("商户", mch.getName()).keyValue("部门", mch.getDeptName())
.keyValue("下单时间", formatDateTime(order.getCreateTime()));
if (order.getPayTime() != null) image.keyValue("支付时间", formatDateTime(order.getPayTime()));
if (ObjectUtil.isNotEmpty(carList)) {
image.divider().sectionTitle("商品明细");
carList.forEach(car -> image.invoiceItem(car.getProductName(), productQuantity(car),
"¥" + money(car.getTotalPrice())));
if (order.getExtraPrice() != null && order.getExtraPrice() != 0) {
image.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
}
image.keyValue("小计", "¥" + money(order.getTotalPrice() == null ? null : order.getTotalPrice().longValue()))
.divider();
appendVoucherUser(image, user);
appendInvoiceOrderBlock(image, order);
appendInvoiceProducts(image, carList, metadata, showComboItems);
if (order.getExtraPrice() != null && order.getExtraPrice() != 0) {
image.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
}
image.keyValue("打印时间", formatDateTime(LocalDateTime.now()));
image.voucherTotal(money(order.getTotalPrice() == null ? null : order.getTotalPrice().longValue()))
.keyValue("打印时间", formatDateTime(LocalDateTime.now()));
if (StrUtil.isNotBlank(code)) image.divider().sectionTitle("取件码").verificationCode(code);
try {
return uploadPng("invoice_" + order.getId(), image.toPngBytes());
@@ -303,6 +289,75 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
}
}
/**
* 按兑换券订单区版式绘制发票订单识别信息;预约单补齐预约日期与餐段。
*
* @param image 当前发票图片绘制器
* @param order 订单快照
*/
private static void appendInvoiceOrderBlock(ThermalReceiptImage image, HardOrderEntity order) {
image.divider().sectionTitle("订单 " + displayOrderNo(order));
if (ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) {
image.keyValue("预约日期", order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString())
.keyValue("餐段", resolveMealName(order.getHourType()));
}
String orderType = Optional.ofNullable(HardOrderTypeEnum.valueOfByType(order.getOrderType()))
.map(HardOrderTypeEnum::getDescription).orElse("--");
image.keyValue("订单类型", orderType)
.keyValue("下单时间", formatDateTime(order.getCreateTime()));
}
/**
* 按兑换券商品卡片版式绘制发票商品明细,数量支持份数与称重重量。
*
* <p>发票是支付凭证,小计使用下单时的商品快照总额;套餐标签与组合内容沿用兑换券元数据和显示开关。</p>
*
* @param image 当前发票图片绘制器
* @param cars 订单商品快照
* @param metadata 按商品 ID 索引的套餐类型和组成项
* @param showComboItems 是否展示套餐组成内容
*/
private static void appendInvoiceProducts(ThermalReceiptImage image, List<HardCarVo> cars,
Map<Long, ProductMetadata> metadata, boolean showComboItems) {
image.divider().sectionTitle("商品明细");
boolean hasProduct = false;
for (HardCarVo car : cars) {
hasProduct = true;
// 历史购物车允许商品 ID 为空,空 ID 直接按单品降级。
ProductMetadata product = car.getProductId() == null ? null : metadata.get(car.getProductId());
boolean combo = product != null && product.combo();
List<String> comboItems = combo && showComboItems
? componentItemTexts(product.items(), invoiceComboQuantity(car)) : List.of();
image.voucherProduct(car.getProductName(), invoiceQuantityText(car), money(voucherUnitPrice(car)),
money(car.getTotalPrice()), combo, comboItems);
}
if (!hasProduct) image.item("无商品明细", "x0", false, false);
}
/**
* 计算发票商品数量展示文本:按份数商品显示 xN,称重商品显示快照重量。
*
* @param car 订单商品快照
* @return 商品数量文本
*/
private static String invoiceQuantityText(HardCarVo car) {
if (car.getProductCount() != null && car.getProductCount() != 0) return "x" + car.getProductCount();
if (car.getProductWeight() != null && car.getProductWeight().compareTo(BigDecimal.ZERO) != 0) {
return car.getProductWeight() + "/kg";
}
return "x0";
}
/**
* 套餐组成数量的放大基数:套餐必为份数商品,称重行按零处理。
*
* @param car 订单商品快照
* @return 组成数量放大基数
*/
private static int invoiceComboQuantity(HardCarVo car) {
return Math.max(0, ObjectUtil.defaultIfNull(car.getProductCount(), 0));
}
/**
* {@inheritDoc}
*/
@@ -755,7 +810,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
List<HardOrderEntity> voucherOrders = loadVoucherBatchOrders(order);
MchManageVo merchant = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在");
UserVO user = loadVoucherUser(order.getUserId());
ThermalReceiptImage image = new ThermalReceiptImage().title("预约核销兑换券")
ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true).title("预约核销兑换券")
.banner(String.format("第 %d 次打印 · %d 笔预约", ObjectUtil.defaultIfNull(printNo, 1), voucherOrders.size()))
.keyValue("商户", merchant.getName());
appendVoucherUser(image, user);
@@ -914,11 +969,22 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
*/
private static List<String> voucherComponentItems(VoucherProductLine product) {
if (!product.combo() || product.comboItems().isEmpty()) return List.of();
return product.comboItems().stream().filter(Objects::nonNull)
return componentItemTexts(product.comboItems(), product.quantity());
}
/**
* 将套餐组成快照转换为票据说明文本,并按商品合计数量放大组成数量;兑换券与发票共用。
*
* @param items 套餐组成快照
* @param quantity 当前商品的合计数量放大基数
* @return 已包含总数量且按组成排序稳定排列的说明文本
*/
static List<String> componentItemTexts(List<OnlineSpuComboItemVO> items, int quantity) {
return items.stream().filter(Objects::nonNull)
.sorted(Comparator.comparing(item ->
ObjectUtil.defaultIfNull(item.getSortOrder(), Integer.MAX_VALUE)))
.map(item -> StrUtil.blankToDefault(item.getName(), "未命名组成商品") + " x"
+ (long) ObjectUtil.defaultIfNull(item.getQuantity(), 1) * product.quantity())
+ (long) ObjectUtil.defaultIfNull(item.getQuantity(), 1) * Math.max(0, quantity))
.toList();
}
@@ -1272,17 +1338,6 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return value == null ? "--" : LocalDateTimeUtil.format(value, DatePattern.NORM_DATETIME_PATTERN);
}
/**
* 计算发票显示数量或重量。
*/
private static String productQuantity(HardCarEntity car) {
if (car.getProductCount() != null && car.getProductCount() != 0) return car.getProductCount().toString();
if (car.getProductWeight() != null && car.getProductWeight().compareTo(BigDecimal.ZERO) != 0) {
return car.getProductWeight() + "/kg";
}
return "0";
}
/**
* 将分转换为两位小数金额文本。
*/
@@ -19,6 +19,7 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundAttemptResultEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
@@ -33,6 +34,7 @@ import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
import com.bsd.pjcloud.hard.service.HardAutoVerifyService;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -125,6 +127,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/** 重扫单个订单时刷新未尝试任务,结果计数为二。 */
private static final int RESCAN_SYNC_REFRESHED = 2;
/** 订单售后处理中时自动核销任务的固定延后时长,单位小时;不消耗自动尝试额度。 */
private static final long PENDING_REFUND_DEFER_HOURS = 24L;
/**
* 尝试日志 Mapper,用于保存每次自动、人工和开关跳过记录。
*/
@@ -145,6 +150,11 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*/
private final ObjectProvider<HardAfterSaleService> hardAfterSaleServiceProvider;
/**
* 自动核销服务延迟提供器,用于打破任务状态机、核销服务与订单服务之间的循环依赖。
*/
private final ObjectProvider<HardAutoVerifyService> hardAutoVerifyServiceProvider;
/**
* Wallet 自动退款配置远程服务,用于受理和每次尝试前复核当前开关。
*/
@@ -252,6 +262,43 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
return task;
}
/**
* 在每日菜谱订单支付成功事务内按自动核销快照创建任务;同一订单和场景由 scene_biz_id 唯一键兜底幂等。
*
* @param order 已更新为支付成功且带自动核销快照的订单
* @return 已存在或新建的任务;订单不具备快照或不满足支付协议时返回 {@code null}
*/
@Override
public HardAutoRefundTaskEntity createAutoVerifyTask(HardOrderEntity order) {
if (order == null || order.getId() == null || order.getAutoVerifyConfigId() == null
|| order.getAutoVerifyDeadline() == null
|| !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
|| !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())
|| !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())) {
return null;
}
HardAutoRefundTaskEntity existing = baseMapper.selectOne(new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.eq(HardAutoRefundTaskEntity::getOrderId, order.getId())
.eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()));
if (existing != null) {
return existing;
}
HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder()
.taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
.orderId(order.getId()).mchId(order.getMerchantId())
.configId(order.getAutoVerifyConfigId())
.triggerTime(order.getAutoVerifyDeadline())
.restoreInventory(order.getAutoVerifyRestoreInventory())
.status(HardAutoRefundTaskStatusEnum.PENDING.getStatus())
.autoAttemptCount(0).totalAttemptCount(0)
.nextRetryTime(order.getAutoVerifyDeadline())
.build();
if (baseMapper.insert(task) != 1) {
throw new RuntimeException("创建自动核销任务失败");
}
return task;
}
/**
* 事务提交成功后在当前请求线程同步执行首次退款,执行失败只更新任务,不改变创建接口返回值。
*
@@ -397,6 +444,12 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
completeAttempt(task, triggerType, operatorUserId, false);
return;
}
// 后台逐单立即核销属于人工业务动作:跳过配置与开关复核,订单校验由核销核心在行锁内完成。
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
&& triggerType == HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL) {
completeAttempt(task, triggerType, operatorUserId, false);
return;
}
MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
if (!matchesExpiredSnapshot(task, current)) {
@@ -410,6 +463,41 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
completeAttempt(task, triggerType, operatorUserId, true);
return;
}
// 自动核销任务:先复核配置身份和开关,再按订单行锁预判终态和售后占用,避免无意义尝试消耗额度。
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())) {
if (!matchesVerifySnapshot(task, current)) {
pauseConfigUnavailable(task, "自动核销配置与订单快照不一致");
return;
}
if (!Objects.equals(current.getAutoVerifyEnabled(), YesNoEnum.YES.getCode())) {
pauseForDisabledSwitch(task, triggerType, operatorUserId);
return;
}
HardOrderEntity verifyOrder = hardOrderMapper.selectByIdForUpdate(task.getOrderId());
if (verifyOrder == null) {
pauseConfigUnavailable(task, "自动核销订单不存在");
return;
}
// 订单已人工核销、退款或取消时无需再核销,任务直接进入跳过终态,避免重试耗尽噪音。
if (!Objects.equals(verifyOrder.getOrderStatus(), HardOrderStatusEnum.PAY.getType())) {
completeSkippedAutoVerify(task, triggerType, operatorUserId, "订单已核销、退款或取消,无需自动核销");
return;
}
// 售后处理中禁止核销(与人工核销同一约束),延后重试且不消耗自动尝试额度,等待售后终局后再执行。
if (Objects.equals(verifyOrder.getRefundStatus(), HardRefundStatusEnum.APPLY.getStatus())) {
deferAutoVerifyForPendingRefund(task, triggerType, operatorUserId);
return;
}
int verifyAutoAttempts = safeCount(task.getAutoAttemptCount());
if (triggerType.isAutoAttempt() && verifyAutoAttempts >= MAX_AUTO_ATTEMPTS) {
task.setStatus(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
task.setNextRetryTime(null);
baseMapper.updateById(task);
return;
}
completeAttempt(task, triggerType, operatorUserId, true);
return;
}
if (!matchesSnapshot(task.getConfigId(), task.getRuleType(), current)) {
if (!recoverPersistedWalletRefund(task, triggerType, operatorUserId)) {
pauseConfigUnavailable(task, "自动退款配置与订单快照不一致");
@@ -475,6 +563,11 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
Long afterSaleId = hardAfterSaleServiceProvider.getObject()
.completeExpiredReserveRefund(task, enforceAutoSwitch);
task.setAfterSaleId(afterSaleId);
} else if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())) {
// 自动核销不产生售后单和资金动作,由核销服务在同一事务内完成静默核销和可选退库存;
// 后台逐单立即核销不校验触发时间,允许管理员提前关闭尚未到期的订单。
hardAutoVerifyServiceProvider.getObject().completeAutoVerify(task,
triggerType != HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
} else {
hardAfterSaleServiceProvider.getObject().completeAutoRefund(task.getAfterSaleId(), task.getConfigId());
}
@@ -515,15 +608,33 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
try {
MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
boolean expiredTask = Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType());
if ((!expiredTask && !matchesSnapshot(task.getConfigId(), task.getRuleType(), current))
|| (expiredTask && !matchesExpiredSnapshot(task, current))) {
getSelf().pauseConfigUnavailableInTransaction(taskId, "自动退款配置与订单快照不一致");
return;
}
if ((!expiredTask && !Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode()))
|| (expiredTask && !Objects.equals(current.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode()))) {
getSelf().pauseSwitchAfterFailureInTransaction(taskId, triggerType, operatorUserId);
return;
// 自动核销任务没有退款规则快照,必须按核销口径归类;复用售后判断会因规则为空被误判成配置变更,
// 任务无限暂停且吞掉真实失败原因(如退库存失败、二维码状态异常)。
boolean verifyTask = Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType());
if (verifyTask) {
// 后台逐单立即核销本就跳过配置与开关执行,失败归类同样不能再看开关;
// 否则商户关闭开关期间的手动失败会被归类成开关暂停,真实失败原因(如退库存异常)被吞掉。
if (triggerType != HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL) {
if (!matchesVerifySnapshot(task, current)) {
getSelf().pauseConfigUnavailableInTransaction(taskId, "自动核销配置与订单快照不一致");
return;
}
if (!Objects.equals(current.getAutoVerifyEnabled(), YesNoEnum.YES.getCode())) {
getSelf().pauseSwitchAfterFailureInTransaction(taskId, triggerType, operatorUserId);
return;
}
}
} else {
if ((!expiredTask && !matchesSnapshot(task.getConfigId(), task.getRuleType(), current))
|| (expiredTask && !matchesExpiredSnapshot(task, current))) {
getSelf().pauseConfigUnavailableInTransaction(taskId, "自动退款配置与订单快照不一致");
return;
}
if ((!expiredTask && !Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode()))
|| (expiredTask && !Objects.equals(current.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode()))) {
getSelf().pauseSwitchAfterFailureInTransaction(taskId, triggerType, operatorUserId);
return;
}
}
} catch (CurrentConfigLookupException configException) {
getSelf().pauseConfigUnavailableInTransaction(taskId, configException.getMessage());
@@ -652,7 +763,10 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
task.setNextRetryTime(null);
} else {
task.setStatus(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus());
task.setNextRetryTime(now.plusMinutes(RETRY_DELAY_MINUTES[autoAttempts - 1]));
// 手动触发的失败不消耗自动次数(autoAttempts 可能为 0,如后台逐单立即核销),
// 退避下标按区间钳制:沿用最近一次自动尝试的档位,避免 -1 越界;任务保留给调度器继续接管。
int delayIndex = Math.min(Math.max(autoAttempts - 1, 0), RETRY_DELAY_MINUTES.length - 1);
task.setNextRetryTime(now.plusMinutes(RETRY_DELAY_MINUTES[delayIndex]));
}
baseMapper.updateById(task);
log.warn("|{}|退款尝试失败,taskId:{},orderId:{},attemptNo:{},status:{}", TAG, task.getId(), task.getOrderId(),
@@ -745,7 +859,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* @return 允许执行时返回 {@code true}
*/
private boolean canExecute(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType) {
if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())
|| Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SKIPPED.getStatus())) {
return false;
}
if (triggerType == HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL) {
@@ -754,13 +869,25 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
&& List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()).contains(task.getStatus());
}
// 后台逐单立即核销允许提前关闭尚未到期订单,因此不校验触发时间;准入状态与候选列表口径一致,
// 覆盖待执行、等待重试、开关关闭和配置不可用暂停,仅终态和技术耗尽任务不可手动核销。
if (triggerType == HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL) {
return Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
&& List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus(),
HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(),
HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus()).contains(task.getStatus());
}
if (triggerType == HardAutoRefundTriggerTypeEnum.MANUAL) {
return Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
}
if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus())) {
return false;
}
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
// 过期退款和自动核销都按订单快照触发时间执行,未到期任务不允许被调度器提前拾取。
String taskType = task.getTaskType();
if ((Objects.equals(taskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
|| Objects.equals(taskType, HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()))
&& (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now()))) {
return false;
}
@@ -815,6 +942,88 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
}
/**
* 校验自动核销任务仍指向创建时的配置身份;天数和退库存以任务快照为准,配置修改只影响后续订单。
*/
private boolean matchesVerifySnapshot(HardAutoRefundTaskEntity task, MchAutoRefundConfigVo current) {
return current != null && Objects.equals(current.getId(), task.getConfigId())
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
}
/**
* 订单已进入人工核销、退款或取消等终态时,把自动核销任务收尾为跳过终态。
* 写入一条跳过结果的尝试日志便于审计,任务不再被调度器拾取。
*
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工触发用户 ID,自动触发为空
* @param reason 跳过原因,持久化到任务诊断信息
*/
private void completeSkippedAutoVerify(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId, String reason) {
LocalDateTime now = LocalDateTime.now();
int attemptNo = safeCount(task.getTotalAttemptCount()) + 1;
HardAutoRefundAttemptLogEntity attempt = HardAutoRefundAttemptLogEntity.builder()
.taskId(task.getId())
.attemptNo(attemptNo)
.triggerType(triggerType.getType())
.resultStatus(HardAutoRefundAttemptResultEnum.SKIPPED.getStatus())
.refundCode(buildRefundCode(task))
.operatorUserId(operatorUserId)
.startedTime(now)
.finishedTime(now)
.errorMessage(reason)
.build();
if (attemptLogMapper.insert(attempt) != 1) {
throw new RuntimeException("创建自动核销跳过日志失败");
}
task.setTotalAttemptCount(attemptNo);
task.setLastAttemptTime(now);
task.setStatus(HardAutoRefundTaskStatusEnum.SKIPPED.getStatus());
task.setNextRetryTime(null);
task.setLastError(reason);
if (baseMapper.updateById(task) != 1) {
throw new RuntimeException("更新自动核销任务跳过状态失败");
}
}
/**
* 订单售后处理中时不允许核销,任务固定延后 24 小时重试且不消耗自动尝试额度;
* 售后终局(退款成功订单转终态跳过、退款拒绝恢复核销)由下一次执行重新判定。
*
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工触发用户 ID,自动触发为空
*/
private void deferAutoVerifyForPendingRefund(HardAutoRefundTaskEntity task,
HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) {
LocalDateTime now = LocalDateTime.now();
String reason = "订单售后处理中,延后自动核销";
int attemptNo = safeCount(task.getTotalAttemptCount()) + 1;
HardAutoRefundAttemptLogEntity attempt = HardAutoRefundAttemptLogEntity.builder()
.taskId(task.getId())
.attemptNo(attemptNo)
.triggerType(triggerType.getType())
.resultStatus(HardAutoRefundAttemptResultEnum.SKIPPED.getStatus())
.refundCode(buildRefundCode(task))
.operatorUserId(operatorUserId)
.startedTime(now)
.finishedTime(now)
.errorMessage(reason)
.build();
if (attemptLogMapper.insert(attempt) != 1) {
throw new RuntimeException("创建自动核销延后日志失败");
}
task.setTotalAttemptCount(attemptNo);
task.setLastAttemptTime(now);
task.setStatus(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus());
task.setNextRetryTime(now.plusHours(PENDING_REFUND_DEFER_HOURS));
task.setLastError(reason);
if (baseMapper.updateById(task) != 1) {
throw new RuntimeException("更新自动核销任务延后状态失败");
}
}
/**
* 校验订单是否具备每日菜谱自动退款的完整快照和固定支付协议。
*
@@ -1003,6 +1212,23 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
return result;
}
/**
* 分页查询商户的自动核销候选任务:覆盖待执行、等待重试和暂停状态,包含尚未到期的任务,
* 便于管理端提前查看将被自动核销的订单及其预计触发时间;已成功、跳过和耗尽任务不再展示。
*/
@Override
public Page<HardAutoRefundTaskVo> pageVerifyCandidates(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto) {
LocalDateTime now = LocalDateTime.now();
Page<HardAutoRefundTaskEntity> entityPage = baseMapper.pageVerifyCandidates(page, dto, now);
List<HardAutoRefundTaskVo> records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class);
fillOrderData(records);
fillUserData(records);
fillMerchantData(records);
Page<HardAutoRefundTaskVo> result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
result.setRecords(records);
return result;
}
/**
* 按商户当前过期预约配置重扫历史订单,补建或刷新未尝试任务,并立即执行已经到期的任务。
* 资金动作仍统一经过现有订单锁、售后校验、Wallet 幂等和自动重试状态机;开关关闭时只同步暂停任务,绝不调用退款。
@@ -1289,18 +1515,23 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.and(wrapper -> wrapper.eq(HardAutoRefundTaskEntity::getStatus,
HardAutoRefundTaskStatusEnum.PENDING.getStatus())
.and(nested -> nested.ne(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
.or(expired -> expired.eq(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
// 售后任务无触发时间即扫;过期退款与自动核销任务必须到达快照触发时间后才可执行。
.and(nested -> nested.notIn(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(),
HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
.or(triggered -> triggered.in(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(),
HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
.le(HardAutoRefundTaskEntity::getTriggerTime, now)))
.or(nested -> nested.eq(HardAutoRefundTaskEntity::getStatus,
HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus())
.le(HardAutoRefundTaskEntity::getNextRetryTime, now)
.and(expired -> expired.ne(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
.or(expiredType -> expiredType.eq(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
.and(triggered -> triggered.notIn(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(),
HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
.or(triggeredType -> triggeredType.in(HardAutoRefundTaskEntity::getTaskType,
HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(),
HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
.le(HardAutoRefundTaskEntity::getTriggerTime, now)))))
.orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime)
.orderByAsc(HardAutoRefundTaskEntity::getId)
@@ -1331,6 +1562,10 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
return "hard_expired_" + task.getOrderId();
}
// 自动核销任务没有钱包退款业务号,仅用稳定订单号满足尝试日志非空约束并便于排查。
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())) {
return "hard_auto_verify_" + task.getOrderId();
}
return "hard_" + task.getOrderId() + "_" + task.getAfterSaleId();
}
@@ -0,0 +1,486 @@
package com.bsd.pjcloud.hard.service.impl.order;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.lock.LockInfo;
import com.baomidou.lock.LockTemplate;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoVerifyRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
import com.bsd.pjcloud.hard.service.HardAutoVerifyService;
import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService;
import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
/**
* 过期未核销自动核销实现。核销动作复用 {@link HardOrderService#autoVerifyReserveOrder} 的行锁与状态机,
* 与人工核销、自动退款共用订单级并发保护;退库存按剩余有效数量回补,避免部分退款订单被重复回补。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class HardAutoVerifyServiceImpl implements HardAutoVerifyService {
/**
* 自动核销日志标签,只记录任务和订单标识,不输出用户敏感信息。
*/
private static final String TAG = "自动核销";
/**
* 与自动退款、售后共用的订单 Redis 锁键前缀,保证核销与退款在同一订单上互斥。
*/
private static final String ORDER_LOCK_PREFIX = "hard:refund:order:";
/**
* 单次订单同步锁最长持有时间,单位毫秒。
*/
private static final long ORDER_LOCK_EXPIRE_MILLIS = 60_000L;
/**
* 获取订单同步锁的最大等待时间,单位毫秒。
*/
private static final long ORDER_LOCK_WAIT_MILLIS = 3_000L;
/**
* 首期唯一支持的预约订单类型编码,对应 HardOrderTypeEnum.RESERVE。
*/
private static final String SUPPORTED_ORDER_TYPE = "2";
/**
* 支付后自动核销天数允许的最小值,与 Wallet 配置校验保持一致。
*/
private static final int MIN_AUTO_VERIFY_DAYS = 1;
/**
* 支付后自动核销天数允许的最大值,与 Wallet 配置校验保持一致。
*/
private static final int MAX_AUTO_VERIFY_DAYS = 365;
/**
* 重扫单个订单时未同步任务,结果计数保持为零。
*/
private static final int RESCAN_SYNC_SKIPPED = 0;
/**
* 重扫单个订单时新建任务,结果计数为一。
*/
private static final int RESCAN_SYNC_CREATED = 1;
/**
* 重扫单个订单时刷新未尝试任务,结果计数为二。
*/
private static final int RESCAN_SYNC_REFRESHED = 2;
/**
* 自动核销任务 Mapper,重扫时负责任务的幂等查询、创建和刷新。
*/
private final HardAutoRefundTaskMapper autoRefundTaskMapper;
/**
* 订单 Mapper,重扫同步时锁定订单行并刷新自动核销快照。
*/
private final HardOrderMapper hardOrderMapper;
/**
* 售后 Mapper,重扫同步时排除处理中售后的订单。
*/
private final HardAfterSaleMapper afterSaleMapper;
/**
* 购物车服务,退库存前查询订单商品及剩余有效数量。
*/
private final HardCarService hardCarService;
/**
* 订单服务,提供静默核销入口和库存恢复协议。
*/
private final HardOrderService hardOrderService;
/**
* 自动退款任务服务,重扫补建任务后驱动到期任务立即执行。
*/
private final HardAutoRefundTaskService hardAutoRefundTaskService;
/**
* Wallet 自动退款配置远程服务,重扫前读取商户当前自动核销配置。
*/
private final RemoteMchAutoRefundConfigService remoteConfigService;
/**
* 订单级分布式锁模板,与退款任务和售后操作使用相同 Redis 锁。
*/
private final LockTemplate lockTemplate;
/**
* 在任务状态机事务内执行一次自动核销。触发时间、配置开关和订单状态已由任务状态机在订单行锁内预检,
* 本方法只负责核销动作本身和可选退库存;任一远程或本地步骤失败都会随任务事务回滚并进入重试。
*
* @param task 已通过预检的自动核销任务
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void completeAutoVerify(HardAutoRefundTaskEntity task) {
completeAutoVerify(task, true);
}
/**
* completeAutoVerify 的触发时间开关版本:自动调度必须到期执行,后台逐单立即核销允许提前关闭。
*
* @param task 已锁定的自动核销任务
* @param enforceTriggerTime 是否强制校验任务触发时间已到期
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void completeAutoVerify(HardAutoRefundTaskEntity task, boolean enforceTriggerTime) {
if (task == null || task.getOrderId() == null
|| !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())) {
throw new RuntimeException("自动核销任务数据异常");
}
if (enforceTriggerTime && (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now()))) {
throw new RuntimeException("自动核销尚未到达触发时间");
}
// 静默核销:订单行锁、履约校验、二维码消费与状态条件更新与人工核销完全一致,仅标记核销方式为自动。
hardOrderService.autoVerifyReserveOrder(task.getOrderId());
if (Objects.equals(task.getRestoreInventory(), YesNoEnum.YES.getCode())) {
restoreAutoVerifyInventory(task.getOrderId());
}
}
/**
* 管理端在自动核销候选列表逐单立即核销。核销仍走自动核销链路(核销方式标记为自动),
* 允许提前关闭尚未到期订单;状态机为保留失败日志会吸收业务异常,因此执行后必须复查最终状态。
*
* @param taskId 自动核销任务主键
* @param operatorUserId 当前业务操作用户 ID
*/
@Override
public void manualAutoVerify(Long taskId, Long operatorUserId) {
HardAutoRefundTaskEntity task = autoRefundTaskMapper.selectById(taskId);
if (task == null || !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())) {
throw new RuntimeException("自动核销任务不存在");
}
if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
throw new RuntimeException("订单已核销");
}
// 与候选列表查询口径保持一致:待执行、等待重试、开关关闭和配置不可用暂停均可手动立即核销;
// 等待重试中的订单若售后未结束,核销核心会在订单行锁内给出业务拒绝。
if (!List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus(),
HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(),
HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus()).contains(task.getStatus())) {
throw new RuntimeException("当前任务不属于待自动核销候选订单");
}
hardAutoRefundTaskService.execute(taskId, HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL, operatorUserId);
HardAutoRefundTaskEntity completed = autoRefundTaskMapper.selectById(taskId);
if (completed == null || !Objects.equals(completed.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
// 兜底文案必须带出任务诊断信息,让操作员直接看到核销失败的真实原因而不是无意义的重试提示。
String reason = completed == null ? null : completed.getLastError();
throw new RuntimeException("订单核销未完成:" + StrUtil.blankToDefault(reason, "请刷新列表后重试"));
}
}
/**
* 按剩余有效数量恢复自动核销订单占用的每日菜谱线上池库存。
* 已完成部分退款的份数在退款时已经回补,这里只回补未退部分,防止库存虚增;
* 幂等业务号以订单维度固定,任务重试不会重复回补。
*
* @param orderId 已完成自动核销的订单 ID
*/
private void restoreAutoVerifyInventory(Long orderId) {
HardOrderEntity order = hardOrderMapper.selectById(orderId);
if (order == null) {
throw new RuntimeException("自动核销订单不存在,无法恢复库存");
}
List<HardCarVo> cars = hardCarService.getByOrderId(orderId);
if (ObjectUtil.isEmpty(cars)) {
throw new RuntimeException("自动核销订单缺少购物车,无法恢复库存");
}
List<HardCarEntity> restoreCars = cars.stream()
.filter(car -> car != null && car.getProductId() != null)
.map(car -> HardCarEntity.builder()
.productId(car.getProductId())
.productCount(resolveEffectiveProductCount(car))
.build())
.filter(car -> car.getProductCount() != null && car.getProductCount() > 0)
.toList();
if (restoreCars.isEmpty()) {
throw new RuntimeException("自动核销订单没有可恢复库存的商品");
}
String inventoryBizId = "HARD_AUTO_VERIFY_ORDER:".concat(String.valueOf(orderId));
try {
hardOrderService.fastenerInventory(order, restoreCars, YesNoEnum.NO, null, inventoryBizId);
} catch (Exception exception) {
// 历史订单的当日菜谱可能已下架或清理,退库存属于尽力而为的修正动作(人工核销从不退库存);
// 失败只告警不阻断核销,订单仍按计划关闭,库存差异由运营在菜谱侧核对补偿。
log.warn("{}自动核销退库存失败,已降级跳过,orderId:{},exceptionType:{},message:{}",
TAG, orderId, exception.getClass().getSimpleName(), exception.getMessage(), exception);
}
}
/**
* 解析购物车行的剩余有效数量;历史数据缺少退款统计字段时回退为原始购买数量。
*
* @param car 订单购物车展示对象
* @return 扣除已完成退款后的剩余份数
*/
private int resolveEffectiveProductCount(HardCarVo car) {
Integer count = car.getEffectiveProductCount() == null ? car.getProductCount() : car.getEffectiveProductCount();
return count == null ? 0 : Math.max(0, count);
}
/**
* 按商户当前自动核销配置重扫历史订单,补建或刷新未尝试任务;已到期任务立即进入核销状态机。
* 与过期退款不同:部分退款完成的订单仍可自动核销剩余商品,因此只排除处理中售后,不排除已完成售后。
*
* @param mchId 精确商户 ID
* @param operatorUserId 发起重扫的后台用户 ID,用于核销尝试审计
* @return 本轮扫描、任务同步和跳过数量
*/
@Override
public HardAutoVerifyRescanResultVo rescanAutoVerifyOrders(Long mchId, Long operatorUserId) {
if (mchId == null) {
throw new RuntimeException("商户不能为空");
}
MchAutoRefundConfigVo config = loadCurrentConfig(mchId);
if (config == null || config.getId() == null || !Objects.equals(config.getMchId(), mchId)
|| !Objects.equals(config.getOrderType(), SUPPORTED_ORDER_TYPE)) {
throw new RuntimeException("当前商户未配置自动核销规则");
}
int verifyDays = config.getAutoVerifyDays() == null ? 0 : config.getAutoVerifyDays();
if (verifyDays < MIN_AUTO_VERIFY_DAYS || verifyDays > MAX_AUTO_VERIFY_DAYS) {
throw new RuntimeException("支付后自动核销天数必须在1到365天之间");
}
boolean enabled = Objects.equals(config.getAutoVerifyEnabled(), YesNoEnum.YES.getCode());
String restoreInventory = Objects.equals(config.getAutoVerifyRestoreInventory(), YesNoEnum.YES.getCode())
? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode();
// 只有支付时间已超过配置天数的订单才具备重扫意义,其余订单由支付时创建的任务接管。
LocalDateTime payTimeDeadline = LocalDateTime.now().minusDays(verifyDays);
List<HardOrderEntity> orders = autoRefundTaskMapper.listAutoVerifyRescanOrders(mchId, payTimeDeadline);
HardAutoVerifyRescanResultVo result = new HardAutoVerifyRescanResultVo();
result.setScannedOrderCount(orders == null ? 0 : orders.size());
result.setCreatedTaskCount(0);
result.setRefreshedTaskCount(0);
result.setSkippedOrderCount(0);
if (orders == null || orders.isEmpty()) {
return result;
}
LocalDateTime now = LocalDateTime.now();
for (HardOrderEntity order : orders) {
boolean taskSynced = false;
try {
if (order.getPayTime() == null) {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
log.warn("{}跳过缺少支付时间的订单,orderId:{}", TAG, order.getId());
continue;
}
LocalDateTime triggerTime = order.getPayTime().plusDays(verifyDays);
LockInfo syncLock = lockTemplate.lock(buildOrderLockKey(order.getId()),
ORDER_LOCK_EXPIRE_MILLIS, ORDER_LOCK_WAIT_MILLIS);
if (syncLock == null) {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
continue;
}
int syncResult;
try {
syncResult = getSelf().syncAutoVerifyRescanTask(order, config, triggerTime, restoreInventory, enabled);
} finally {
try {
lockTemplate.releaseLock(syncLock);
} catch (Exception exception) {
log.warn("{}释放重扫订单锁失败,orderId:{},exceptionType:{}", TAG, order.getId(),
exception.getClass().getSimpleName());
}
}
if (syncResult == RESCAN_SYNC_CREATED) {
result.setCreatedTaskCount(result.getCreatedTaskCount() + 1);
} else if (syncResult == RESCAN_SYNC_REFRESHED) {
result.setRefreshedTaskCount(result.getRefreshedTaskCount() + 1);
} else {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
continue;
}
taskSynced = true;
if (enabled && !triggerTime.isAfter(now)) {
HardAutoRefundTaskEntity task = autoRefundTaskMapper.selectOne(new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.eq(HardAutoRefundTaskEntity::getOrderId, order.getId())
.eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()));
if (task != null && List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()).contains(task.getStatus())) {
hardAutoRefundTaskService.execute(task.getId(), HardAutoRefundTriggerTypeEnum.EXPIRED_RESCAN,
operatorUserId);
}
}
} catch (Exception exception) {
if (!taskSynced) {
result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
}
log.warn("{}重扫订单失败,orderId:{},exceptionType:{}", TAG, order.getId(),
exception.getClass().getSimpleName());
}
}
return result;
}
/**
* 同步单个历史订单的自动核销任务。已成功、已跳过或已产生尝试的任务不允许被新配置覆盖,
* 避免重扫把已经核销或人工介入过的订单再次排队;部分退款完成的订单保留核销资格。
*
* @param order 历史已支付未核销订单
* @param config 当前商户自动核销配置
* @param triggerTime 按当前天数以订单支付时间重新计算的触发时间
* @param restoreInventory 当前退库存快照
* @param enabled 当前自动核销开关
* @return 新建、刷新或跳过结果编码
*/
@Transactional(rollbackFor = Exception.class)
public int syncAutoVerifyRescanTask(HardOrderEntity order, MchAutoRefundConfigVo config,
LocalDateTime triggerTime, String restoreInventory, boolean enabled) {
if (order == null || order.getId() == null || config == null || config.getId() == null || triggerTime == null) {
return RESCAN_SYNC_SKIPPED;
}
HardOrderEntity currentOrder = hardOrderMapper.selectByIdForUpdate(order.getId());
if (currentOrder == null || !Objects.equals(currentOrder.getMerchantId(), config.getMchId())
|| !Objects.equals(currentOrder.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
|| !Objects.equals(currentOrder.getOrderStatus(), HardOrderStatusEnum.PAY.getType())
|| !Objects.equals(currentOrder.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())
|| currentOrder.getPayTime() == null) {
return RESCAN_SYNC_SKIPPED;
}
// 只排除处理中售后;已完成的部分退款不剥夺剩余商品的自动核销资格。
Long pendingAfterSaleCount = afterSaleMapper.selectCount(new LambdaQueryWrapper<HardAfterSaleEntity>()
.eq(HardAfterSaleEntity::getOrderId, currentOrder.getId())
.eq(HardAfterSaleEntity::getStatus, HardSaleStatusEnum.APPLY.getStatus()));
if (pendingAfterSaleCount != null && pendingAfterSaleCount > 0) {
return RESCAN_SYNC_SKIPPED;
}
HardAutoRefundTaskEntity existing = autoRefundTaskMapper.selectOne(new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.eq(HardAutoRefundTaskEntity::getOrderId, currentOrder.getId())
.eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()));
if (existing != null) {
boolean refreshableStatus = List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(),
HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus(),
HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus()).contains(existing.getStatus());
if (!refreshableStatus || !Objects.equals(existing.getMchId(), currentOrder.getMerchantId())
|| existing.getAutoAttemptCount() != null && existing.getAutoAttemptCount() > 0) {
return RESCAN_SYNC_SKIPPED;
}
existing.setConfigId(config.getId());
existing.setTriggerTime(triggerTime);
existing.setRestoreInventory(restoreInventory);
existing.setStatus(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus()
: HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus());
existing.setNextRetryTime(enabled ? triggerTime : null);
existing.setLastError(null);
if (autoRefundTaskMapper.updateById(existing) != 1) {
throw new RuntimeException("刷新自动核销任务失败");
}
refreshOrderAutoVerifySnapshot(currentOrder.getId(), config.getId(), triggerTime, restoreInventory);
return RESCAN_SYNC_REFRESHED;
}
HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder()
.taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
.orderId(currentOrder.getId()).mchId(currentOrder.getMerchantId())
.configId(config.getId()).triggerTime(triggerTime)
.restoreInventory(restoreInventory)
.status(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus()
: HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus())
.autoAttemptCount(0).totalAttemptCount(0)
.nextRetryTime(enabled ? triggerTime : null)
.build();
if (autoRefundTaskMapper.insert(task) != 1) {
throw new RuntimeException("创建自动核销任务失败");
}
refreshOrderAutoVerifySnapshot(currentOrder.getId(), config.getId(), triggerTime, restoreInventory);
return RESCAN_SYNC_CREATED;
}
/**
* 重扫补建或刷新任务时同步写入订单自动核销快照,保持订单数据与任务触发口径一致;
* 已存在更新快照的订单也会被覆盖为当前配置,因为任务尚未产生任何尝试。
*
* @param orderId 订单 ID
* @param configId 当前配置 ID
* @param triggerTime 重算后的触发时间
* @param restoreInventory 当前退库存快照
*/
private void refreshOrderAutoVerifySnapshot(Long orderId, Long configId, LocalDateTime triggerTime,
String restoreInventory) {
HardOrderEntity orderUpdate = HardOrderEntity.builder()
.id(orderId)
.autoVerifyConfigId(configId)
.autoVerifyDeadline(triggerTime)
.autoVerifyRestoreInventory(restoreInventory)
.build();
if (hardOrderMapper.updateById(orderUpdate) != 1) {
throw new RuntimeException("刷新自动核销订单快照失败");
}
}
/**
* 查询商户当前自动核销配置;配置服务异常按未配置处理,由调用方提示业务错误。
*
* @param mchId 商户 ID
* @return 当前配置;未配置或响应异常时返回 {@code null}
*/
private MchAutoRefundConfigVo loadCurrentConfig(Long mchId) {
R<MchAutoRefundConfigVo> result;
try {
result = remoteConfigService.getExact(mchId, SUPPORTED_ORDER_TYPE);
} catch (Exception exception) {
log.warn("{}查询配置失败,mchId:{},exceptionType:{}", TAG, mchId, exception.getClass().getSimpleName());
return null;
}
if (result == null || !result.isOk()) {
return null;
}
return result.getData();
}
/**
* 构造与退款、售后共用的订单 Redis 锁键,保证自动核销重扫与任务执行、人工操作互斥。
*
* @param orderId 订单 ID
* @return 订单级分布式锁键
*/
private String buildOrderLockKey(Long orderId) {
return ORDER_LOCK_PREFIX.concat(String.valueOf(orderId));
}
/**
* 获取当前 Spring 代理,保证类内调用的事务方法经过事务拦截器。
*
* @return 当前核销服务代理
*/
private HardAutoVerifyServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}
@@ -56,6 +56,7 @@ import com.bsd.pjcloud.hard.api.constant.HardThirdMiStatusEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderVerificationTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.constant.xfj.HardXfjTypeEnum;
@@ -90,6 +91,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.WardMenuMapDayVo;
import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
@@ -191,6 +193,12 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
/** 管理员回退核销时写入Outbox的停止原因,用于再次正确核销时识别并重新启用该事件记录。 */
private static final String MEAL_VERIFICATION_ROLLBACK_OUTBOX_REASON = "管理员已退回到核销前,停止投递本次核销消息";
/** 支付后自动核销允许的最小天数,与 Wallet 配置校验保持一致。 */
private static final int MIN_AUTO_VERIFY_DAYS = 1;
/** 支付后自动核销允许的最大天数,与 Wallet 配置校验保持一致。 */
private static final int MAX_AUTO_VERIFY_DAYS = 365;
private final HardManagerService hardManagerService;
private final HardCarService hardCarService;
/** 订单识别证据服务,在人脸订单事务提交后创建独立的待补传占位。 */
@@ -2047,7 +2055,9 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
MchAutoRefundRuleTypeEnum rule = MchAutoRefundRuleTypeEnum.valueOfName(config.getRuleType());
boolean legacyEnabled = Objects.equals(config.getEnabled(), YesNoEnum.YES.getCode());
boolean expiredEnabled = Objects.equals(config.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode());
if (!legacyEnabled && !expiredEnabled) {
// 自动核销与自动退款共用配置行;只要任一开关开启就要把配置带回支付环节,各自的快照有效性在快照写入处单独判断。
boolean verifyEnabled = Objects.equals(config.getAutoVerifyEnabled(), YesNoEnum.YES.getCode());
if (!legacyEnabled && !expiredEnabled && !verifyEnabled) {
return null;
}
if (legacyEnabled && rule == null) {
@@ -2386,6 +2396,53 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
return new ArrayList<>(distinctIds);
}
/**
* app 预约菜谱日历地图:按用餐日聚合未退款预约订单的待核销/已核销数量。
* 整单退完(退款金额为正且不低于实付金额)的订单不再计入。
*/
@Override
public List<WardMenuMapDayVo> wardMenuMap(HardOrderDto dto) {
List<HardOrderVo> orders = scopeList(dto, null);
Map<LocalDate, WardMenuMapDayVo> dayMap = new TreeMap<>();
for (HardOrderVo order : orders) {
if (order.getApptUseDate() == null || isFullyRefundedOrder(order)) {
continue;
}
WardMenuMapDayVo day = dayMap.computeIfAbsent(order.getApptUseDate(), date -> {
WardMenuMapDayVo item = new WardMenuMapDayVo();
item.setApptUseDate(date);
item.setPendingCount(0);
item.setVerifiedCount(0);
return item;
});
if (HardOrderStatusEnum.PAY.getType().equals(order.getOrderStatus())) {
day.setPendingCount(day.getPendingCount() + 1);
} else if (HardOrderStatusEnum.CANCEL.getType().equals(order.getOrderStatus())) {
day.setVerifiedCount(day.getVerifiedCount() + 1);
}
}
return new ArrayList<>(dayMap.values());
}
/**
* app 预约菜谱某日明细:返回当天未整单退款的待核销/已核销预约订单,与日历地图同口径。
*/
@Override
public List<HardOrderVo> wardMenuDayOrders(HardOrderDto dto) {
return scopeList(dto, null).stream()
.filter(order -> !isFullyRefundedOrder(order))
.toList();
}
/**
* 判断订单是否已整单退完:退款金额为正且不低于实付金额时视为整单退款,菜谱地图不再展示。
*/
private boolean isFullyRefundedOrder(HardOrderVo order) {
Integer payPrice = order.getPayPrice();
Integer refundPrice = order.getRefundPrice();
return refundPrice != null && payPrice != null && refundPrice > 0 && refundPrice >= payPrice;
}
/**
* 按商户配置校验代订关系和用餐人身份,服务端必须再次校验以防绕过 App 限制。
*
@@ -2704,6 +2761,21 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
&& autoRefundConfig.getPayTimeLimitHours() != null) {
autoRefundDeadline = payTime.plusHours(autoRefundConfig.getPayTimeLimitHours());
}
// 自动核销触发时间以实际支付时间为基准;开关关闭或天数异常时保留订单原快照(正常为空,即不参与自动核销)。
Long autoVerifyConfigId = order.getAutoVerifyConfigId();
LocalDateTime autoVerifyDeadline = order.getAutoVerifyDeadline();
String autoVerifyRestoreInventory = order.getAutoVerifyRestoreInventory();
if (autoRefundConfig != null
&& Objects.equals(autoRefundConfig.getAutoVerifyEnabled(), YesNoEnum.YES.getCode())
&& autoRefundConfig.getAutoVerifyDays() != null
&& autoRefundConfig.getAutoVerifyDays() >= MIN_AUTO_VERIFY_DAYS
&& autoRefundConfig.getAutoVerifyDays() <= MAX_AUTO_VERIFY_DAYS
&& autoRefundConfig.getId() != null) {
autoVerifyConfigId = autoRefundConfig.getId();
autoVerifyDeadline = payTime.plusDays(autoRefundConfig.getAutoVerifyDays());
autoVerifyRestoreInventory = Objects.equals(autoRefundConfig.getAutoVerifyRestoreInventory(),
YesNoEnum.YES.getCode()) ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode();
}
HardOrderEntity orderUpdate = HardOrderEntity.builder()
.id(order.getId())
.payTime(payTime)
@@ -2716,6 +2788,9 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.expiredRefundUsageEndTime(order.getExpiredRefundUsageEndTime())
.expiredRefundDeadline(order.getExpiredRefundDeadline())
.expiredRefundRestoreInventory(order.getExpiredRefundRestoreInventory())
.autoVerifyConfigId(autoVerifyConfigId)
.autoVerifyDeadline(autoVerifyDeadline)
.autoVerifyRestoreInventory(autoVerifyRestoreInventory)
.build();
if (baseMapper.updateById(orderUpdate) != 1) {
throw new I18nBizException(ErrorCodes.HARD_UPDATE_PAY_STATUS_FAIL);
@@ -2734,9 +2809,15 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
order.setExpiredRefundUsageEndTime(orderUpdate.getExpiredRefundUsageEndTime());
order.setExpiredRefundDeadline(orderUpdate.getExpiredRefundDeadline());
order.setExpiredRefundRestoreInventory(orderUpdate.getExpiredRefundRestoreInventory());
order.setAutoVerifyConfigId(autoVerifyConfigId);
order.setAutoVerifyDeadline(autoVerifyDeadline);
order.setAutoVerifyRestoreInventory(autoVerifyRestoreInventory);
if (order.getExpiredRefundDeadline() != null && order.getExpiredRefundConfigId() != null) {
hardAutoRefundTaskService.createExpiredReserveTask(order);
}
if (order.getAutoVerifyDeadline() != null && order.getAutoVerifyConfigId() != null) {
hardAutoRefundTaskService.createAutoVerifyTask(order);
}
}
/**
@@ -3952,21 +4033,65 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean handCancelCode(HandCancelCodeDto dto) {
return cancelCodeWithVerificationType(dto, HardOrderVerificationTypeEnum.MANUAL, false);
}
/**
* 自动核销任务专用核销入口:与人工核销共用订单行锁、履约校验、二维码消费和状态条件更新,
* 但静默执行——不查询用户、不发取餐广播、不建兑换券打印任务和核销成功推送,
* 核销方式固定写自动核销,设备 ID 与人工 App/管理端核销一致记为 {@code -1}。
*
* @param orderId 待自动核销的每日菜谱预约订单主键
* @return 核销完成时返回 {@code true}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean autoVerifyReserveOrder(Long orderId) {
HandCancelCodeDto dto = new HandCancelCodeDto();
dto.setOrderId(orderId);
dto.setHardwareId(-1L);
return cancelCodeWithVerificationType(dto, HardOrderVerificationTypeEnum.AUTO, true);
}
/**
* 核销核心流程:行锁取单、履约校验、消费预约二维码、按待核销状态条件更新并落核销方式。
* 静默模式只跳过用户查询、取餐广播和核销后附属动作(推送/打印任务),状态机与人工核销完全一致。
*
* @param dto 订单、设备及可选 Android 订单号组成的核销参数
* @param verificationType 本次核销方式,人工入口或自动核销任务
* @param silent 是否静默执行(自动核销任务专用)
* @return 核销完成时返回 {@code true}
*/
private Boolean cancelCodeWithVerificationType(HandCancelCodeDto dto, HardOrderVerificationTypeEnum verificationType,
boolean silent) {
// 与退款申请共用订单行锁,避免核销校验通过后并发创建售后单。
HardOrderEntity order = baseMapper.selectByIdForUpdate(dto.getOrderId());
if (ObjectUtil.isEmpty(order)) throw new I18nBizException(ErrorCodes.COMMON_ORDER_NOT_FOUND);
validateCanteenReservationBeforeCancel(order);
// 查询用户
UserInfo userInfo = userService.getInnerByUserId(order.getUserId()).getData();
if (ObjectUtil.isEmpty(userInfo)) throw new I18nBizException(ErrorCodes.COMMON_USER_NOT_EXIST);
// 判断是否被核销过
UserInfo userInfo = null;
if (!silent) {
// 查询用户,仅用于核销完成后的取餐广播;静默核销不广播,无需查询。
userInfo = userService.getInnerByUserId(order.getUserId()).getData();
if (ObjectUtil.isEmpty(userInfo)) throw new I18nBizException(ErrorCodes.COMMON_USER_NOT_EXIST);
}
// 判断是否被核销过。人工核销保持严格拒绝,防止重复核销;
// 自动核销对已消费二维码容错:此前尝试可能已扣码但本地回滚、恢复补偿失败,残留"码已用、订单未核销"中间态,
// 二维码已不可再被使用,直接继续关闭订单即可。
HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType());
boolean isCancel = validateCodeIsCancel(orderType, order.getId());
if (isCancel) throw new I18nBizException(ErrorCodes.HARD_QR_ALREADY_VERIFIED);
boolean qrAlreadyConsumed = false;
if (silent) {
qrAlreadyConsumed = isReservationQrConsumed(orderType, order.getId());
} else if (validateCodeIsCancel(orderType, order.getId())) {
throw new I18nBizException(ErrorCodes.HARD_QR_ALREADY_VERIFIED);
}
boolean isReservationVerification = isReservationVerification(order);
try {
// Wallet 是独立远程事务;调用开始后若 Hardware 核销链路异常,必须幂等恢复预约二维码。
getSelf().cancelCode(order, userInfo);
if (silent && !qrAlreadyConsumed) {
consumeReservationQrCode(order);
} else if (!silent) {
getSelf().cancelCode(order, userInfo);
}
LocalDateTime verificationTime = LocalDateTime.now();
HardOrderEntity updateObj = HardOrderEntity.builder()
.id(order.getId())
@@ -3974,6 +4099,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.hardId(dto.getHardwareId())
.orderStatus(HardOrderStatusEnum.CANCEL.getType())
.verificationTime(verificationTime)
.verificationType(verificationType.getType())
.build();
int updated = isReservationVerification
? baseMapper.update(updateObj, new LambdaUpdateWrapper<HardOrderEntity>()
@@ -3983,7 +4109,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (updated == 0) {
throw new I18nBizException(ErrorCodes.HARD_VERIFY_STATUS_CHANGED);
}
if (isReservationVerification) createReservationVerificationArtifacts(order, verificationTime, dto.getHardwareId());
// 自动核销静默跳过兑换券打印任务和取餐成功推送,订单状态与人工核销保持一致。
if (isReservationVerification && !silent) {
createReservationVerificationArtifacts(order, verificationTime, dto.getHardwareId());
}
return true;
} catch (RuntimeException exception) {
if (isReservationVerification) {
@@ -3993,6 +4122,48 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
}
/**
* 查询预约二维码是否已被消费,仅自动核销静默路径使用。远程查询异常按未消费处理,
* 由后续消费调用或任务下一轮重试兜底,避免任务因状态查询异常陷入无限重试。
*
* @param orderType 订单类型,仅预约订单查询二维码状态
* @param orderId 订单 ID
* @return 二维码已被消费时返回 {@code true}
*/
private boolean isReservationQrConsumed(HardOrderTypeEnum orderType, Long orderId) {
if (orderType != HardOrderTypeEnum.RESERVE) {
return false;
}
try {
R<Boolean> result = remoteCodeUseService.isCancelCode(orderId);
return result != null && result.getCode() == 0 && Boolean.TRUE.equals(result.getData());
} catch (Exception exception) {
log.warn("|{}|查询预约二维码状态失败,按未消费处理,orderId:{}", TAG, orderId);
return false;
}
}
/**
* 自动核销静默路径只消费 Wallet 预约二维码,不触发取餐广播等附属动作;
* 二维码业务号解析规则与 {@link #cancelCode} 保持一致,消费失败同样抛出核销失败异常。
*
* @param order 已持有数据库行锁的预约订单
*/
private void consumeReservationQrCode(HardOrderEntity order) {
HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType());
if (payType == null) {
throw new I18nBizException(ErrorCodes.HARD_ACT_QR_BAD);
}
Long id = order.getId();
if (HardPayTypeEnum.isNotWalletPay.contains(payType) && ObjectUtil.isNotEmpty(order.getOrderSourceTypeId())) {
id = order.getOrderSourceTypeId();
}
R<Boolean> result = remoteCodeUseService.cancelCode(id);
if (ObjectUtil.isEmpty(result) || result.getCode() != 0 || !result.getData()) {
throw new I18nBizException(ErrorCodes.HARD_ACT_QR_BAD);
}
}
/**
* 原子批量核销超市、食堂预约订单。
*
@@ -4054,12 +4225,14 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.hardId(sourceHardId)
.orderStatus(HardOrderStatusEnum.CANCEL.getType())
.verificationTime(verificationTime)
.verificationType(HardOrderVerificationTypeEnum.MANUAL.getType())
.build();
int updated = baseMapper.update(updateObj, new LambdaUpdateWrapper<HardOrderEntity>()
.eq(HardOrderEntity::getId, order.getId())
.eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType()));
if (updated == 0) throw new I18nBizException(ErrorCodes.HARD_VERIFY_STATUS_CHANGED);
order.setVerificationTime(verificationTime);
order.setVerificationType(HardOrderVerificationTypeEnum.MANUAL.getType());
order.setHardId(sourceHardId);
order.setOrderStatus(HardOrderStatusEnum.CANCEL.getType());
if (ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())) {
@@ -4251,6 +4424,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
int updated = baseMapper.update(null, new LambdaUpdateWrapper<HardOrderEntity>()
.set(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType())
.set(HardOrderEntity::getVerificationTime, null)
.set(HardOrderEntity::getVerificationType, null)
.set(HardOrderEntity::getHardId, null)
.set(HardOrderEntity::getAndroidOrderCode, null)
.eq(HardOrderEntity::getId, order.getId())
@@ -4284,6 +4458,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (ObjectUtil.notEqual(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) {
throw new I18nBizException(ErrorCodes.HARD_ROLLBACK_ONLINE_ONLY);
}
// 自动核销是支付后超期的系统清账动作,对应任务已终态;退回待核销后不会再有任务接管,订单将永远滞留待核销。
if (ObjectUtil.equals(order.getVerificationType(), HardOrderVerificationTypeEnum.AUTO.getType())) {
throw new RuntimeException("自动核销订单不支持退回核销前状态");
}
if (!List.of(HardTypeEnum.CANTEEN.getType(), HardTypeEnum.SUPERMARKET.getType()).contains(order.getSysType())) {
throw new I18nBizException(ErrorCodes.HARD_ROLLBACK_BIZ_UNSUPPORTED);
}
@@ -4621,7 +4799,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
private void updateCanteenSpuOrderInventory(HardOrderEntity order, List<HardCarEntity> refundCarList,
YesNoEnum isReduce, Long afterSaleId, String inventoryBizId,
boolean validateOnly) {
if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) {
// 恢复库存必须携带幂等隔离键:售后场景用售后单 ID,自动核销等非售后场景由调用方传入稳定业务号。
if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null && StrUtil.isBlank(inventoryBizId)) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_AFTERSALE_MISSING);
}
List<HardCarEntity> carList = hardCarService.getByParentCarId(order.getParentCarId());
@@ -4643,30 +4822,28 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
LinkedHashMap::new));
HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId());
boolean dailyMenu = isDailyMenuParentCar(parentCar);
// 带菜谱标记的购物车(App 预约每日菜谱)整单走分池;设备购物车(createCard 不写 cartScene)
// 按「商户+订单日期+餐段」菜谱在售情况逐商品分流:在册商品扣每日菜谱分池,其余继续扣基础 SPU 库存。
boolean markedDailyMenu = isDailyMenuParentCar(parentCar);
// 每日菜谱分池路由:线上预定订单下单扣 ONLINE 池(updateDailyMenuReserveInventory),退款/重扣必须还同一池;
// 机器收银等其他类型下单走 OFFLINE 池。不看订单类型会把预约单的线上销量错补到线下池。
boolean onlineChannel = HardOrderTypeEnum.RESERVE.getType().equals(order.getOrderType());
String dailyStockChannel = onlineChannel ? "ONLINE" : "OFFLINE";
LocalDate mealDate = order.getApptUseDate();
Integer hourType = parseHourType(order.getHourType());
Map<Long, Integer> inventorySnapshot = new LinkedHashMap<>();
Map<Long, Integer> dailyChanges;
Map<Long, Integer> basicChanges = Map.of();
if (dailyMenu) {
if (markedDailyMenu) {
dailyChanges = stockChanges;
if (mealDate == null || hourType == null) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
}
// 库存恢复可能在自动退款定时任务等无用户凭证的上下文执行,必须走 @NoToken 内部菜谱接口;
// getCanteenList 是 AD 终端登录态接口,任务线程调用会被 424 拒绝。
R<List<DailyMenuAppVO>> menuResult = remoteDailyMenuService.listForOrderValidate(
mealDate.toString(), hourType, order.getMerchantId());
if (menuResult == null || !menuResult.isOk() || menuResult.getData() == null) {
throw new I18nBizException(menuResult == null ? ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP
: ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
}
Map<Long, DailyMenuAppVO> menuBySpu = menuResult.getData().stream()
.filter(Objects::nonNull)
.filter(row -> row.getSpuId() != null)
.collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a));
Map<Long, DailyMenuAppVO> menuBySpu = loadDailyMenuForInventorySplit(
mealDate, hourType, order.getMerchantId(), false);
for (Map.Entry<Long, Integer> entry : stockChanges.entrySet()) {
DailyMenuAppVO row = menuBySpu.get(entry.getKey());
if (row == null) {
@@ -4680,34 +4857,55 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
}
else {
R<List<CanteenSpuEntity>> productResult = remoteCanteenSpuService
.getInnerSpuListByIds(new ArrayList<>(stockChanges.keySet()));
if (productResult == null || !productResult.isOk() || productResult.getData() == null) {
if (productResult == null) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP);
}
if (StrUtil.isNotBlank(productResult.getMsg())) {
throw new RuntimeException(productResult.getMsg());
}
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
}
Map<Long, CanteenSpuEntity> productMap = productResult.getData().stream()
.filter(Objects::nonNull)
.filter(product -> product.getId() != null)
.collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
// 设备购买当日菜谱:按订单日期餐段在售情况分流,日期或餐段缺失时全部按基础 SPU 库存处理。
Map<Long, DailyMenuAppVO> menuBySpu = loadDailyMenuForInventorySplit(
mealDate, hourType, order.getMerchantId(), true);
dailyChanges = new LinkedHashMap<>();
Map<Long, Integer> unresolvedChanges = new LinkedHashMap<>();
for (Map.Entry<Long, Integer> entry : stockChanges.entrySet()) {
CanteenSpuEntity product = productMap.get(entry.getKey());
if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
DailyMenuAppVO row = menuBySpu.get(entry.getKey());
if (row == null) {
unresolvedChanges.put(entry.getKey(), entry.getValue());
continue;
}
int productType = product.getSpuType() == null ? 1 : product.getSpuType();
if (productType != 1 && productType != 2) {
throw new I18nBizException(ErrorCodes.HARD_PRODUCT_MODE_BAD, product.getName());
int channelRemain = onlineChannel ? dailyOnlineRemain(row) : dailyOfflineRemain(row);
if (entry.getValue() < 0 && channelRemain < Math.abs(entry.getValue())) {
throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, row.getName());
}
if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) {
throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, product.getName());
inventorySnapshot.put(entry.getKey(), channelRemain);
dailyChanges.put(entry.getKey(), entry.getValue());
}
basicChanges = unresolvedChanges;
if (!basicChanges.isEmpty()) {
R<List<CanteenSpuEntity>> productResult = remoteCanteenSpuService
.getInnerSpuListByIds(new ArrayList<>(basicChanges.keySet()));
if (productResult == null || !productResult.isOk() || productResult.getData() == null) {
if (productResult == null) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP);
}
if (StrUtil.isNotBlank(productResult.getMsg())) {
throw new RuntimeException(productResult.getMsg());
}
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
}
Map<Long, CanteenSpuEntity> productMap = productResult.getData().stream()
.filter(Objects::nonNull)
.filter(product -> product.getId() != null)
.collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
for (Map.Entry<Long, Integer> entry : basicChanges.entrySet()) {
CanteenSpuEntity product = productMap.get(entry.getKey());
if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
}
int productType = product.getSpuType() == null ? 1 : product.getSpuType();
if (productType != 1 && productType != 2) {
throw new I18nBizException(ErrorCodes.HARD_PRODUCT_MODE_BAD, product.getName());
}
if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) {
throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, product.getName());
}
inventorySnapshot.put(entry.getKey(), product.getStock());
}
inventorySnapshot.put(entry.getKey(), product.getStock());
}
}
if (validateOnly) return;
@@ -4717,19 +4915,48 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
String resolvedInventoryBizId = StrUtil.isNotBlank(inventoryBizId) ? inventoryBizId : afterSaleId == null
? "HARD_DAILY_MENU_ORDER:" + order.getId()
: "HARD_DAILY_MENU_AFTER_SALE:" + afterSaleId;
if (dailyMenu) {
String stockChannel = onlineChannel ? "ONLINE" : "OFFLINE";
registerCanteenDailyChannelInventoryRollback(stockChannel, stockChanges, inventorySnapshot,
if (!dailyChanges.isEmpty()) {
registerCanteenDailyChannelInventoryRollback(dailyStockChannel, dailyChanges, inventorySnapshot,
resolvedInventoryBizId, order.getMerchantId(), mealDate, hourType);
applyCanteenDailyChannelInventoryChange(stockChannel, stockChanges, inventorySnapshot,
applyCanteenDailyChannelInventoryChange(dailyStockChannel, dailyChanges, inventorySnapshot,
resolvedInventoryBizId, "APPLY", order.getMerchantId(), mealDate, hourType);
}
else {
registerCanteenSpuInventoryRollback(stockChanges, inventorySnapshot, resolvedInventoryBizId);
applyCanteenSpuInventoryChange(stockChanges, inventorySnapshot, resolvedInventoryBizId, "APPLY");
if (!basicChanges.isEmpty()) {
registerCanteenSpuInventoryRollback(basicChanges, inventorySnapshot, resolvedInventoryBizId);
applyCanteenSpuInventoryChange(basicChanges, inventorySnapshot, resolvedInventoryBizId, "APPLY");
}
}
/**
* 查询商户在指定日期餐段仍在售的每日菜谱,供库存路由逐商品判断:在册商品走分池,其余走基础 SPU 库存。
*
* @param mealDate 订单用餐日期;为空且允许跳过时返回空表
* @param hourType 订单餐段;为空且允许跳过时返回空表
* @param merchantId 订单商户
* @param skipWhenUnknown 设备订单(无菜谱标记)日期餐段缺失时降级为基础库存;带标记购物车必须可查,缺失即失败
* @return spuId 到菜谱行的映射;无可售菜谱时为空表
*/
private Map<Long, DailyMenuAppVO> loadDailyMenuForInventorySplit(LocalDate mealDate, Integer hourType,
Long merchantId, boolean skipWhenUnknown) {
if (mealDate == null || hourType == null) {
if (skipWhenUnknown) {
return Map.of();
}
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
}
// 必须走 @NoToken 内部菜谱接口:设备支付回调和自动退款任务都没有用户凭证,登录态接口会 424。
R<List<DailyMenuAppVO>> menuResult = remoteDailyMenuService.listForOrderValidate(
mealDate.toString(), hourType, merchantId);
if (menuResult == null || !menuResult.isOk() || menuResult.getData() == null) {
throw new I18nBizException(menuResult == null ? ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP
: ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
}
return menuResult.getData().stream()
.filter(Objects::nonNull)
.filter(row -> row.getSpuId() != null)
.collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a));
}
/**
* 当前 Hardware 本地事务回滚时反向撤销已经尝试的食堂基础 SPU 库存操作,
* 覆盖远程成功但本地提交失败的窗口。
@@ -5197,6 +5424,11 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
if (ObjectUtil.equals(hardOrderType, HardOrderTypeEnum.RESERVE)) {
hardBroadcastService.addReserveOrderTask(orderdetail, userInfo);
}
// 食堂下单打印发票
Boolean isPrint = hardInvoiceService.printByOrder(orderdetail);
if (isPrint) {
log.info("|{}|发送mq消息成功,食堂打印发票order|{}|", TAG, JSON.toJSONString(orderId));
}
}
}
} catch (Exception e) {
@@ -102,4 +102,94 @@
)
order by o.id asc
</select>
<!--
自动核销候选列表返回仍处于核销流程中的任务:待执行、等待重试和暂停状态均展示,
包含尚未到期的任务,便于管理端提前查看将被自动核销的订单和预计触发时间。
订单必须仍为待核销状态;已成功、已跳过、重试耗尽任务和已终态订单不再返回。
部分退款完成的订单仍可核销剩余商品,因此不按退款金额排除。
-->
<select id="pageVerifyCandidates"
resultType="com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity">
select t.*
from hard_auto_refund_task t
inner join hard_order o
on o.id = t.order_id
and o.del_flag = '0'
and o.order_type = '2'
and o.order_status = '5'
and o.pay_type = '1'
inner join hard_parent_car p
on p.id = o.parent_car_id
and p.del_flag = '0'
where t.del_flag = '0'
and t.task_type = '30'
and t.status in ('10', '20', '30', '31')
and case
when json_valid(p.take_out_setting_json) = 1
then json_unquote(json_extract(p.take_out_setting_json, '$.cartScene'))
else null
end = 'DAILY_MENU'
and not exists (
select 1
from hard_after_sale s
where s.order_id = o.id
and s.del_flag = '0'
and s.status = '10'
)
<if test="dto.id != null">
and t.id = #{dto.id}
</if>
<if test="dto.orderId != null">
and t.order_id = #{dto.orderId}
</if>
<if test="dto.mchId != null">
and t.mch_id = #{dto.mchId}
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and t.mch_id in
<foreach collection="dto.mchIds" item="mchId" open="(" close=")" separator=",">
#{mchId}
</foreach>
</if>
<if test="dto.createTimeList != null and dto.createTimeList.length == 2">
and t.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]}
</if>
order by t.trigger_time asc, t.id asc
</select>
<!--
自动核销立即重扫只读取支付时间已超过配置天数、仍处于待核销状态的每日菜谱钱包订单。
只排除处理中售后:已完成部分退款的订单保留剩余商品的自动核销资格,与过期退款整单口径不同。
-->
<select id="listAutoVerifyRescanOrders"
resultType="com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity">
select o.*
from hard_order o
inner join hard_parent_car p
on p.id = o.parent_car_id
and p.del_flag = '0'
where o.del_flag = '0'
and o.merchant_id = #{mchId}
and o.order_type = '2'
and o.sys_type = '2'
and o.order_status = '5'
and o.pay_type = '1'
and o.pay_price &gt; 0
and o.pay_time is not null
and o.pay_time &lt;= #{payTimeDeadline}
and case
when json_valid(p.take_out_setting_json) = 1
then json_unquote(json_extract(p.take_out_setting_json, '$.cartScene'))
else null
end = 'DAILY_MENU'
and not exists (
select 1
from hard_after_sale s
where s.order_id = o.id
and s.del_flag = '0'
and s.status = '10'
)
order by o.id asc
</select>
</mapper>
@@ -40,6 +40,9 @@
a.expired_refund_usage_end_time,
a.expired_refund_deadline,
a.expired_refund_restore_inventory,
a.auto_verify_config_id,
a.auto_verify_deadline,
a.auto_verify_restore_inventory,
a.sys_type,
a.total_price,
a.extra_price,
@@ -51,6 +54,7 @@
a.refund_time,
a.refund_status,
a.verification_time,
a.verification_type,
a.apply_cancel_time,
a.unfollow_time,
a.android_order_code,
@@ -79,6 +79,36 @@ class ThermalReceiptImageTest {
"单价、小计和合计金额应保留右侧数字区域");
}
/** 发票复用兑换券卡片版式时,称重数量文本应原样展示并保持卡片结构。 */
@Test
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"))
.voucherTotal("42.00").toPngBytes()));
assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, image.getWidth());
assertTrue(hasDarkPixel(image, 20, 36, 0, image.getHeight()), "商品卡片左边框应清晰可见");
assertTrue(hasDarkPixel(image, 430, image.getWidth(), 0, image.getHeight()),
"称重数量、单价、小计和合计金额应保留右侧数字区域");
}
/** 紧凑字号整体缩小全部版式:相同内容下票据更矮,且商品卡片内容仍完整保留。 */
@Test
void compactFontsShrinkAllAreasButKeepProductCardsIntact() throws Exception {
ThermalReceiptImage normal = invoiceSample(false);
ThermalReceiptImage compact = invoiceSample(true);
BufferedImage normalImage = ImageIO.read(new ByteArrayInputStream(normal.toPngBytes()));
BufferedImage compactImage = ImageIO.read(new ByteArrayInputStream(compact.toPngBytes()));
assertTrue(compactImage.getHeight() < normalImage.getHeight(),
"紧凑字号应压缩非商品区高度");
assertTrue(hasDarkPixel(compactImage, 24, 552, 150, compactImage.getHeight()),
"商品卡片内容应完整保留在紧凑票据中");
}
/** 组合商品的黑底标签和粗边框应比同内容单品形成更强的黑白视觉层级。 */
@Test
void comboProductCardHasStrongerVisualWeightThanSingleProductCard() throws Exception {
@@ -168,6 +198,31 @@ class ThermalReceiptImageTest {
"合并版式不应重复绘制每笔订单的整套商品卡片");
}
/**
* 构造与发票装配顺序一致的样张。
*
* @param compact 是否启用非商品区紧凑字号
* @return 已完成绘制的发票样张绘制器
*/
private static ThermalReceiptImage invoiceSample(boolean compact) {
return new ThermalReceiptImage().compactFonts(compact)
.title("智慧食堂发票")
.keyValue("商户", "青鸟同文")
.keyValue("钱包类型", "就餐钱包")
.divider().sectionTitle("用户信息")
.keyValue("姓名", "邓樟晨")
.keyValue("学号", "1231312")
.divider().sectionTitle("订单 2096786045923254273")
.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())
.keyValue("额外支付金额", "¥0.50")
.voucherTotal("18.60")
.keyValue("打印时间", "2026-09-14 12:03:05");
}
private static boolean hasDarkPixel(BufferedImage image, int fromX, int toX, int fromY, int toY) {
for (int y = fromY; y < toY; y++) {
for (int x = fromX; x < toX; x++) {
@@ -1,5 +1,6 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.baomidou.lock.LockInfo;
import com.baomidou.lock.LockTemplate;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
@@ -10,6 +11,7 @@ import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
@@ -18,6 +20,7 @@ import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardAutoVerifyService;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -40,9 +43,13 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -62,6 +69,12 @@ class HardAutoRefundTaskServiceImplTest {
/** 售后服务延迟提供器 Mock。 */
@Mock
private ObjectProvider<HardAfterSaleService> afterSaleServiceProvider;
/** 自动核销服务延迟提供器 Mock。 */
@Mock
private ObjectProvider<HardAutoVerifyService> autoVerifyServiceProvider;
/** 自动核销服务 Mock,验证状态机委托核销动作时的参数。 */
@Mock
private HardAutoVerifyService autoVerifyService;
/** Wallet 自动退款配置远程服务 Mock。 */
@Mock
private RemoteMchAutoRefundConfigService configService;
@@ -92,10 +105,13 @@ class HardAutoRefundTaskServiceImplTest {
@BeforeEach
void setUp() {
service = new HardAutoRefundTaskServiceImpl(attemptLogMapper, afterSaleMapper, orderMapper,
afterSaleServiceProvider, configService, walletRechargeService, mchService, userService, lockTemplate);
afterSaleServiceProvider, autoVerifyServiceProvider, configService, walletRechargeService,
mchService, userService, lockTemplate);
ReflectionTestUtils.setField(service, "baseMapper", taskMapper);
lenient().when(attemptLogMapper.insert(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1);
lenient().when(attemptLogMapper.updateById(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1);
lenient().when(taskMapper.updateById(any(HardAutoRefundTaskEntity.class))).thenReturn(1);
lenient().when(autoVerifyServiceProvider.getObject()).thenReturn(autoVerifyService);
}
/**
@@ -279,6 +295,193 @@ class HardAutoRefundTaskServiceImplTest {
.autoAttemptCount(autoAttempts).totalAttemptCount(totalAttempts).build();
}
/** 订单已人工核销或退款时,自动核销任务应直接进入跳过终态并写跳过日志。 */
@Test
void autoVerifyTaskShouldSkipWhenOrderTerminal() {
HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(true)));
when(orderMapper.selectByIdForUpdate(3L)).thenReturn(HardOrderEntity.builder()
.id(3L).orderStatus(HardOrderStatusEnum.CANCEL.getType()).build());
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
assertEquals(HardAutoRefundTaskStatusEnum.SKIPPED.getStatus(), task.getStatus());
assertNull(task.getNextRetryTime());
ArgumentCaptor<HardAutoRefundAttemptLogEntity> captor = ArgumentCaptor.forClass(HardAutoRefundAttemptLogEntity.class);
verify(attemptLogMapper).insert(captor.capture());
assertEquals("50", captor.getValue().getResultStatus());
verify(autoVerifyServiceProvider, org.mockito.Mockito.never()).getObject();
}
/** 订单售后处理中时应延后 24 小时重试,且不消耗自动尝试额度、不调用核销服务。 */
@Test
void autoVerifyTaskShouldDeferWhenRefundPending() {
HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(true)));
when(orderMapper.selectByIdForUpdate(3L)).thenReturn(HardOrderEntity.builder()
.id(3L)
.orderStatus(HardOrderStatusEnum.PAY.getType())
.refundStatus(HardRefundStatusEnum.APPLY.getStatus())
.build());
LocalDateTime before = LocalDateTime.now();
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
assertEquals(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus(), task.getStatus());
assertEquals(0, task.getAutoAttemptCount());
assertTrue(!task.getNextRetryTime().isBefore(before.plusHours(24))
&& !task.getNextRetryTime().isAfter(LocalDateTime.now().plusHours(24)));
verify(autoVerifyServiceProvider, org.mockito.Mockito.never()).getObject();
}
/** 自动核销开关关闭时应沿用退款任务的暂停语义,任务保持开关关闭暂停等待重新开启。 */
@Test
void autoVerifyTaskShouldPauseWhenSwitchDisabled() {
HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(false)));
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
assertEquals(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), task.getStatus());
verify(orderMapper, org.mockito.Mockito.never()).selectByIdForUpdate(any());
}
/** 触发时间未到或任务已跳过终态时,canExecute 都不允许自动核销任务进入执行。 */
@Test
void autoVerifyTaskShouldRespectTriggerTimeAndSkippedStatus() {
HardAutoRefundTaskEntity futureTask = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
futureTask.setTriggerTime(LocalDateTime.now().plusDays(1));
Boolean futureAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", futureTask,
HardAutoRefundTriggerTypeEnum.SCHEDULED);
HardAutoRefundTaskEntity dueTask = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
Boolean dueAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", dueTask,
HardAutoRefundTriggerTypeEnum.SCHEDULED);
HardAutoRefundTaskEntity skippedTask = verifyTask(HardAutoRefundTaskStatusEnum.SKIPPED.getStatus());
Boolean skippedAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", skippedTask,
HardAutoRefundTriggerTypeEnum.SCHEDULED);
assertEquals(Boolean.FALSE, futureAllowed);
assertEquals(Boolean.TRUE, dueAllowed);
assertEquals(Boolean.FALSE, skippedAllowed);
}
/** 同一订单已存在自动核销任务时保持幂等复用,不再重复插入。 */
@Test
void createAutoVerifyTaskShouldReuseExistingTask() {
HardAutoRefundTaskEntity existing = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
when(taskMapper.selectOne(any())).thenReturn(existing);
HardOrderEntity order = HardOrderEntity.builder().id(3L).merchantId(4L)
.orderType(HardOrderTypeEnum.RESERVE.getType())
.orderStatus(HardOrderStatusEnum.PAY.getType())
.payType(HardPayTypeEnum.WALLET_DEFAULT.getType())
.autoVerifyConfigId(5L)
.autoVerifyDeadline(LocalDateTime.now().plusDays(7))
.build();
HardAutoRefundTaskEntity result = service.createAutoVerifyTask(order);
assertEquals(existing, result);
verify(taskMapper, org.mockito.Mockito.never()).insert(any(HardAutoRefundTaskEntity.class));
}
/** 后台逐单立即核销应跳过配置与开关复核,直接以手动触发来源执行且不消耗自动尝试额度。 */
@Test
void autoVerifyManualShouldBypassConfigAndSwitch() {
HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
task.setTriggerTime(LocalDateTime.now().plusDays(2));
when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL, 99L);
assertEquals(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus(), task.getStatus());
assertEquals(0, task.getAutoAttemptCount());
verify(configService, org.mockito.Mockito.never()).getExact(any(), any());
verify(autoVerifyService).completeAutoVerify(task, false);
}
/** 后台逐单立即核销允许提前关闭未到期订单,准入状态与候选列表一致;终态和重试耗尽任务不可手动核销。 */
@Test
void autoVerifyManualShouldOnlyAcceptCandidateStatuses() {
HardAutoRefundTaskEntity futurePending = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
futurePending.setTriggerTime(LocalDateTime.now().plusDays(2));
Boolean futureAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", futurePending,
HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
HardAutoRefundTaskEntity paused = verifyTask(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus());
paused.setTriggerTime(LocalDateTime.now().plusDays(2));
Boolean pausedAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", paused,
HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
HardAutoRefundTaskEntity waiting = verifyTask(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus());
Boolean waitingAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", waiting,
HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
HardAutoRefundTaskEntity configPaused = verifyTask(HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus());
Boolean configPausedAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", configPaused,
HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
HardAutoRefundTaskEntity skipped = verifyTask(HardAutoRefundTaskStatusEnum.SKIPPED.getStatus());
Boolean skippedAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", skipped,
HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
HardAutoRefundTaskEntity exhausted = verifyTask(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
Boolean exhaustedAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", exhausted,
HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
assertEquals(Boolean.TRUE, futureAllowed);
assertEquals(Boolean.TRUE, pausedAllowed);
assertEquals(Boolean.TRUE, waitingAllowed);
assertEquals(Boolean.TRUE, configPausedAllowed);
assertEquals(Boolean.FALSE, skippedAllowed);
assertEquals(Boolean.FALSE, exhaustedAllowed);
}
/** 手动立即核销失败不消耗自动次数(autoAttempts=0),退避下标必须钳制,历史缺陷曾因此取 [-1] 越界。 */
@Test
void manualVerifyFailureShouldClampRetryDelayIndex() {
HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
LocalDateTime before = LocalDateTime.now();
service.recordFailureInTransaction(1L, HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL, 99L, "恢复库存失败");
assertEquals(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus(), task.getStatus());
assertEquals(1, task.getTotalAttemptCount());
assertEquals(0, task.getAutoAttemptCount());
assertEquals("恢复库存失败", task.getLastError());
assertTrue(!task.getNextRetryTime().isBefore(before.plusMinutes(1))
&& !task.getNextRetryTime().isAfter(LocalDateTime.now().plusMinutes(1)));
}
/**
* 构造已到触发时间的自动核销任务。
*
* @param status 当前任务状态
* @return 可供执行分支使用的任务
*/
private HardAutoRefundTaskEntity verifyTask(String status) {
return HardAutoRefundTaskEntity.builder().id(1L).orderId(3L).mchId(4L).configId(5L)
.taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
.triggerTime(LocalDateTime.now().minusMinutes(1)).status(status)
.restoreInventory(YesNoEnum.NO.getCode())
.autoAttemptCount(0).totalAttemptCount(0).build();
}
/**
* 构造自动核销已开启且身份匹配的任务配置。
*
* @param enabled 自动核销开关
* @return 与任务快照一致的当前配置
*/
private MchAutoRefundConfigVo verifyConfig(boolean enabled) {
MchAutoRefundConfigVo config = new MchAutoRefundConfigVo();
config.setId(5L);
config.setMchId(4L);
config.setOrderType("2");
config.setAutoVerifyEnabled(enabled ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode());
config.setAutoVerifyDays(7);
config.setAutoVerifyRestoreInventory(YesNoEnum.YES.getCode());
return config;
}
/**
* 构造已到期的每日菜谱过期退款任务。
*
@@ -0,0 +1,231 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.baomidou.lock.LockTemplate;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService;
import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 过期未核销自动核销执行与重扫配置测试:静默核销、按剩余数量退库存和非法配置拦截。
*/
@ExtendWith(MockitoExtension.class)
class HardAutoVerifyServiceImplTest {
/** 自动退款任务 Mapper Mock,重扫流程使用。 */
@Mock
private HardAutoRefundTaskMapper autoRefundTaskMapper;
/** 订单 Mapper Mock。 */
@Mock
private HardOrderMapper hardOrderMapper;
/** 售后 Mapper Mock。 */
@Mock
private HardAfterSaleMapper afterSaleMapper;
/** 购物车服务 Mock,提供剩余有效份数。 */
@Mock
private HardCarService hardCarService;
/** 订单服务 Mock,提供静默核销和库存恢复入口。 */
@Mock
private HardOrderService hardOrderService;
/** 自动退款任务服务 Mock,重扫后驱动到期任务执行。 */
@Mock
private HardAutoRefundTaskService hardAutoRefundTaskService;
/** Wallet 配置远程服务 Mock。 */
@Mock
private RemoteMchAutoRefundConfigService remoteConfigService;
/** 订单分布式锁模板 Mock。 */
@Mock
private LockTemplate lockTemplate;
/** 被测自动核销服务。 */
private HardAutoVerifyServiceImpl service;
/**
* 按构造器注入全部依赖。
*/
@BeforeEach
void setUp() {
service = new HardAutoVerifyServiceImpl(autoRefundTaskMapper, hardOrderMapper, afterSaleMapper,
hardCarService, hardOrderService, hardAutoRefundTaskService, remoteConfigService, lockTemplate);
}
/** 触发时间未到的任务不允许提前核销,防止调度竞态绕过商户配置的时限。 */
@Test
void completeAutoVerifyShouldRejectFutureTrigger() {
HardAutoRefundTaskEntity task = task(YesNoEnum.NO.getCode(), LocalDateTime.now().plusDays(1));
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.completeAutoVerify(task));
assertEquals("自动核销尚未到达触发时间", exception.getMessage());
verify(hardOrderService, never()).autoVerifyReserveOrder(any());
}
/** 退库存开关开启时按剩余有效份数回补线上池:已退款份数不重复回补,幂等业务号按订单固定。 */
@Test
void completeAutoVerifyShouldRestoreInventoryWithEffectiveCounts() {
HardAutoRefundTaskEntity task = task(YesNoEnum.YES.getCode(), LocalDateTime.now().minusMinutes(1));
HardOrderEntity order = HardOrderEntity.builder().id(1L).build();
when(hardOrderMapper.selectById(1L)).thenReturn(order);
HardCarVo partiallyRefunded = new HardCarVo();
partiallyRefunded.setProductId(11L);
partiallyRefunded.setProductCount(3);
partiallyRefunded.setEffectiveProductCount(2);
HardCarVo fullyRefunded = new HardCarVo();
fullyRefunded.setProductId(12L);
fullyRefunded.setProductCount(2);
fullyRefunded.setEffectiveProductCount(0);
when(hardCarService.getByOrderId(1L)).thenReturn(List.of(partiallyRefunded, fullyRefunded));
ArgumentCaptor<List<HardCarEntity>> captor = ArgumentCaptor.forClass(List.class);
service.completeAutoVerify(task);
verify(hardOrderService).autoVerifyReserveOrder(1L);
verify(hardOrderService).fastenerInventory(eq(order), captor.capture(), eq(YesNoEnum.NO), isNull(),
eq("HARD_AUTO_VERIFY_ORDER:1"));
assertEquals(1, captor.getValue().size());
assertEquals(11L, captor.getValue().get(0).getProductId());
assertEquals(2, captor.getValue().get(0).getProductCount());
}
/** 退库存开关关闭时只静默核销,不触发任何库存恢复。 */
@Test
void completeAutoVerifyShouldSkipInventoryWhenDisabled() {
HardAutoRefundTaskEntity task = task(YesNoEnum.NO.getCode(), LocalDateTime.now().minusMinutes(1));
service.completeAutoVerify(task);
verify(hardOrderService).autoVerifyReserveOrder(1L);
verify(hardOrderService, never()).fastenerInventory(any(), anyList(), any(), any(), anyString());
}
/** 订单商品全部退款后无可恢复库存,核销事务应失败回滚并交由任务重试与人工排查。 */
@Test
void completeAutoVerifyShouldFailWhenNoRestorableProduct() {
HardAutoRefundTaskEntity task = task(YesNoEnum.YES.getCode(), LocalDateTime.now().minusMinutes(1));
when(hardOrderMapper.selectById(1L)).thenReturn(HardOrderEntity.builder().id(1L).build());
HardCarVo fullyRefunded = new HardCarVo();
fullyRefunded.setProductId(12L);
fullyRefunded.setProductCount(2);
fullyRefunded.setEffectiveProductCount(0);
when(hardCarService.getByOrderId(1L)).thenReturn(List.of(fullyRefunded));
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.completeAutoVerify(task));
assertEquals("自动核销订单没有可恢复库存的商品", exception.getMessage());
}
/** 天数配置非法时重扫直接拒绝,不触碰任何订单和任务。 */
@Test
void rescanShouldRejectInvalidDays() {
when(remoteConfigService.getExact(4L, "2")).thenReturn(R.ok(config(0)));
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.rescanAutoVerifyOrders(4L, 99L));
assertEquals("支付后自动核销天数必须在1到365天之间", exception.getMessage());
}
/** 商户未配置自动核销时重扫拒绝执行,提示先完成配置。 */
@Test
void rescanShouldRejectMissingConfig() {
when(remoteConfigService.getExact(4L, "2")).thenReturn(null);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.rescanAutoVerifyOrders(4L, 99L));
assertEquals("当前商户未配置自动核销规则", exception.getMessage());
}
/** 后台逐单立即核销不校验触发时间,允许提前关闭尚未到期的订单。 */
@Test
void completeAutoVerifyWithManualTriggerShouldSkipTriggerTimeCheck() {
HardAutoRefundTaskEntity task = task(YesNoEnum.NO.getCode(), LocalDateTime.now().plusDays(2));
service.completeAutoVerify(task, false);
verify(hardOrderService).autoVerifyReserveOrder(1L);
}
/** 退库存失败降级:历史订单菜谱已不可考时只告警跳过,核销本身仍完成。 */
@Test
void completeAutoVerifyShouldDegradeInventoryRestoreFailure() {
HardAutoRefundTaskEntity task = task(YesNoEnum.YES.getCode(), LocalDateTime.now().minusMinutes(1));
when(hardOrderMapper.selectById(1L)).thenReturn(HardOrderEntity.builder().id(1L).build());
HardCarVo remaining = new HardCarVo();
remaining.setProductId(11L);
remaining.setProductCount(2);
remaining.setEffectiveProductCount(2);
when(hardCarService.getByOrderId(1L)).thenReturn(List.of(remaining));
doThrow(new RuntimeException("菜谱已下架"))
.when(hardOrderService).fastenerInventory(any(), anyList(), any(), any(), anyString());
service.completeAutoVerify(task);
verify(hardOrderService).autoVerifyReserveOrder(1L);
}
/**
* 构造已到触发时间的自动核销任务。
*
* @param restoreInventory 退库存快照
* @param triggerTime 触发时间
* @return 待执行任务
*/
private HardAutoRefundTaskEntity task(String restoreInventory, LocalDateTime triggerTime) {
return HardAutoRefundTaskEntity.builder().id(9L).orderId(1L).mchId(4L).configId(5L)
.taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
.triggerTime(triggerTime).status(HardAutoRefundTaskStatusEnum.PENDING.getStatus())
.restoreInventory(restoreInventory)
.autoAttemptCount(0).totalAttemptCount(0).build();
}
/**
* 构造商户自动核销配置。
*
* @param days 支付后自动核销天数
* @return 已开启开关的配置
*/
private MchAutoRefundConfigVo config(int days) {
MchAutoRefundConfigVo config = new MchAutoRefundConfigVo();
config.setId(5L);
config.setMchId(4L);
config.setOrderType("2");
config.setAutoVerifyEnabled(YesNoEnum.YES.getCode());
config.setAutoVerifyDays(days);
config.setAutoVerifyRestoreInventory(YesNoEnum.YES.getCode());
return config;
}
}
@@ -0,0 +1,107 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardBroadcastService;
import com.bsd.pjcloud.hard.service.HardInvoiceService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 机器下单后置钩子的发票打印测试:超市与食堂下单均打印发票,食堂预约单额外发送订餐广播。
*/
class HardOrderServiceImplCreateOrderPrintTest {
/** 订单 Mapper,用于返回下单后重新读取的订单快照。 */
private final HardOrderMapper hardOrderMapper = mock(HardOrderMapper.class);
/** 打印任务服务,用于验证下单后创建的发票打印任务。 */
private final HardInvoiceService hardInvoiceService = mock(HardInvoiceService.class);
/** 取餐广播服务,用于验证食堂预约单仍发送订餐消息。 */
private final HardBroadcastService hardBroadcastService = mock(HardBroadcastService.class);
/** 被测订单服务,仅执行真实方法,未涉及的庞大依赖保持空值。 */
private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS);
/**
* 为下单后置钩子注入实际访问的依赖。
*/
@BeforeEach
void setUp() {
when(hardInvoiceService.printByOrder(any())).thenReturn(true);
ReflectionTestUtils.setField(service, "baseMapper", hardOrderMapper);
ReflectionTestUtils.setField(service, "hardInvoiceService", hardInvoiceService);
ReflectionTestUtils.setField(service, "hardBroadcastService", hardBroadcastService);
}
/** 食堂即时消费单下单后创建发票打印任务,不发送订餐广播。 */
@Test
void canteenImmediateOrderPrintsInvoice() {
HardOrderEntity order = order(HardTypeEnum.CANTEEN, HardOrderTypeEnum.BELOW);
service.createOrderAfterSendMqMsg(1L, null);
verify(hardInvoiceService).printByOrder(order);
verify(hardBroadcastService, never()).addReserveOrderTask(any(), any());
}
/** 食堂预约单下单后既发送订餐广播,也创建发票打印任务;兑换券仍在核销时打印。 */
@Test
void canteenReserveOrderPrintsInvoiceAndBroadcasts() {
HardOrderEntity order = order(HardTypeEnum.CANTEEN, HardOrderTypeEnum.RESERVE);
service.createOrderAfterSendMqMsg(1L, null);
verify(hardBroadcastService).addReserveOrderTask(order, null);
verify(hardInvoiceService).printByOrder(order);
}
/** 超市订单下单后打印发票的既有行为保持不变。 */
@Test
void supermarketOrderPrintsInvoice() {
HardOrderEntity order = order(HardTypeEnum.SUPERMARKET, HardOrderTypeEnum.BELOW);
service.createOrderAfterSendMqMsg(1L, null);
verify(hardInvoiceService).printByOrder(order);
verify(hardBroadcastService, never()).addReserveOrderTask(any(), any());
}
/** App 自助下单没有机器上下文,不进入打印分支。 */
@Test
void selfAppOrderSkipsPrinting() {
order(HardTypeEnum.SELF_APP, HardOrderTypeEnum.RESERVE);
service.createOrderAfterSendMqMsg(1L, null);
verify(hardInvoiceService, never()).printByOrder(any());
}
/**
* 构造下单后置钩子使用的订单快照。
*
* @param sysType 下单设备类型
* @param orderType 订单业务类型
* @return 固定主键的订单
*/
private HardOrderEntity order(HardTypeEnum sysType, HardOrderTypeEnum orderType) {
HardOrderEntity order = HardOrderEntity.builder()
.id(1L)
.sysType(sysType.getType())
.orderType(orderType.getType())
.build();
when(hardOrderMapper.selectById(1L)).thenReturn(order);
return order;
}
}
@@ -188,25 +188,89 @@ class HardOrderServiceImplInventoryTest {
}
/**
* 验证设备购买链路(/hard-api/ad/create-order 产生的 BELOW+CANTEEN_SPU 购物车)扣基础 canteen_spu.stock:
* 请求不携带每日菜谱分池通道,也不查询每日菜谱接口——每日菜谱页面的线上/线下剩余不会变化。
* 验证设备购买链路(/hard/hard-api/ad/create-order 的 BELOW+CANTEEN_SPU 购物车)对当日菜谱在册商品
* 扣每日菜谱 OFFLINE 池:请求带 stockChannel=OFFLINE、日期餐段和线下剩余快照,不再扣 canteen_spu.stock。
*/
@Test
void deviceCreateOrderDeductsBasicSpuStockWithoutDailyMenuPools() {
void deviceCreateOrderDeductsDailyMenuOfflinePoolWhenOnMenu() {
prepareCart(1);
when(remoteDailyMenuService.listForOrderValidate(any(), any(), any()))
.thenReturn(R.ok(List.of(dailyMenuRow(8, 4))));
when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok());
HardOrderEntity order = deviceDailyMenuOrder();
assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null));
ArgumentCaptor<CanteenSpuUpdateStockReqDTO> captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class);
verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture());
CanteenSpuUpdateStockReqDTO request = captor.getValue();
assertEquals("OFFLINE", request.getStockChannel());
assertEquals("APPLY", request.getOperation());
assertEquals("HARD_DAILY_MENU_ORDER:100", request.getBizId());
assertEquals(300L, request.getMerchantId());
CanteenSpuUpdateStockReqDTO.Item item = request.getItems().get(0);
assertEquals(11L, item.getId());
assertEquals(-1, item.getIncrCount());
assertEquals(4, item.getBeforeStock());
assertEquals(LocalDate.of(2026, 9, 13), item.getMealDate());
assertEquals(2, item.getHourType());
verify(remoteCanteenSpuService, never()).getInnerSpuListByIds(any());
}
/**
* 验证设备购买不在当日菜谱的商品时仍扣基础 canteen_spu.stock:请求不携带分池通道。
*/
@Test
void deviceCreateOrderDeductsBasicSpuStockWhenNotOnMenu() {
prepareCart(1);
when(remoteDailyMenuService.listForOrderValidate(any(), any(), any()))
.thenReturn(R.ok(List.of()));
CanteenSpuEntity product = productWithStock(9996);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product)));
when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok());
assertDoesNotThrow(() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, null, null));
HardOrderEntity order = deviceDailyMenuOrder();
assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null));
ArgumentCaptor<CanteenSpuUpdateStockReqDTO> captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class);
verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture());
CanteenSpuUpdateStockReqDTO request = captor.getValue();
assertNull(request.getStockChannel());
assertEquals(-1, request.getItems().get(0).getIncrCount());
verify(remoteDailyMenuService, never()).listForOrderValidate(any(), any(), any());
verify(remoteDailyMenuService, never()).getCanteenList(any(), any(), any(), any());
assertEquals(9996, request.getItems().get(0).getBeforeStock());
}
/**
* 验证混合购物车按商品分流:在册商品扣每日菜谱 OFFLINE 池,不在册商品扣基础 canteen_spu.stock,
* 两次幂等调用共用同一业务号(食堂端状态键按渠道隔离,不会互相命中)。
*/
@Test
void deviceCreateOrderSplitsMixedCartBetweenPools() {
prepareTwoProductCart(1, 2);
when(remoteDailyMenuService.listForOrderValidate(any(), any(), any()))
.thenReturn(R.ok(List.of(dailyMenuRow(8, 4))));
CanteenSpuEntity product = productWithStock(5);
product.setId(12L);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(12L))).thenReturn(R.ok(List.of(product)));
when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok());
HardOrderEntity order = deviceDailyMenuOrder();
assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null));
ArgumentCaptor<CanteenSpuUpdateStockReqDTO> captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class);
verify(remoteCanteenSpuService, org.mockito.Mockito.times(2)).updateSkuStockIdempotent(captor.capture());
CanteenSpuUpdateStockReqDTO dailyRequest = captor.getAllValues().stream()
.filter(r -> "OFFLINE".equals(r.getStockChannel())).findFirst().orElseThrow();
CanteenSpuUpdateStockReqDTO basicRequest = captor.getAllValues().stream()
.filter(r -> r.getStockChannel() == null).findFirst().orElseThrow();
assertEquals("HARD_DAILY_MENU_ORDER:100", dailyRequest.getBizId());
assertEquals(11L, dailyRequest.getItems().get(0).getId());
assertEquals(-1, dailyRequest.getItems().get(0).getIncrCount());
assertEquals(4, dailyRequest.getItems().get(0).getBeforeStock());
assertEquals("HARD_DAILY_MENU_ORDER:100", basicRequest.getBizId());
assertEquals(12L, basicRequest.getItems().get(0).getId());
assertEquals(-2, basicRequest.getItems().get(0).getIncrCount());
assertEquals(5, basicRequest.getItems().get(0).getBeforeStock());
}
/**
@@ -318,6 +382,31 @@ class HardOrderServiceImplInventoryTest {
when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(car));
}
/**
* 准备双商品购物车:第一个商品可出现在当日菜谱,第二个不在菜谱,用于混合分流验证。
*
* @param firstCount 第一个商品份数
* @param secondCount 第二个商品份数
*/
private void prepareTwoProductCart(int firstCount, int secondCount) {
HardParentCarEntity parentCar = HardParentCarEntity.builder()
.id(200L)
.productSource(HardProductSourceEnum.CANTEEN_SPU.getType())
.build();
HardCarEntity first = HardCarEntity.builder()
.parentCarId(200L)
.productId(11L)
.productCount(firstCount)
.build();
HardCarEntity second = HardCarEntity.builder()
.parentCarId(200L)
.productId(12L)
.productCount(secondCount)
.build();
when(hardCarService.getParentCarById(200L)).thenReturn(parentCar);
when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(first, second));
}
/**
* 构造指定线上/线下剩余的每日菜谱行。
*
@@ -365,6 +454,24 @@ class HardOrderServiceImplInventoryTest {
.build();
}
/**
* 构造带日期餐段的设备购买订单:createOrder 会写入 apptUseDate=今天、hourType=请求餐段,
* 是 /hard/hard-api/ad/create-order 购买每日菜谱时的真实形态。
*
* @return 关联 CANTEEN_SPU 购物车、固定日期餐段和商户的 BELOW 订单
*/
private HardOrderEntity deviceDailyMenuOrder() {
return HardOrderEntity.builder()
.id(100L)
.sysType(HardTypeEnum.CANTEEN.getType())
.orderType(HardOrderTypeEnum.BELOW.getType())
.parentCarId(200L)
.merchantId(300L)
.apptUseDate(LocalDate.of(2026, 9, 13))
.hourType("2")
.build();
}
/**
* 构造线上预定提交的每日菜谱订单。
*
@@ -8,6 +8,8 @@ import com.bsd.pjcloud.common.core.exception.ErrorCodes;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderVerificationTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.dto.hard.HandCancelCodeDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
@@ -24,6 +26,7 @@ import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
@@ -256,6 +259,74 @@ class HardOrderServiceImplVerificationTest {
assertNull(order.getExpiredRefundDeadline());
}
/**
* 自动核销入口静默完成核销:核销方式写自动、不查询用户、不建兑换券打印任务、不写取餐消息。
*/
@Test
void autoVerifyReserveOrderSilentlyVerifiesWithAutoType() {
HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.PAY);
order.setPayType(HardPayTypeEnum.WALLET_DEFAULT.getType());
HardCarVo remaining = new HardCarVo();
remaining.setProductCount(2);
remaining.setEffectiveProductCount(2);
when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order);
when(hardCarService.getByOrderId(1L)).thenReturn(List.of(remaining));
when(remoteCodeUseService.isCancelCode(1L)).thenReturn(R.ok(false));
when(remoteCodeUseService.cancelCode(1L)).thenReturn(R.ok(true));
when(hardOrderMapper.update(any(HardOrderEntity.class), any(Wrapper.class))).thenReturn(1);
ArgumentCaptor<HardOrderEntity> captor = ArgumentCaptor.forClass(HardOrderEntity.class);
Boolean result = service.autoVerifyReserveOrder(1L);
assertTrue(Boolean.TRUE.equals(result));
verify(hardOrderMapper).update(captor.capture(), any(Wrapper.class));
assertEquals(HardOrderStatusEnum.CANCEL.getType(), captor.getValue().getOrderStatus());
assertEquals(HardOrderVerificationTypeEnum.AUTO.getType(), captor.getValue().getVerificationType());
assertEquals(-1L, captor.getValue().getHardId());
// 静默核销不得触发人工核销的附属动作:无兑换券打印任务、无取餐成功消息。
verify(hardInvoiceService, never()).createRedemptionVoucherTask(any(), anyLong());
verify(hardMessageOutboxMapper, never()).insert(any(HardMessageOutboxEntity.class));
}
/**
* 自动核销入口对已消费二维码容错:此前失败的尝试可能已在 Wallet 扣码但补偿未恢复,
* 静默路径跳过消费直接关闭订单,不再被防重校验拒绝。
*/
@Test
void autoVerifyReserveOrderShouldTolerateConsumedQr() {
HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.PAY);
order.setPayType(HardPayTypeEnum.WALLET_DEFAULT.getType());
HardCarVo remaining = new HardCarVo();
remaining.setProductCount(2);
remaining.setEffectiveProductCount(2);
when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order);
when(hardCarService.getByOrderId(1L)).thenReturn(List.of(remaining));
when(remoteCodeUseService.isCancelCode(1L)).thenReturn(R.ok(true));
when(hardOrderMapper.update(any(HardOrderEntity.class), any(Wrapper.class))).thenReturn(1);
Boolean result = service.autoVerifyReserveOrder(1L);
assertTrue(Boolean.TRUE.equals(result));
verify(remoteCodeUseService, never()).cancelCode(anyLong());
verify(hardInvoiceService, never()).createRedemptionVoucherTask(any(), anyLong());
}
/**
* 自动核销订单不允许退回核销前:任务已终态,回退会让订单永远滞留待核销状态。
*/
@Test
void rollbackReserveVerificationRejectsAutoVerifiedOrder() {
HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.CANCEL);
order.setVerificationType(HardOrderVerificationTypeEnum.AUTO.getType());
when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.rollbackReserveVerification(1L));
assertEquals("自动核销订单不支持退回核销前状态", exception.getMessage());
verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong());
}
/**
* 构造旧消费机通用核销接口请求。
*
@@ -1,19 +1,29 @@
package com.bsd.pjcloud.admin.api.vo;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import lombok.Data;
import java.util.List;
@Data
public class SysUserFaceVo extends SysUserFaceEntity implements BaseUserInterface, BaseDeptInterface {
public class SysUserFaceVo extends SysUserFaceEntity implements BaseUserInterface, BaseDeptInterface, BaseUserIdentityInterface {
/**
* 人脸图片
*/
private String faceImgs;
/**
* 当前人脸在该用户全部未删除人脸中的序号,按记录 id 升序从 1 开始,供前端下载命名。
*/
private Integer faceIndex;
/**
* 用户相关
*/
@@ -23,6 +33,41 @@ public class SysUserFaceVo extends SysUserFaceEntity implements BaseUserInterfac
private String userAvatar;
/**
* 用户标准姓名字段,供统一用户信息组件展示;与历史 username 字段并存。
*/
private String name;
/**
* 用户标准手机号字段,供统一用户信息与身份组件展示;与历史 userMobile 字段并存。
*/
private String phone;
/**
* 用户标准头像字段,供统一用户信息组件展示;与历史 userAvatar 字段并存。
*/
private String avatar;
/**
* 用户性别:0未知 1男 2女
*/
private Integer gender;
/**
* 用户身份协议编码集合;没有身份时为空集合或空值。
*/
private List<String> identityTypes;
/**
* 学生身份扩展资料;没有学生身份时为空。
*/
private SysUserStudentDto studentInfo;
/**
* 老师身份扩展资料;没有老师身份时为空。
*/
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -22,7 +22,9 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo;
import com.bsd.pjcloud.admin.service.SysUserFaceExportService;
import com.bsd.pjcloud.admin.service.SysUserFaceService;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
@@ -64,6 +66,8 @@ public class SysUserFaceController {
private final SysUserFaceService sysUserFaceService;
private final SysUserFaceExportService sysUserFaceExportService;
/* ************************************* start 回调 start ************************************* */
@Inner(value = false)
@PostMapping("/apply-callback")
@@ -289,6 +293,21 @@ public class SysUserFaceController {
return R.ok(sysUserFaceService.scopePageList(page, dto, dataScope));
}
/**
* 按当前筛选条件和数据权限提交人脸图片 ZIP 异步导出任务。
*
* @param dto 人脸审核列表当前筛选条件
* @return 供前端轮询的初始进度,成功终态包含压缩包下载地址
*/
@Operation(summary = "人脸图片分批导出", description = "按筛选条件分批打包人脸图片为 ZIP,异步执行并逐张上报进度")
@SysLog("人脸图片导出")
@PostMapping("/export")
public R<AsyncTaskProgressVO> exportFaces(@RequestBody SysUserFaceDto dto) {
DataScope dataScope = new DataScope();
dataScope.setScopeDeptName("userDeptId");
return R.ok(sysUserFaceExportService.submitExport(dto, dataScope), "人脸图片导出任务已提交");
}
@Operation(summary = "通过id查询", description = "通过id查询")
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('userFace_sysUserFace_view')")
@@ -0,0 +1,23 @@
package com.bsd.pjcloud.admin.service;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.datascope.DataScope;
/**
* 人脸图片批量导出业务服务。
* <p>按人脸审核列表筛选条件分批打包人脸图片为 ZIP 压缩包,异步执行并逐张上报进度。</p>
*/
public interface SysUserFaceExportService {
/**
* 提交人脸图片 ZIP 导出任务。
* <p>请求线程只冻结筛选条件并创建任务;异步线程恢复租户和用户上下文后按数据权限分批查询、
* 从文件中心逐张读取图片写入压缩包,完成后上传文件中心并返回下载地址。</p>
*
* @param dto 人脸审核列表当前筛选条件
* @param dataScope 当前登录人的部门数据权限
* @return 已创建的异步任务初始进度
*/
AsyncTaskProgressVO submitExport(SysUserFaceDto dto, DataScope dataScope);
}
@@ -110,6 +110,13 @@ public interface SysUserFaceService extends IService<SysUserFaceEntity> {
Page<SysUserFaceVo> scopePageList(Page page, SysUserFaceDto dto, DataScope dataScope);
/**
* 批量回填人脸记录在该用户全部未删除人脸中的序号,按记录 id 升序从 1 开始。
*
* @param records 已查询到基础数据的人脸记录列表
*/
void fillFaceIndex(List<SysUserFaceVo> records);
Page<SysUserFaceVo> pageJoin(Page page, SysUserFaceDto dto);
Page<SysUserFaceVo> allPageJoin(Page page, SysUserFaceDto dto);
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.entity.user.SysUserIdentityEntity;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import java.util.List;
@@ -46,12 +47,12 @@ public interface SysUserIdentityService extends IService<SysUserIdentityEntity>
*
* @param userVos 已查询到基础数据的用户展示对象列表
*/
void fillUserIdentityTypes(List<UserVO> userVos);
void fillUserIdentityTypes(List<? extends BaseUserIdentityInterface> userVos);
/**
* 批量填充用户身份及对应扩展信息,供分页列表避免逐用户查询。
*
* @param userVos 已查询到基础数据的用户展示对象列表
*/
void fillUserIdentities(List<UserVO> userVos);
void fillUserIdentities(List<? extends BaseUserIdentityInterface> userVos);
}
@@ -0,0 +1,428 @@
package com.bsd.pjcloud.admin.service.impl;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import com.amazonaws.services.s3.model.S3Object;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
import com.bsd.pjcloud.admin.api.entity.SysFile;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
import com.bsd.pjcloud.admin.service.SysUserFaceExportService;
import com.bsd.pjcloud.admin.service.SysUserFaceService;
import com.bsd.pjcloud.admin.service.SysFileService;
import com.bsd.pjcloud.admin.service.SysUserIdentityService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.file.core.FileProperties;
import com.bsd.pjcloud.common.file.core.FileTemplate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* 人脸图片批量导出服务实现。
* <p>同步请求只冻结筛选条件并创建任务;异步线程按数据权限分批查询人脸记录,逐张从文件中心读取
* 图片流式写入临时 ZIP,完成后上传文件中心并返回下载地址,全程内存占用与单批数据量成正比。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SysUserFaceExportServiceImpl implements SysUserFaceExportService {
/** 人脸图片导出日志标签,用于定位分批打包和文件保存异常。 */
private static final String LOG_TAG = "人脸图片导出";
/** 人脸图片导出任务的业务 Redis Key 命名空间。 */
private static final String FACE_EXPORT_REDIS_KEY = "user-face:export";
/** 人脸图片导出任务展示名称。 */
private static final String FACE_EXPORT_TASK_NAME = "人脸图片导出";
/** 总数查询完成前创建任务使用的占位总数,异步查询后立即替换为真实人脸数。 */
private static final int FACE_EXPORT_INITIAL_TOTAL = 1;
/** 单批查询的人脸记录数,控制数据库分页与图片读取的瞬时压力。 */
private static final int FACE_EXPORT_BATCH_SIZE = 200;
/** 文件中心使用的 ZIP MIME 类型。 */
private static final String ZIP_CONTENT_TYPE = "application/zip";
/** 文件中心导出文件业务目录。 */
private static final String FACE_EXPORT_FILE_DIR = "face-export";
/** 文件中心导出文件分类,用于后台文件管理区分系统生成附件。 */
private static final String FACE_EXPORT_FILE_TYPE = "USER_FACE_EXPORT";
/** 业务导出文件名时间格式,避免同一用户连续导出时名称难以区分。 */
private static final DateTimeFormatter EXPORT_FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/** 人脸 URL 中文件中心对象名的查询参数前缀。 */
private static final String FILE_NAME_PARAM = "fileName=";
/** 人脸映射不到文件中心记录或读取失败时,ZIP 内使用的兜底扩展名。 */
private static final String DEFAULT_IMAGE_EXT = "jpg";
/** 人脸查询 Mapper,用于应用列表筛选和当前登录人的数据权限。 */
private final SysUserFaceMapper sysUserFaceMapper;
/** 人脸业务服务,用于回填“姓名-班级-序号”命名所需的人脸序号。 */
private final SysUserFaceService sysUserFaceService;
/** 用户身份服务,用于批量补齐学生班级等身份资料。 */
private final SysUserIdentityService sysUserIdentityService;
/** 文件管理服务,用于按对象名批量定位人脸图片的文件中心记录并登记导出压缩包。 */
private final SysFileService sysFileService;
/** 文件存储模板,用于把导出压缩包流式上传到项目统一对象存储。 */
private final FileTemplate fileTemplate;
/** 文件存储配置,提供导出压缩包使用的默认 bucket。 */
private final FileProperties fileProperties;
/** 通用异步任务执行器,用于恢复租户和安全上下文并上报导出进度。 */
private final AsyncTaskExecutorService asyncTaskExecutorService;
/**
* 提交人脸图片 ZIP 导出任务。
*
* @param dto 人脸审核列表当前筛选条件
* @param dataScope 当前登录人的部门数据权限
* @return 已创建的异步任务初始进度
*/
@Override
public AsyncTaskProgressVO submitExport(SysUserFaceDto dto, DataScope dataScope) {
SysUserFaceDto querySnapshot = copyQuery(dto);
return asyncTaskExecutorService.submitWithResult(FACE_EXPORT_REDIS_KEY, FACE_EXPORT_TASK_NAME,
FACE_EXPORT_INITIAL_TOTAL, reporter -> {
long total = countFaces(querySnapshot, dataScope);
if (total <= 0) {
throw new IllegalStateException("没有符合当前条件的人脸");
}
reporter.updateTotal((int) Math.min(total, Integer.MAX_VALUE), "共 " + total + " 张人脸,正在分批打包");
ExportSummary summary = exportFaces(querySnapshot, dataScope, total, reporter);
String resultFileName = "人脸图片_" + LocalDateTime.now().format(EXPORT_FILE_TIME_FORMATTER) + ".zip";
String resultFileUrl = uploadZip(summary.zipFile(), resultFileName);
reporter.advance("压缩包已生成,共打包 " + summary.packedCount() + " 张,跳过 " + summary.skippedCount() + " 张无效图片");
return AsyncTaskResult.file(resultFileUrl, resultFileName);
});
}
/**
* 人脸图片分批导出的结果摘要。
*
* @param zipFile 已写入全部图片的临时压缩包
* @param packedCount 成功写入压缩包的图片数量
* @param skippedCount 映射不到文件或读取失败而跳过的图片数量
*/
private record ExportSummary(Path zipFile, int packedCount, int skippedCount) {
}
/**
* 按筛选条件分批查询人脸并逐张写入临时 ZIP 压缩包。
*
* @param querySnapshot 已冻结的列表筛选条件
* @param dataScope 当前登录人的部门数据权限
* @param total 总数查询得到的真实人脸数量
* @param reporter 异步进度报告器;成功与跳过的图片均计数,保证进度能推进到完成
* @return 临时压缩包与打包统计
*/
private ExportSummary exportFaces(SysUserFaceDto querySnapshot, DataScope dataScope, long total,
AsyncTaskProgressReporter reporter) {
Path zipFile;
try {
zipFile = Files.createTempFile("face-export-", ".zip");
} catch (IOException exception) {
throw new IllegalStateException("创建人脸导出临时文件失败", exception);
}
int packedCount = 0;
int skippedCount = 0;
long processed = 0;
Set<String> usedEntryNames = new HashSet<>();
try (ZipOutputStream zipStream = new ZipOutputStream(Files.newOutputStream(zipFile), StandardCharsets.UTF_8)) {
for (long current = 1; processed < total; current++) {
Page<SysUserFaceVo> page = new Page<>(current, FACE_EXPORT_BATCH_SIZE, false);
List<SysUserFaceVo> records = sysUserFaceMapper.scopePageList(page, querySnapshot, dataScope).getRecords();
if (records == null || records.isEmpty()) {
break;
}
sysUserIdentityService.fillUserIdentities(records);
sysUserFaceService.fillFaceIndex(records);
Map<String, SysFile> fileByFileName = loadFileRecords(records);
for (SysUserFaceVo record : records) {
processed++;
if (writeFaceEntry(zipStream, record, fileByFileName, usedEntryNames)) {
packedCount++;
} else {
skippedCount++;
}
reporter.advance("正在打包第 " + processed + " / " + total + " 张人脸图片");
}
}
zipStream.finish();
} catch (IOException exception) {
deleteQuietly(zipFile);
throw new IllegalStateException("写入人脸导出压缩包失败", exception);
} catch (RuntimeException exception) {
deleteQuietly(zipFile);
throw exception;
}
if (packedCount == 0) {
deleteQuietly(zipFile);
throw new IllegalStateException("人脸图片均无法从文件中心读取,导出已取消");
}
return new ExportSummary(zipFile, packedCount, skippedCount);
}
/**
* 把单条人脸记录的图片写入压缩包。
*
* @param zipStream 压缩包输出流
* @param record 人脸记录,已带用户姓名、学生班级和该用户人脸序号
* @param fileByFileName 文件中心记录索引,键为对象名
* @param usedEntryNames 已使用的压缩包条目名,用于跨用户同名时去重
* @return 成功写入返回 {@code true};图片缺失或读取失败返回 {@code false} 并由调用方计数
*/
private boolean writeFaceEntry(ZipOutputStream zipStream, SysUserFaceVo record, Map<String, SysFile> fileByFileName,
Set<String> usedEntryNames) {
String storedFileName = resolveStoredFileName(record.getFaceImg());
SysFile fileRecord = storedFileName == null ? null : fileByFileName.get(storedFileName);
if (fileRecord == null) {
log.warn("|{}|人脸记录 {} 的图片 {} 未找到文件中心记录,已跳过", LOG_TAG, record.getId(), storedFileName);
return false;
}
String entryName = uniqueEntryName(buildEntryName(record, storedFileName), usedEntryNames);
try (S3Object s3Object = fileTemplate.getObject(fileRecord.getBucketName(), fileRecord.getDir(),
fileRecord.getFileName());
InputStream content = s3Object.getObjectContent()) {
zipStream.putNextEntry(new ZipEntry(entryName));
IoUtil.copy(content, zipStream);
zipStream.closeEntry();
return true;
} catch (Exception exception) {
log.error("|{}|人脸记录 {} 的图片 {} 读取失败,已跳过", LOG_TAG, record.getId(), storedFileName, exception);
return false;
}
}
/**
* 组装与单张下载一致的“姓名-部门-班级-序号”压缩包条目名。
* <p>用户无部门或非学生身份时省略对应分段,保持文件名紧凑。</p>
*
* @param record 已补齐用户资料与人脸序号的人脸记录
* @param storedFileName 文件中心对象名,用于保留图片原始扩展名
* @return 可直接写入 ZIP 的条目名
*/
private String buildEntryName(SysUserFaceVo record, String storedFileName) {
String name = sanitizeEntrySegment(record.getName() != null ? record.getName() : record.getUsername());
if (name == null) {
name = "用户" + record.getUserId();
}
String deptName = sanitizeEntrySegment(record.getDeptName());
String className = sanitizeEntrySegment(record.getStudentInfo() == null ? null : record.getStudentInfo().getClassName());
int sequence = record.getFaceIndex() == null || record.getFaceIndex() < 1 ? 1 : record.getFaceIndex();
String ext = StrUtil.blankToDefault(StrUtil.subAfter(storedFileName, ".", true), DEFAULT_IMAGE_EXT).toLowerCase();
StringBuilder baseName = new StringBuilder(name);
if (deptName != null) {
baseName.append("-").append(deptName);
}
if (className != null) {
baseName.append("-").append(className);
}
baseName.append("-").append(sequence);
return baseName.append(".").append(ext).toString();
}
/**
* 清理条目名分段中的文件系统非法字符。
*
* @param segment 姓名或班级原始文本
* @return 清理后的文本;清理后为空时返回 {@code null}
*/
private String sanitizeEntrySegment(String segment) {
String sanitized = segment == null ? null : segment.replaceAll("[\\\\/:*?\"<>|\\r\\n]", "").trim();
return StrUtil.isBlank(sanitized) ? null : sanitized;
}
/**
* 保证压缩包条目名全局唯一;不同用户姓名与班级相同时追加序号后缀。
*
* @param baseName 按命名规则组装的条目名
* @param usedEntryNames 已使用条目名集合,方法内会登记最终结果
* @return 未被使用的唯一条目名
*/
private String uniqueEntryName(String baseName, Set<String> usedEntryNames) {
String candidate = baseName;
int suffix = 2;
while (!usedEntryNames.add(candidate)) {
candidate = baseName + "(" + suffix++ + ")";
}
return candidate;
}
/**
* 从人脸图片地址解析文件中心对象名。
* <p>人脸上传返回的地址形如 {@code /admin/sys-file/oss/file?fileName=uuid.jpg};http 外链无法映射
* 到项目文件中心,返回空交由调用方跳过。</p>
*
* @param faceImg 人脸记录存储的图片地址,可能为空
* @return 文件中心对象名;无法解析时返回 {@code null}
*/
private String resolveStoredFileName(String faceImg) {
if (StrUtil.isBlank(faceImg)) {
return null;
}
String value = faceImg.trim().split(",")[0].trim();
int paramNameIndex = value.indexOf(FILE_NAME_PARAM);
if (paramNameIndex >= 0) {
String param = value.substring(paramNameIndex + FILE_NAME_PARAM.length());
int nextParamIndex = param.indexOf('&');
String fileName = nextParamIndex >= 0 ? param.substring(0, nextParamIndex) : param;
return StrUtil.isBlank(fileName) ? null : fileName;
}
if (value.toLowerCase().startsWith("http")) {
return null;
}
int slashIndex = value.lastIndexOf('/');
String segment = slashIndex >= 0 ? value.substring(slashIndex + 1) : value;
return StrUtil.isBlank(segment) ? null : segment;
}
/**
* 批量定位当前批人脸图片在文件中心的记录。
*
* @param records 当前批人脸记录
* @return 键为对象名的文件中心记录索引;无有效对象名时返回空集合
*/
private Map<String, SysFile> loadFileRecords(List<SysUserFaceVo> records) {
List<String> fileNames = records.stream().map(record -> resolveStoredFileName(record.getFaceImg()))
.filter(Objects::nonNull).distinct().toList();
if (fileNames.isEmpty()) {
return Map.of();
}
return sysFileService.list(Wrappers.<SysFile>lambdaQuery().in(SysFile::getFileName, fileNames)).stream()
.collect(Collectors.toMap(SysFile::getFileName, Function.identity(), (first, ignored) -> first));
}
/**
* 查询当前筛选条件与数据权限范围内的人脸总数。
*
* @param querySnapshot 已冻结的列表筛选条件
* @param dataScope 当前登录人的部门数据权限
* @return 分页总数统计得到的人脸数量
*/
private long countFaces(SysUserFaceDto querySnapshot, DataScope dataScope) {
Page<SysUserFaceVo> countPage = sysUserFaceMapper.scopePageList(new Page<>(1, 1), querySnapshot, dataScope);
return countPage.getTotal();
}
/**
* 流式上传导出压缩包并登记文件中心记录。
* <p>压缩包可能达到数百兆,因此直接走对象存储流式接口并在本服务内登记文件记录,
* 避免经过内存 MultipartFile 造成导出峰值内存与文件大小成正比。</p>
*
* @param zipFile 已写入全部图片的临时压缩包
* @param resultFileName 用户下载时使用的文件名称
* @return 文件中心提供的受控下载地址
*/
private String uploadZip(Path zipFile, String resultFileName) {
String zipFileName = IdUtil.simpleUUID() + ".zip";
try (InputStream stream = Files.newInputStream(zipFile)) {
fileTemplate.putObject(fileProperties.getBucketName(), FACE_EXPORT_FILE_DIR, zipFileName, stream, ZIP_CONTENT_TYPE);
} catch (Exception exception) {
deleteQuietly(zipFile);
log.error("|{}|上传人脸导出压缩包失败", LOG_TAG, exception);
throw new IllegalStateException("上传人脸导出压缩包失败", exception);
}
try {
SysFile fileRecord = new SysFile();
fileRecord.setFileName(zipFileName);
fileRecord.setDir(FACE_EXPORT_FILE_DIR);
fileRecord.setOriginal(resultFileName);
fileRecord.setFileSize(Files.size(zipFile));
fileRecord.setBucketName(fileProperties.getBucketName());
fileRecord.setType(FACE_EXPORT_FILE_TYPE);
fileRecord.setHash(SecureUtil.md5(zipFile.toFile()));
sysFileService.save(fileRecord);
} catch (Exception exception) {
deleteQuietly(zipFile);
log.error("|{}|登记人脸导出文件记录失败", LOG_TAG, exception);
throw new IllegalStateException("登记人脸导出文件记录失败", exception);
}
deleteQuietly(zipFile);
return String.format("/admin/sys-file/oss/file?fileName=%s", zipFileName);
}
/**
* 静默删除临时文件,仅记录调试日志。
*
* @param zipFile 待删除的临时压缩包
*/
private void deleteQuietly(Path zipFile) {
try {
Files.deleteIfExists(zipFile);
} catch (IOException exception) {
log.debug("|{}|临时压缩包删除失败:{}", LOG_TAG, zipFile, exception);
}
}
/**
* 复制人脸查询条件,避免控制器请求对象在异步任务开始前被其他代码修改。
*
* @param source 控制器接收的人脸筛选条件,可为空
* @return 供异步任务独占使用的查询条件副本
*/
private SysUserFaceDto copyQuery(SysUserFaceDto source) {
SysUserFaceDto target = new SysUserFaceDto();
if (source == null) {
return target;
}
org.springframework.beans.BeanUtils.copyProperties(source, target);
target.setUserIds(copyList(source.getUserIds()));
target.setProcessStatuses(copyList(source.getProcessStatuses()));
target.setDeptIds(copyList(source.getDeptIds()));
// 导出不需要审批流程候选人标记
target.setIsFindCheckUser(false);
return target;
}
/**
* 复制可能为空的查询集合,避免异步任务与请求对象共享可变列表。
*
* @param source 请求参数中的可变列表
* @param <T> 列表元素类型
* @return 空值保持为空,非空值返回新的可变列表
*/
private <T> List<T> copyList(List<T> source) {
return source == null ? null : new ArrayList<>(source);
}
}
@@ -33,6 +33,7 @@ import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
import com.bsd.pjcloud.admin.service.SysDeptService;
import com.bsd.pjcloud.admin.service.SysRoleService;
import com.bsd.pjcloud.admin.service.SysUserFaceService;
import com.bsd.pjcloud.admin.service.SysUserIdentityService;
import com.bsd.pjcloud.admin.service.SysUserRelationService;
import com.bsd.pjcloud.admin.service.SysUserService;
import com.bsd.pjcloud.admin.utils.flow.FlowUtil;
@@ -146,6 +147,11 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
private final SysRoleService sysRoleService;
private final SysUserRelationService sysUserRelationService;
/**
* 用户身份服务,批量补齐审批列表的身份协议与学生、老师扩展资料。
*/
private final SysUserIdentityService sysUserIdentityService;
/**
* Redis 字符串客户端,用于租户隔离的人脸搜索编号原子递增。
*/
@@ -911,6 +917,10 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
}
}
Page<SysUserFaceVo> list = baseMapper.scopePageList(page, dto, dataScope);
// 批量补齐身份协议与学生、老师扩展资料,供管理端“身份信息”复合列展示。
sysUserIdentityService.fillUserIdentities(list.getRecords());
// 回填人脸序号,供前端按“姓名-班级-序号”命名下载当前行人脸图片。
fillFaceIndex(list.getRecords());
// 审批流程
if (dto.getIsFindCheckUser()) {
List<String> processInstanceIds = list.getRecords().stream().map(SysUserFaceVo::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList();
@@ -931,6 +941,39 @@ public class SysUserFaceServiceImpl extends ServiceImpl<SysUserFaceMapper, SysUs
return list;
}
/**
* 批量回填当前页人脸在该用户全部未删除人脸中的序号,按记录 id 升序从 1 开始。
*
* @param records 分页查询返回的人脸记录
*/
@Override
public void fillFaceIndex(List<SysUserFaceVo> records) {
if (ObjectUtil.isEmpty(records)) {
return;
}
List<Long> userIds = records.stream().map(SysUserFaceVo::getUserId).filter(Objects::nonNull).distinct().toList();
if (ObjectUtil.isEmpty(userIds)) {
return;
}
Map<Long, List<Long>> faceIdsByUserId = list(new LambdaQueryWrapper<SysUserFaceEntity>()
.select(SysUserFaceEntity::getId, SysUserFaceEntity::getUserId)
.in(SysUserFaceEntity::getUserId, userIds)
.orderByAsc(SysUserFaceEntity::getId))
.stream()
.collect(Collectors.groupingBy(SysUserFaceEntity::getUserId,
Collectors.mapping(SysUserFaceEntity::getId, Collectors.toList())));
for (SysUserFaceVo record : records) {
List<Long> faceIds = faceIdsByUserId.get(record.getUserId());
if (ObjectUtil.isEmpty(faceIds)) {
continue;
}
int index = faceIds.indexOf(record.getId());
if (index >= 0) {
record.setFaceIndex(index + 1);
}
}
}
@Override
public Page<SysUserFaceVo> pageJoin(Page page, SysUserFaceDto dto) {
if (ObjectUtil.isNotEmpty(dto.getMerchantDeptIdOrGlobal())) {
@@ -13,6 +13,7 @@ import com.bsd.pjcloud.admin.api.entity.user.SysUserIdentityEntity;
import com.bsd.pjcloud.admin.api.entity.user.SysUserStudentEntity;
import com.bsd.pjcloud.admin.api.entity.user.SysUserTeacherEntity;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.admin.mapper.SysUserMapper;
import com.bsd.pjcloud.admin.mapper.user.SysUserIdentityMapper;
import com.bsd.pjcloud.admin.service.SysUserIdentityService;
@@ -187,13 +188,13 @@ public class SysUserIdentityServiceImpl extends ServiceImpl<SysUserIdentityMappe
* @param userVos 已查询到基础数据的用户展示对象列表;空列表或无有效用户 ID 时不执行查询
*/
@Override
public void fillUserIdentityTypes(List<UserVO> userVos) {
public void fillUserIdentityTypes(List<? extends BaseUserIdentityInterface> userVos) {
if (CollUtil.isEmpty(userVos)) {
return;
}
List<Long> userIds = userVos.stream()
.filter(userVo -> userVo != null && userVo.getUserId() != null)
.map(UserVO::getUserId)
.map(BaseUserIdentityInterface::getUserId)
.distinct()
.toList();
if (CollUtil.isEmpty(userIds)) {
@@ -217,13 +218,13 @@ public class SysUserIdentityServiceImpl extends ServiceImpl<SysUserIdentityMappe
* @param userVos 已查询到基础数据的用户展示对象列表;空列表或无有效用户 ID 时不执行查询
*/
@Override
public void fillUserIdentities(List<UserVO> userVos) {
public void fillUserIdentities(List<? extends BaseUserIdentityInterface> userVos) {
if (CollUtil.isEmpty(userVos)) {
return;
}
List<Long> userIds = userVos.stream()
.filter(userVo -> userVo != null && userVo.getUserId() != null)
.map(UserVO::getUserId)
.map(BaseUserIdentityInterface::getUserId)
.distinct()
.toList();
if (CollUtil.isEmpty(userIds)) {
@@ -236,7 +237,7 @@ public class SysUserIdentityServiceImpl extends ServiceImpl<SysUserIdentityMappe
Map<Long, SysUserTeacherEntity> teacherByUserId = sysUserTeacherService.listByIds(userIds).stream()
.collect(Collectors.toMap(SysUserTeacherEntity::getUserId, Function.identity(), (first, ignored) -> first));
for (UserVO userVo : userVos) {
for (BaseUserIdentityInterface userVo : userVos) {
if (userVo == null || userVo.getUserId() == null) {
continue;
}
@@ -84,8 +84,12 @@
<select id="scopePageList" resultType="com.bsd.pjcloud.admin.api.vo.SysUserFaceVo">
<include refid="selectA"/>
u.name as username,
u.avatar as userAvatar,
u.name as name,
u.phone as userMobile,
u.phone as phone,
u.avatar as userAvatar,
u.avatar as avatar,
u.gender as gender,
u.dept_id as userDeptId,
u.dept_id as deptId,
d.name as deptName
@@ -100,6 +104,23 @@
<if test="pa.phone != null and pa.phone != ''">
and u.phone LIKE CONCAT('%', #{pa.phone}, '%')
</if>
<!-- 左侧部门树筛选:isInclude 为 true 时取勾选部门,false 时排除勾选部门 -->
<if test="pa.deptIds != null and pa.deptIds.size() != 0">
<choose>
<when test="pa.isInclude != null and pa.isInclude == true">
and d.dept_id IN
</when>
<when test="pa.isInclude != null and pa.isInclude == false">
and d.dept_id NOT IN
</when>
<otherwise>
and d.dept_id IN
</otherwise>
</choose>
<foreach collection="pa.deptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
</where>
order by a.create_time desc , a.id desc
</select>
@@ -63,4 +63,16 @@ public class MchAutoRefundConfigDto {
/** 过期预约退款是否恢复库存,1恢复、0不恢复。 */
@Schema(description = "过期预约退款是否退库存")
private String expiredReserveRefundRestoreInventory;
/** 过期未核销自动核销开关,1开启、0关闭。 */
@Schema(description = "过期未核销自动核销开关")
private String autoVerifyEnabled;
/** 支付后自动核销天数,单位天,允许1至365;开关开启时必填。 */
@Schema(description = "支付后自动核销天数")
private Integer autoVerifyDays;
/** 自动核销是否恢复每日菜谱线上池库存,1恢复、0不恢复。 */
@Schema(description = "自动核销是否退库存")
private String autoVerifyRestoreInventory;
}
@@ -14,6 +14,8 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import jakarta.validation.constraints.Size;
@Data
@Builder
@AllArgsConstructor
@@ -51,6 +53,14 @@ public class WalletRechargeExcelDto {
@ExcelDict(value = "wallet_transaction_tag", multiple = true)
private String tag;
/**
* Excel 中填写的流水备注,导入落库后随审批通过写入钱包流水 remark;允许为空。
*/
@ColumnWidth(100)
@Size(max = 256, message = "备注不能超过256个字符")
@ExcelProperty("备注")
private String remark;
/**
* 导入校验后解析出的财务标签字典值,多个值使用英文逗号分隔,不直接读取 Excel。
*/
@@ -10,6 +10,8 @@ import com.bsd.pjcloud.common.excel.annotation.ExcelDict;
import com.bsd.pjcloud.common.excel.annotation.ExcelLine;
import lombok.Data;
import jakarta.validation.constraints.Size;
/**
* 学生充值/扣款申请 Excel 导入行。
* <p>使用学校和学号精确定位学生,再校验学生姓名,避免同名学生被错误充值或扣款。</p>
@@ -52,6 +54,12 @@ public class WalletStudentRechargeExcelDto {
@ExcelDict(value = "wallet_transaction_tag", multiple = true)
private String tag;
/** Excel 中填写的流水备注,导入落库后随审批通过写入钱包流水 remark;允许为空。 */
@ColumnWidth(100)
@Size(max = 256, message = "备注不能超过256个字符")
@ExcelProperty("备注")
private String remark;
/** 校验阶段解析出的财务标签字典值,多个值使用英文逗号分隔。 */
@ExcelIgnore
private String tags;
@@ -79,6 +79,18 @@ public class MchAutoRefundConfigEntity extends Model<MchAutoRefundConfigEntity>
@Schema(description = "过期预约退款是否退库存")
private String expiredReserveRefundRestoreInventory;
/** 过期未核销自动核销开关,持久化值为1开启、0关闭;与两个自动退款开关相互独立。 */
@Schema(description = "过期未核销自动核销开关")
private String autoVerifyEnabled;
/** 支付后自动核销天数,允许1至365;开关开启时必填,快照到订单生成触发时间。 */
@Schema(description = "支付后自动核销天数")
private Integer autoVerifyDays;
/** 自动核销是否恢复每日菜谱线上池库存,1恢复、0不恢复;配置保存时快照到订单和任务。 */
@Schema(description = "自动核销是否退库存")
private String autoVerifyRestoreInventory;
/**
* 创建人账号,由公共字段填充器在新增时写入。
*/
@@ -73,6 +73,12 @@ public class WalletRechargeImportEntity extends Model<WalletRechargeImportEntity
@Schema(description = "财务统计标签,多个字典值使用英文逗号分隔")
private String tags;
/**
* 流水备注,导入 Excel 时填写,审批通过后写入钱包流水 remark;最多 256 个字符。
*/
@Schema(description = "流水备注,最多256个字符")
private String remark;
/**
* 创建人
*/
@@ -7,6 +7,7 @@ import com.bsd.pjcloud.wallet.api.dto.WalletActiveDto;
import com.bsd.pjcloud.wallet.api.dto.WalletRemoteOpenCardCountDto;
import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo;
import jakarta.validation.constraints.NotEmpty;
@@ -37,6 +38,16 @@ public interface WalletActiveService extends IService<WalletActiveEntity> {
void createAndActiveCard(WalletManageEntity dto);
/**
* 为尚未绑定饭卡的钱包分配空闲卡并建立开卡关联的短事务实现。
* <p>饭卡所属商户包含 Feign 远程调用,必须由调用方在事务开启前解析并传入,
* 事务内只保留空闲卡余量检查、必要的独立事务补卡和抢卡绑定,避免拉长抢卡持锁时间。</p>
*
* @param wallet 待绑定的饭卡钱包;为空或已有卡号时不执行任何操作
* @param mch 事务外按钱包部门解析的饭卡所属商户;为空时不自动绑卡
*/
void createAndActiveCard(WalletManageEntity wallet, MchManageVo mch);
/**
* 取消绑定饭卡
*/
@@ -3,13 +3,16 @@ package com.bsd.pjcloud.wallet.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto;
import com.bsd.pjcloud.wallet.api.dto.WalletManageDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTopupActionDto;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity;
import com.bsd.pjcloud.wallet.api.entity.mch.MchTakeOutSettingEntity;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.WalletManageVo;
import com.bsd.pjcloud.wallet.api.vo.WalletQbTotalVo;
import jakarta.validation.constraints.NotNull;
@@ -35,6 +38,18 @@ public interface WalletManageService extends IService<WalletManageEntity> {
*/
void createWallet(@NotNull Long userId);
/**
* 创建钱包的短事务实现,仅由 {@link #createWallet(Long, Boolean)} 在准备动作完成后经代理调用。
* <p>事务内只保留钱包占位查询、钱包插入和饭卡自动绑定,不包含任何远程调用;
* 所需的用户部门、钱包类型全集和饭卡所属商户均由调用方在事务开启前解析传入。</p>
*
* @param userId 用户 ID
* @param deptId 用户部门 ID
* @param walletTypes 事务外已查询的钱包类型全集;为空时不创建任何钱包
* @param eatMch 事务外按用户部门解析的饭卡所属商户;为空时饭卡钱包不自动绑卡
*/
void createWalletInTx(Long userId, Long deptId, List<WalletTypeEntity> walletTypes, MchManageVo eatMch);
/**
* 获取钱包没有就创建用户钱包(没有缓存)
*/
@@ -148,6 +163,16 @@ public interface WalletManageService extends IService<WalletManageEntity> {
*/
void userChange(Long userId, Boolean isDel);
/**
* 用户变更同步的短事务实现,仅由 {@link #userChange(Long, Boolean)} 在商户解析完成后经代理调用。
* <p>事务内只保留钱包部门和已绑饭卡商户的本地更新;用户资料与商户解析的远程调用、
* 正元第三方同步均由调用方在事务外执行。</p>
*
* @param sysUser 已查询的用户资料,取部门和用户 ID
* @param mch 按用户部门解析的食堂商户;为空时不调整已绑饭卡的商户归属
*/
void userChangeInTx(SysUser sysUser, MchManageVo mch);
/**
* 修改部门
*/
@@ -40,6 +40,7 @@ import com.bsd.pjcloud.wallet.service.WalletTypeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -68,7 +69,9 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
private final WalletUserIdentitySupport userIdentitySupport;
@Override
@Transactional(rollbackFor = Exception.class)
// 补卡必须以独立事务提交:批量 INSERT 的插入意向锁若与抢卡 FOR UPDATE 的锁同处一个事务,
// 并发导入时两者互相等待形成死锁环;独立提交后补卡只贡献卡池库存,不再参与抢卡事务的回滚。
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void fastCreateCard(WalletActiveDto dto) {
Long count = dto.getCount();
// 查询饭卡钱包类型
@@ -184,18 +187,35 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
/**
* 为尚未绑定饭卡的钱包分配空闲卡并建立开卡关联。
* <p>学生学校可能挂在“其他食堂”商户下,因此自动绑卡同时匹配其他食堂和自营食堂;
* 钱包已有卡号时视为已绑定并保持原行为,不重复分配。</p>
* 钱包已有卡号时视为已绑定并保持原行为,不重复分配。商户解析包含 Feign 远程调用,
* 在事务外完成后委托短事务重载执行,避免远程调用拉长抢卡事务持锁时间。</p>
*
* @param wallet 待绑定的饭卡钱包;为空或已有卡号时不执行任何操作
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void createAndActiveCard(WalletManageEntity wallet) {
if (ObjectUtil.isEmpty(wallet) || ObjectUtil.isNotEmpty(wallet.getCardNumber())) return;
// 学校部门可能归属其他食堂,必须与用户部门商户关系同步时采用相同的食堂类型范围。
MchManageVo mch = mchManageService.getFirstMchByDeptId(wallet.getDeptId(),
List.of(MerchantTypeEnum.OTHER_CANTEEN, MerchantTypeEnum.CANTEEN));
if (ObjectUtil.isEmpty(mch)) return;
getSelf().createAndActiveCard(wallet, mch);
}
/**
* 为尚未绑定饭卡的钱包分配空闲卡并建立开卡关联的短事务实现,商户由调用方在事务外解析。
* <p>事务内仅包含空闲卡余量 COUNT、必要时的独立事务补卡和抢卡绑定:
* 抢卡 SELECT ... FOR UPDATE 依赖 idx_wallet_active_merchant_user_sort 复合索引将锁定范围
* 收敛到队首一张卡;卡池余量不足时的批量补卡走独立事务(见 {@link #fastCreateCard}),
* 避免插入意向锁与抢卡锁互相等待造成死锁。</p>
*
* @param wallet 待绑定的饭卡钱包;为空或已有卡号时不执行任何操作
* @param mch 事务外按钱包部门解析的饭卡所属商户;为空时不自动绑卡
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void createAndActiveCard(WalletManageEntity wallet, MchManageVo mch) {
if (ObjectUtil.isEmpty(wallet) || ObjectUtil.isNotEmpty(wallet.getCardNumber()) || ObjectUtil.isEmpty(mch)) return;
LambdaQueryWrapper<WalletActiveEntity> wrapper = new LambdaQueryWrapper<WalletActiveEntity>()
.isNull(WalletActiveEntity::getUserId)
.isNull(WalletActiveEntity::getWalletId)
@@ -206,6 +226,7 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
WalletActiveDto createCardDto = new WalletActiveDto();
createCardDto.setCount(20L);
createCardDto.setMerchantId(mch.getId());
// 经代理调用以触发 REQUIRES_NEW,补卡事务独立提交
getSelf().fastCreateCard(createCardDto);
}
// 在当前事务内锁定候选卡;后续并发请求等待前一笔提交后重新判断空闲条件,避免选中同一张卡。
@@ -54,6 +54,7 @@ import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.dao.PessimisticLockingFailureException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -72,6 +73,12 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
static private final String TAG = "钱包管理";
/** 创建钱包抢卡锁冲突(死锁/锁等待超时)的最大尝试次数;MySQL 对应处理语义是重启事务,直接重试即可。 */
private static final int CREATE_WALLET_MAX_ATTEMPTS = 3;
/** 抢卡锁冲突重试的基础退避毫秒数,按尝试次数递增,避免高并发导入下形成重试风暴。 */
private static final long CREATE_WALLET_RETRY_BACKOFF_MILLIS = 100L;
private final RemoteDataScopeService dataScopeService;
private final WalletTypeMapper walletTypeMapper;
@@ -95,8 +102,17 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
private final WalletTransactionMapper walletTransactionMapper;
/**
* 创建用户各类型钱包,并为新建的饭卡钱包自动分配绑定饭卡。
* <p>用户资料、权限参数、钱包类型全集和饭卡所属商户(均含远程调用)全部在事务外解析,
* 随后经代理调用短事务 {@link #createWalletInTx} 完成写库;抢卡锁冲突(死锁/锁等待超时)
* 时 MySQL 已回滚整笔短事务,直接重试即可,用尽次数后原样抛出。
* 钱包已存在的用户保持原有行为跳过,不重复自动绑卡。</p>
*
* @param userId 用户 ID
* @param checkPermission 是否按 WALLET_AUTO_CREATE 角色参数控制自动开户
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void createWallet(Long userId, Boolean checkPermission) {
UserInfo user = null;
if (checkPermission) {
@@ -119,22 +135,22 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
List<WalletTypeEntity> list = walletTypeMapper.selectList(new LambdaQueryWrapper<>());
if (ObjectUtil.isEmpty(list)) return;
if (ObjectUtil.isEmpty(user)) user = userService.getInnerByUserId(userId).getData();
for (WalletTypeEntity v : list) {
WalletManageEntity wallet = baseMapper.selectOne(new LambdaQueryWrapper<WalletManageEntity>()
.eq(WalletManageEntity::getUserId, userId)
.eq(WalletManageEntity::getWalletTypeId, v.getId())
);
// 创建钱包
WalletManageEntity insertObj = new WalletManageEntity();
if (ObjectUtil.isEmpty(wallet)) {
insertObj.setUserId(userId);
insertObj.setWalletTypeId(v.getId());
insertObj.setDeptId(user.getSysUser().getDeptId());
baseMapper.insert(insertObj);
}
// 默认创建饭卡并绑定饭卡钱包
if (ObjectUtil.equals(v.getUniqueCode(), WalletTypeEnum.EAT.getCode())) {
walletActiveService.createAndActiveCard(insertObj);
Long deptId = user.getSysUser().getDeptId();
// 饭卡所属商户解析包含 Feign 远程调用,必须在事务开启前完成,避免拉长抢卡事务持锁时间。
MchManageVo eatMch = null;
if (list.stream().anyMatch(v -> ObjectUtil.equals(v.getUniqueCode(), WalletTypeEnum.EAT.getCode()))) {
eatMch = mchManageService.getFirstMchByDeptId(deptId,
List.of(MerchantTypeEnum.OTHER_CANTEEN, MerchantTypeEnum.CANTEEN));
}
for (int attempt = 1; ; attempt++) {
try {
getSelf().createWalletInTx(userId, deptId, list, eatMch);
return;
} catch (PessimisticLockingFailureException ex) {
// 死锁/锁等待超时发生时短事务已整体回滚,未产生部分创建的钱包,可直接重试
if (attempt >= CREATE_WALLET_MAX_ATTEMPTS) throw ex;
log.warn("|{}|创建钱包抢卡锁冲突,第{}次重试,userId:{}", TAG, attempt, userId, ex);
sleepBeforeWalletRetry(attempt, ex);
}
}
}
@@ -144,6 +160,54 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
getSelf().createWallet(userId, true);
}
/**
* 创建钱包的短事务实现:钱包占位查询、钱包插入和饭卡钱包自动绑卡。
* <p>仅经代理由 {@link #createWallet(Long, Boolean)} 调用;事务内不发起任何远程调用,
* 避免网络往返期间持有抢卡行锁。饭卡类型钱包仅在本次新建成功后才自动绑卡,
* 已有钱包(含并发请求刚创建的)直接跳过,与原行为保持一致。</p>
*
* @param userId 用户 ID
* @param deptId 事务外解析的用户部门 ID
* @param walletTypes 事务外查询的钱包类型全集
* @param eatMch 事务外解析的饭卡所属商户;为空时饭卡钱包不自动绑卡
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void createWalletInTx(Long userId, Long deptId, List<WalletTypeEntity> walletTypes, MchManageVo eatMch) {
for (WalletTypeEntity v : walletTypes) {
WalletManageEntity wallet = baseMapper.selectOne(new LambdaQueryWrapper<WalletManageEntity>()
.eq(WalletManageEntity::getUserId, userId)
.eq(WalletManageEntity::getWalletTypeId, v.getId())
);
if (ObjectUtil.isNotEmpty(wallet)) continue;
// 创建钱包
WalletManageEntity insertObj = new WalletManageEntity();
insertObj.setUserId(userId);
insertObj.setWalletTypeId(v.getId());
insertObj.setDeptId(deptId);
baseMapper.insert(insertObj);
// 默认创建饭卡并绑定饭卡钱包
if (ObjectUtil.equals(v.getUniqueCode(), WalletTypeEnum.EAT.getCode())) {
walletActiveService.createAndActiveCard(insertObj, eatMch);
}
}
}
/**
* 抢卡锁冲突重试前的递增退避;线程被中断时恢复中断标记并终止重试。
*
* @param attempt 当前已失败的尝试序号(从 1 开始)
* @param cause 触发重试的锁冲突异常,中断时原样抛出
*/
private void sleepBeforeWalletRetry(int attempt, PessimisticLockingFailureException cause) {
try {
Thread.sleep(CREATE_WALLET_RETRY_BACKOFF_MILLIS * attempt);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw cause;
}
}
@Override
public WalletManageEntity getOrCreateWallet(Long userId, WalletTypeEnum walletTypeCode) {
WalletTypeEntity walletType = walletTypeService.getByUniqueCode(walletTypeCode);
@@ -534,8 +598,16 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
return walletList;
}
/**
* 用户资料变更后的钱包侧同步。
* <p>用户资料查询与食堂商户解析(均含远程调用)在事务外完成,本地更新经短事务
* {@link #userChangeInTx} 提交;正元第三方同步放在事务提交后执行,
* 避免远程调用和第三方逻辑拉长事务持锁时间,同步异常也不再回滚本地部门变更。</p>
*
* @param userId 变更用户 ID
* @param isDel 是否删除事件;仅处理非删除事件
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void userChange(Long userId, Boolean isDel) {
if (ObjectUtil.isEmpty(userId)) return;
if (ObjectUtil.isEmpty(isDel)) return;
@@ -543,27 +615,39 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
UserInfo userInfo = userService.getInnerByUserId(userId).getData();
if (ObjectUtil.isEmpty(userInfo)) return;
SysUser sysUser = userInfo.getSysUser();
// 修改钱包部门
baseMapper.update(new LambdaUpdateWrapper<WalletManageEntity>()
.set(WalletManageEntity::getDeptId, sysUser.getDeptId())
.ne(WalletManageEntity::getDeptId, sysUser.getDeptId())
.eq(WalletManageEntity::getUserId, sysUser.getUserId())
);
// 修改钱包冻结开卡商户
// 查询用户所属于的食堂商户
// 查询用户所属于的食堂商户,解析含远程调用,先于事务执行
MchManageVo mch = mchManageService.getFirstMchByDeptId(sysUser.getDeptId(), List.of(MerchantTypeEnum.CANTEEN));
if (ObjectUtil.isNotEmpty(mch)) {
walletActiveService.update(new LambdaUpdateWrapper<WalletActiveEntity>()
.set(WalletActiveEntity::getMerchantId, mch.getId())
.ne(WalletActiveEntity::getMerchantId, mch.getId())
.eq(WalletActiveEntity::getUserId, sysUser.getUserId())
);
}
// 同步到正元
getSelf().userChangeInTx(sysUser, mch);
// 同步到正元:本地更新提交后执行,失败不影响钱包部门变更
walletThirdZyService.syncAllUser(userId);
}
}
/**
* 用户变更同步的短事务实现:钱包部门与已绑饭卡商户的本地更新。
*
* @param sysUser 已查询的用户资料,取部门和用户 ID
* @param mch 按用户部门解析的食堂商户;为空时不调整已绑饭卡的商户归属
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void userChangeInTx(SysUser sysUser, MchManageVo mch) {
// 修改钱包部门
baseMapper.update(new LambdaUpdateWrapper<WalletManageEntity>()
.set(WalletManageEntity::getDeptId, sysUser.getDeptId())
.ne(WalletManageEntity::getDeptId, sysUser.getDeptId())
.eq(WalletManageEntity::getUserId, sysUser.getUserId())
);
// 修改钱包冻结开卡商户
if (ObjectUtil.isNotEmpty(mch)) {
walletActiveService.update(new LambdaUpdateWrapper<WalletActiveEntity>()
.set(WalletActiveEntity::getMerchantId, mch.getId())
.ne(WalletActiveEntity::getMerchantId, mch.getId())
.eq(WalletActiveEntity::getUserId, sysUser.getUserId())
);
}
}
@Override
public void deptChange(Long deptId, Boolean isDel) {
if (ObjectUtil.isEmpty(deptId)) return;
@@ -59,6 +59,15 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
/** 过期预约退款默认恢复库存。 */
private static final String DEFAULT_EXPIRED_RESERVE_RESTORE_INVENTORY = "1";
/** 自动核销允许的最小天数。 */
private static final int MIN_AUTO_VERIFY_DAYS = 1;
/** 自动核销允许的最大天数,覆盖一年内的核销窗口即可,更长周期订单应改用人工处理。 */
private static final int MAX_AUTO_VERIFY_DAYS = 365;
/** 自动核销默认恢复库存,与过期预约退款一致,过期未取餐应回收每日菜谱线上池配额。 */
private static final String DEFAULT_AUTO_VERIFY_RESTORE_INVENTORY = "1";
/**
* 商户服务,用于管理接口校验目标商户属于当前用户的数据范围。
*/
@@ -90,7 +99,8 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
validateMerchantScope(dto.getMchId());
validateConfig(dto.getOrderType(), dto.getEnabled(), dto.getRuleType(), dto.getPayTimeLimitHours(),
dto.getExpiredReserveRefundEnabled(), dto.getExpiredReserveRefundDelayHours(),
dto.getExpiredReserveRefundRestoreInventory());
dto.getExpiredReserveRefundRestoreInventory(), dto.getAutoVerifyEnabled(), dto.getAutoVerifyDays(),
dto.getAutoVerifyRestoreInventory());
MchAutoRefundConfigEntity exact = baseMapper.selectExactForUpdate(dto.getMchId(), dto.getOrderType());
MchAutoRefundConfigEntity target;
@@ -119,6 +129,10 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
target.setExpiredReserveRefundDelayHours(resolveExpiredDelay(dto.getExpiredReserveRefundDelayHours()));
target.setExpiredReserveRefundRestoreInventory(resolveYesNo(dto.getExpiredReserveRefundRestoreInventory(),
DEFAULT_EXPIRED_RESERVE_RESTORE_INVENTORY));
target.setAutoVerifyEnabled(resolveYesNo(dto.getAutoVerifyEnabled(), YesNoEnum.NO.getCode()));
target.setAutoVerifyDays(dto.getAutoVerifyDays());
target.setAutoVerifyRestoreInventory(resolveYesNo(dto.getAutoVerifyRestoreInventory(),
DEFAULT_AUTO_VERIFY_RESTORE_INVENTORY));
if (target.getId() == null) {
if (baseMapper.insert(target) != 1) {
throw new RuntimeException("新增自动退款配置失败");
@@ -218,7 +232,8 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
}
validateConfig(config.getOrderType(), config.getEnabled(), config.getRuleType(), config.getPayTimeLimitHours(),
config.getExpiredReserveRefundEnabled(), config.getExpiredReserveRefundDelayHours(),
config.getExpiredReserveRefundRestoreInventory());
config.getExpiredReserveRefundRestoreInventory(), config.getAutoVerifyEnabled(),
config.getAutoVerifyDays(), config.getAutoVerifyRestoreInventory());
return config;
}
@@ -256,9 +271,16 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
* @param enabled 开关编码
* @param ruleType 截止规则名称
* @param payTimeLimitHours 支付后限时小时数
* @param expiredEnabled 过期预约退款开关编码
* @param expiredDelayHours 过期预约退款延迟小时数
* @param expiredRestoreInventory 过期预约退库存开关编码
* @param autoVerifyEnabled 自动核销开关编码
* @param autoVerifyDays 支付后自动核销天数
* @param autoVerifyRestoreInventory 自动核销退库存开关编码
*/
private void validateConfig(String orderType, String enabled, String ruleType, Integer payTimeLimitHours,
String expiredEnabled, Integer expiredDelayHours, String expiredRestoreInventory) {
String expiredEnabled, Integer expiredDelayHours, String expiredRestoreInventory,
String autoVerifyEnabled, Integer autoVerifyDays, String autoVerifyRestoreInventory) {
validateOrderType(orderType);
if (!Objects.equals(enabled, YesNoEnum.YES.getCode()) && !Objects.equals(enabled, YesNoEnum.NO.getCode())) {
throw new RuntimeException("自动退款开关值无效");
@@ -285,6 +307,22 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
&& !Objects.equals(expiredRestoreInventory, YesNoEnum.NO.getCode())) {
throw new RuntimeException("过期预约退库存开关值无效");
}
// 自动核销与自动退款共用本配置行,但开关、时限和退库存完全独立校验。
if (autoVerifyEnabled != null && !Objects.equals(autoVerifyEnabled, YesNoEnum.YES.getCode())
&& !Objects.equals(autoVerifyEnabled, YesNoEnum.NO.getCode())) {
throw new RuntimeException("自动核销开关值无效");
}
if (autoVerifyDays != null && (autoVerifyDays < MIN_AUTO_VERIFY_DAYS || autoVerifyDays > MAX_AUTO_VERIFY_DAYS)) {
throw new RuntimeException("支付后自动核销天数必须在1至365天之间");
}
// 开启自动核销时天数必填,避免任务创建时无法计算触发时间。
if (Objects.equals(autoVerifyEnabled, YesNoEnum.YES.getCode()) && autoVerifyDays == null) {
throw new RuntimeException("开启自动核销时必须配置支付后天数");
}
if (autoVerifyRestoreInventory != null && !Objects.equals(autoVerifyRestoreInventory, YesNoEnum.YES.getCode())
&& !Objects.equals(autoVerifyRestoreInventory, YesNoEnum.NO.getCode())) {
throw new RuntimeException("自动核销退库存开关值无效");
}
}
/** 解析过期退款延迟,兼容历史配置行新增字段为空的场景。 */
@@ -167,6 +167,8 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
.userDeptId(row.getUserDeptId())
.invoiceId(invoice.getId())
.tags(row.getTags())
// Excel"备注"列随明细落库,审批通过后写入钱包流水 remark
.remark(row.getRemark())
.build())
.toList();
walletRechargeImportService.saveBatch(details);
@@ -197,6 +199,8 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
.userId(row.getUserId())
.userDeptId(row.getUserDeptId())
.tags(row.getTags())
// Excel"备注"列随明细落库,审批通过后写入钱包流水 remark
.remark(row.getRemark())
.build())
.toList();
walletRechargeImportService.saveBatch(details);
@@ -227,6 +231,8 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
.userId(row.getUserId())
.userDeptId(row.getUserDeptId())
.tags(row.getTags())
// Excel"备注"列随明细落库,审批通过后写入钱包流水 remark
.remark(row.getRemark())
.build())
.toList();
walletRechargeImportService.saveBatch(details);
@@ -369,7 +369,8 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl<WalletRechargeAp
}
// 判断是`退还各自食堂`还是`批量更新余额`
WalletBizEnum walletBiz = ObjectUtil.equal(WalletBizRechargeEnum.RETURN_CANTEEN.getCode(), detail.getBizType()) ? WalletBizEnum.RETURN_CANTEEN : WalletBizEnum.BATCH;
walletTransactionService.addWalletBalance(wallet.getId(), v.getPrice(), String.valueOf(v.getId()), walletBiz, v.getTags());
// Excel 导入的行级备注随流水落库,6 参重载内部会做空白清理
walletTransactionService.addWalletBalance(wallet.getId(), v.getPrice(), String.valueOf(v.getId()), walletBiz, v.getTags(), v.getRemark());
}
// 同步到正元钱包状态
List<Long> userIds = importList.stream().map(WalletRechargeImportEntity::getUserId).distinct().toList();