feat:钱包的流水增加"备注字段"

fix:合并设备管理的统计模块
This commit is contained in:
dzc committed 2026-08-25 11:11:01 +08:00
1 parent d1cd86dfa4
commit 9d2a0ee1ab
49 files changed
+1943 -424

No files matched your search

+96 -2
View File
@@ -31,8 +31,8 @@ CREATE TABLE IF NOT EXISTS `third_payment_task`
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`),
KEY `idx_third_payment_task_due` (`status`, `next_run_time`, `priority`, `id`),
KEY `idx_third_payment_task_lease` (`status`, `lease_expire_time`),
KEY `idx_third_payment_task_due` (`task_type`, `status`, `next_run_time`, `priority`, `id`),
KEY `idx_third_payment_task_lease` (`task_type`, `status`, `lease_expire_time`),
KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`),
KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实';
@@ -57,3 +57,97 @@ CREATE TABLE IF NOT EXISTS `third_payment_task_attempt`
KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`),
KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试';
-- Quartz 会按业务事实持续物化任务。百万级订单量下,如果业务表只有主键,每个固定触发器都会退化为全表扫描。
-- 以下索引只覆盖实际扫描谓词和租户内业务号关联;使用 information_schema 判断保证迁移可以重复执行。
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_task_scan'),
'SELECT ''idx_third_pay_order_task_scan already exists'' AS message',
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_fz_scan'),
'SELECT ''idx_third_pay_order_fz_scan already exists'' AS message',
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_callback_scan'),
'SELECT ''idx_third_pay_order_callback_scan already exists'' AS message',
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_close_scan'),
'SELECT ''idx_third_pay_order_close_scan already exists'' AS message',
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_tenant_pay_no'),
'SELECT ''idx_third_pay_order_tenant_pay_no already exists'' AS message',
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_task_scan'),
'SELECT ''idx_third_fz_order_task_scan already exists'' AS message',
'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_tenant_pay_no'),
'SELECT ''idx_third_fz_order_tenant_pay_no already exists'' AS message',
'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_task_scan'),
'SELECT ''idx_third_aggregate_task_scan already exists'' AS message',
'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
SET @index_ddl = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_close_scan'),
'SELECT ''idx_third_aggregate_close_scan already exists'' AS message',
'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)'
);
PREPARE index_stmt FROM @index_ddl;
EXECUTE index_stmt;
DEALLOCATE PREPARE index_stmt;
-- 生产执行建议:低峰期使用在线 DDL 能力建索引,完成后 ANALYZE TABLE 并用真实状态分布执行 EXPLAIN ANALYZE。
+79
View File
@@ -0,0 +1,79 @@
USE `pjcloudx_thirdparty`;
-- 商户默认平台分佣关系。微信关系由 sub_mchid 区分,支付宝关系由调用应用、授权商户和接收账号共同确定。
-- 表中只保存渠道标识和脱敏调用快照,不保存私钥、公钥或完整 app_auth_token。
CREATE TABLE IF NOT EXISTS `third_mch_fz_relation`
(
`id` bigint NOT NULL COMMENT '关系主键,应用侧雪花算法生成',
`tenant_id` bigint NOT NULL COMMENT '租户ID,与 mch_manage 所属租户一致',
`mch_id` bigint NOT NULL COMMENT 'mch_manage 商户主键',
`pay_setting_id` bigint NOT NULL COMMENT 'mch_pay_setting 支付配置主键',
`platform_type` varchar(32) NOT NULL COMMENT '支付平台类型:10微信、20支付宝',
`relation_scene` varchar(64) NOT NULL COMMENT '本地关系场景,默认平台分佣为 DEFAULT_PLATFORM_COMMISSION',
`channel_app_id` varchar(64) NOT NULL COMMENT '微信服务商AppID或支付宝平台AppID快照',
`channel_sub_mch_id` varchar(64) NOT NULL COMMENT '微信子商户号或支付宝直付通smid/ip_role_id',
`account_key` varchar(64) DEFAULT NULL COMMENT '支付宝命名平台账号键;默认账号及微信为空',
`receiver_type` varchar(32) NOT NULL COMMENT '渠道接收方类型,如MERCHANT_ID、userId、loginName',
`receiver_account` varchar(128) NOT NULL COMMENT '平台分佣接收账号;管理端展示时必须脱敏',
`receiver_name` varchar(128) DEFAULT NULL COMMENT '平台分佣接收方名称快照',
`channel_relation_type` varchar(64) DEFAULT NULL COMMENT '渠道关系类型;微信默认SERVICE_PROVIDER,支付宝为空',
`out_request_no` varchar(64) NOT NULL COMMENT '支付宝绑定稳定幂等请求号;微信作为本地稳定审计号',
`status` varchar(32) NOT NULL COMMENT 'PENDING_BIND、BINDING、BOUND、BIND_FAILED或SUPERSEDED',
`is_default` char(1) NOT NULL DEFAULT '1' COMMENT '是否为当前支付配置的默认平台分佣关系:0否、1是',
`bind_req` text DEFAULT NULL COMMENT '脱敏后的关系绑定请求快照',
`bind_res` text DEFAULT NULL COMMENT '渠道关系绑定或查询响应快照',
`bind_error` varchar(1000) DEFAULT NULL COMMENT '最近一次绑定错误摘要,不含凭据',
`last_external_status` varchar(128) DEFAULT NULL COMMENT '最近一次渠道结果码或外部状态',
`last_query_time` datetime DEFAULT NULL COMMENT '最近一次关系绑定或查询时间',
`bound_time` datetime DEFAULT NULL COMMENT '渠道明确确认关系已绑定的时间',
`create_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0正常、1删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_mch_fz_relation_identity`
(`tenant_id`, `pay_setting_id`, `platform_type`, `channel_app_id`, `channel_sub_mch_id`,
`receiver_type`, `receiver_account`, `relation_scene`),
KEY `idx_third_mch_fz_relation_default`
(`tenant_id`, `mch_id`, `platform_type`, `is_default`, `status`, `del_flag`),
KEY `idx_third_mch_fz_relation_task`
(`status`, `is_default`, `update_time`, `id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci
COMMENT ='商户第三方支付分账关系';
-- 支付任务通过结构化主键定位关系事实,禁止从 task_key 字符串反解析业务主键。
SET @relation_task_column_exists = (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'third_payment_task'
AND COLUMN_NAME = 'fz_relation_id'
);
SET @relation_task_column_sql = IF(
@relation_task_column_exists = 0,
'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''商户分账关系主键'' AFTER `fz_order_id`, ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)',
'SELECT 1'
);
PREPARE relation_task_column_stmt FROM @relation_task_column_sql;
EXECUTE relation_task_column_stmt;
DEALLOCATE PREPARE relation_task_column_stmt;
-- 支付订单固定引用创建时使用的已绑定关系,后续分账不跟随平台接收账号配置漂移。
SET @pay_relation_column_exists = (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'third_pay_order'
AND COLUMN_NAME = 'fz_relation_id'
);
SET @pay_relation_column_sql = IF(
@pay_relation_column_exists = 0,
'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键'' AFTER `mch_pay_setting_json`, ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)',
'SELECT 1'
);
PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql;
EXECUTE pay_relation_column_stmt;
DEALLOCATE PREPARE pay_relation_column_stmt;
@@ -0,0 +1,3 @@
-- 为每笔钱包流水增加可选业务备注;历史流水保持为空,前后端统一限制为 256 个字符。
ALTER TABLE wallet_transaction
ADD COLUMN remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符' AFTER tags;
+1
View File
@@ -456,6 +456,7 @@ CREATE TABLE wallet_transaction
price int NOT NULL COMMENT '交易金额, 单位分',
balance int NOT NULL COMMENT '余额, 单位分',
tags varchar(512) DEFAULT NULL COMMENT '财务统计标签,多个wallet_transaction_tag字典值使用英文逗号分隔',
remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
@@ -56,6 +56,11 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
*/
private List<String> saleSourceCodes;
/**
* 商品名称模糊查询条件,仅供购物车商品统计 SQL 筛选使用,不对应订单表持久化字段。
*/
private String productName;
private LocalDateTime[] refundTimeList;
/** 预计使用日期范围。 */
@@ -3,23 +3,27 @@ package com.bsd.pjcloud.hard.api.vo.order;
import lombok.Data;
/**
* 食堂预约菜品导出聚合记录。
* 食堂菜品销售三级导出的聚合记录。
*
* <p>同一模型承载商户部门聚合和商品聚合两种查询结果,商品聚合时额外填充商品字段;金额统一使用分。</p>
* <p>同一模型承载日期餐段、商户部门和商品三种聚合查询结果,商品聚合时额外填充商品字段;
* 统计时间已按销售来源统一转换为业务发生日期,金额统一使用分。</p>
*/
@Data
public class HardCanteenProductApptExportRecord {
/** 预约使用日期,格式为 yyyy-MM-dd。 */
private String apptDate;
/** 业务发生日期,格式为 yyyy-MM-dd;线下取支付日期,预约取核销日期。 */
private String statisticsDate;
/** 用餐时间段协议编码。 */
private String hourType;
/** 销售来源协议编码;为空表示当前筛选范围内的跨来源合计。 */
private String saleSource;
/** 食堂商户主键。 */
private Long merchantId;
/** 预约用户所属部门主键。 */
/** 购买用户所属部门主键。 */
private Long userDeptId;
/** 商品主键;商户部门聚合记录为空。 */
@@ -28,15 +32,15 @@ public class HardCanteenProductApptExportRecord {
/** 订单快照中的商品名称;商户部门聚合记录为空。 */
private String productName;
/** 去重预约人数。 */
/** 当前统计层级内去重后的交易人数。 */
private Long payUserCount;
/** 去重预约次数。 */
/** 当前统计层级内去重后的订单数量。 */
private Long timeCount;
/** 预约商品总数量。 */
/** 有效商品总数量,预约来源已扣减退款完成数量。 */
private Long payCount;
/** 预约商品总价,单位为分。 */
/** 有效商品总价,单位为分;预约来源已扣减退款完成金额。 */
private Long totalPrice;
}
@@ -9,32 +9,36 @@ import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 食堂预约菜品统计自定义导出行。
* 食堂菜品销售统计自定义导出行。
*
* <p>一组日期和餐段按汇总行、商户部门行、商品明细行组织;商品统计在同一商户部门块内按名称合并套餐展开后的明细,
* 不同层级只填写所属列,配合自定义合并处理器形成树形报表。</p>
* <p>一个日期、餐段和来源口径按汇总行、商户部门行、商品明细行组织;来源口径包含筛选范围合计及各销售来源。
* 商品统计在同一商户部门块内按名称合并套餐展开后的明细,不同层级只填写所属列,配合自定义合并处理器形成树形报表。</p>
*/
@Data
public class HardCanteenProductApptExcelVo {
/** 日期餐段汇总中的预约日期。 */
@ExcelProperty({"日期餐段汇总", "预约时间"})
/** 日期餐段汇总中的业务发生日期。 */
@ExcelProperty({"日期餐段汇总", "统计日期"})
private LocalDate summaryDate;
/** 日期餐段汇总中的餐段编码或字典名称。 */
@ExcelProperty({"日期餐段汇总", "预约时间段"})
@ExcelProperty({"日期餐段汇总", "餐段"})
private String summaryHourType;
/** 日期餐段汇总中的去重预约人数。 */
@ExcelProperty({"日期餐段汇总", "预约人数"})
/** 日期餐段汇总的来源口径,取值为筛选范围合计、线下购买或预约核销。 */
@ExcelProperty({"日期餐段汇总", "来源口径"})
private String summarySaleSourceName;
/** 日期餐段汇总中的去重交易人数。 */
@ExcelProperty({"日期餐段汇总", "交易人数"})
private Long summaryPayUserCount;
/** 日期餐段汇总中的预约次数。 */
@ExcelProperty({"日期餐段汇总", "预约次数"})
/** 日期餐段汇总中的交易次数。 */
@ExcelProperty({"日期餐段汇总", "交易次数"})
private Long summaryTimeCount;
/** 日期餐段汇总中的商品总数量。 */
@ExcelProperty({"日期餐段汇总", "预约商品数量"})
@ExcelProperty({"日期餐段汇总", "商品数量"})
private Long summaryPayCount;
/** 日期餐段汇总中的总价,单位为元。 */
@@ -50,24 +54,24 @@ public class HardCanteenProductApptExcelVo {
@ExcelProperty({"商户部门统计", "部门"})
private String departmentName;
/** 商户部门统计中的预约日期。 */
@ExcelProperty({"商户部门统计", "预约时间"})
/** 商户部门统计中的业务发生日期。 */
@ExcelProperty({"商户部门统计", "统计日期"})
private LocalDate merchantDate;
/** 商户部门统计中的餐段名称。 */
@ExcelProperty({"商户部门统计", "预约时间段"})
@ExcelProperty({"商户部门统计", "餐段"})
private String merchantHourType;
/** 商户部门统计中的去重预约人数。 */
@ExcelProperty({"商户部门统计", "预约人数"})
/** 商户部门统计中的去重交易人数。 */
@ExcelProperty({"商户部门统计", "交易人数"})
private Long merchantPayUserCount;
/** 商户部门统计中的预约次数。 */
@ExcelProperty({"商户部门统计", "预约次数"})
/** 商户部门统计中的交易次数。 */
@ExcelProperty({"商户部门统计", "交易次数"})
private Long merchantTimeCount;
/** 商户部门统计中的商品总数量。 */
@ExcelProperty({"商户部门统计", "预约商品数量"})
/** 商户部门统计中的有效商品总数量。 */
@ExcelProperty({"商户部门统计", "商品数量"})
private Long merchantPayCount;
/** 商户部门统计中的总价,单位为元。 */
@@ -92,7 +96,7 @@ public class HardCanteenProductApptExcelVo {
@ExcelProperty({"商品统计", "商品名称"})
private String statisticProductName;
/** 商品统计中的商品总数量,按退款扣减及套餐展开后的有效明细累计。 */
/** 商品统计中的商品总数量,预约按退款扣减,套餐按组成商品展开后的有效明细累计。 */
@ExcelProperty({"商品统计", "商品总数量"})
private Long statisticProductCount;
@@ -101,7 +105,7 @@ public class HardCanteenProductApptExcelVo {
@NumberFormat("#,##0.00")
private BigDecimal statisticProductTotalPrice;
/** 导出行层级,仅供合并处理器识别,不写入 Excel。 */
/** 导出行层级,仅供合并处理器和测试识别,不写入 Excel。 */
@ExcelIgnore
private String rowLevel;
}
@@ -21,25 +21,25 @@ import java.util.HashMap;
import java.util.Map;
/**
* 食堂预约菜品统计导出合并处理器。
* 食堂菜品销售统计三级导出合并处理器。
*
* <p>数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列;
* 第 17 至 19 列展示同一商户部门内按名称合并后的商品统计。
* <p>数据行通过各层级专属列识别:日期餐段来源行填写第 0 列,商户部门行填写第 7 列,商品行填写第 15 列;
* 第 18 至 20 列展示同一商户部门内按名称合并后的商品统计。
* Easy Excel 使用流式工作簿,较早的数据行会在关闭工作簿前刷出内存,因此必须在逐行写出时记录分组边界并及时合并。</p>
*/
public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler, WorkbookWriteHandler {
/** 日期餐段汇总列。 */
private static final int[] SUMMARY_COLUMNS = {0, 1, 2, 3, 4, 5};
private static final int[] SUMMARY_COLUMNS = {0, 1, 2, 3, 4, 5, 6};
/** 商户部门统计列。 */
private static final int[] MERCHANT_COLUMNS = {6, 7, 8, 9, 10, 11, 12, 13};
private static final int[] MERCHANT_COLUMNS = {7, 8, 9, 10, 11, 12, 13, 14};
/** Easy Excel 双层表头占用两行,数据从第三行开始。 */
private static final int DATA_START_ROW = 2;
/** 导出列总数,用于逐行应用居中样式。 */
private static final int COLUMN_COUNT = 20;
private static final int COLUMN_COUNT = 21;
/** 表头字体大小,单位为磅;使用紧凑字号降低双层表头占用空间。 */
private static final short HEAD_FONT_SIZE = 10;
@@ -57,7 +57,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
* 各列宽度,单位为 Excel 字符宽度;常规统计列保持紧凑,商品名称列单独加宽以展示更多名称内容。
*/
private static final int[] COLUMN_WIDTHS = {
12, 11, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11, 32, 14, 12
12, 11, 16, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11, 32, 14, 12
};
/** 原样式索引到居中样式的缓存,避免每个数据单元格重复创建样式。 */
@@ -113,7 +113,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
applyCenteredStyle(row, writeSheetHolder.getParentWriteWorkbookHolder());
int rowIndex = row.getRowNum();
boolean summaryRow = hasValue(row, 0);
boolean merchantRow = hasValue(row, 6);
boolean merchantRow = hasValue(row, 7);
boolean newDateGroup = false;
if (summaryRow) {
String summaryDateKey = getCellValueKey(row, 0);
@@ -260,7 +260,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
CellStyle copiedStyle = holder.getWorkbook().createCellStyle();
copiedStyle.cloneStyleFrom(originalStyle);
if (!(copiedStyle instanceof XSSFCellStyle xssfStyle)) {
throw new IllegalStateException("菜品预约导出仅支持 XLSX 彩色边框样式");
throw new IllegalStateException("菜品销售统计导出仅支持 XLSX 彩色边框样式");
}
XSSFColor color = new XSSFColor(DATE_GROUP_BORDER_COLORS[colorIndex]);
if (leftBorder) {
@@ -69,14 +69,29 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
Page<HardCanteenProductTotalVo> productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/** 按预约日期和餐段查询导出所需的日期餐段总计。 */
List<HardCanteenProductTotalVo> listApptExportSummary(@Param("dto") HardOrderDto dto);
/**
* 查询菜品销售三级导出的日期餐段汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个日期餐段的跨来源合计及各来源统计,交易人数在当前层级独立去重
*/
List<HardCanteenProductApptExportRecord> listSalesExportSummary(@Param("dto") HardOrderDto dto);
/** 按预约日期、餐段、商户和部门查询导出商户部门统计。 */
List<HardCanteenProductApptExportRecord> listApptExportMerchant(@Param("dto") HardOrderDto dto);
/**
* 查询菜品销售三级导出的商户部门汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个日期餐段下商户部门的跨来源合计及各来源统计
*/
List<HardCanteenProductApptExportRecord> listSalesExportMerchant(@Param("dto") HardOrderDto dto);
/** 按预约日期、餐段、商户、部门和商品查询导出商品明细。 */
List<HardCanteenProductApptExportRecord> listApptExportProduct(@Param("dto") HardOrderDto dto);
/**
* 查询菜品销售三级导出的商品汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个商户部门块下商品的跨来源合计及各来源统计
*/
List<HardCanteenProductApptExportRecord> listSalesExportProduct(@Param("dto") HardOrderDto dto);
Page<HardCarVo> proudctQuery(Page page, @Param("dto") HardCarDto dto);
@@ -15,8 +15,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import java.util.List;
import java.util.Map;
@@ -69,21 +68,14 @@ public interface HardCanteenTotalService {
List<HardCanteenProductTotalVo> listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope);
/**
* 按页面相同筛选条件导出菜品销售统计。
* 按页面筛选条件导出统一的菜品销售三级统计。
*
* @param dto 来源统计请求
* @param dataScope 部门数据权限;为空时沿用控制器既有范围
* @return 按日期、餐段和来源拆分的导出行
*/
List<HardCanteenProductTotalExcelVo> exportProduct(HardOrderDto dto, DataScope dataScope);
/**
* 按页面相同筛选条件导出部门销售统计。
* <p>每个日期餐段同时生成当前来源范围的合计块以及各来源块,每个块继续按商户部门和商品展开;
* 菜品统计页与部门统计页共用该方法,避免两套导出口径再次分叉。</p>
*
* @param dto 来源统计请求
* @param user 当前登录用户,用于沿用商户范围查询
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @param dataScope 部门数据权限;为空时沿用控制器既有范围
* @return 按日期、食堂、来源、部门和餐段拆分的导出行
* @return 日期餐段、商户部门、商品三级排列的导出行
*/
List<HardCanteenDeptTotalExcelVo> exportDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope);
List<HardCanteenProductApptExcelVo> exportSales(HardOrderDto dto, DataScope dataScope);
}
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.service.impl.order;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysDictItem;
@@ -10,7 +11,10 @@ import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenCategoryService;
import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
@@ -24,14 +28,14 @@ import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductByCanteenDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductByClassifyDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductLikeTopDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
@@ -50,11 +54,14 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
@@ -73,6 +80,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
static private final String TAG = "食堂统计";
/** 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。 */
private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE;
private final HardOrderMapper hardOrderMapper;
private final HardCarMapper hardCarMapper;
@@ -81,6 +91,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
private final RemoteDataScopeService dataScopeService;
private final RemoteCanteenCategoryService canteenCategoryService;
/** 每日菜谱远程服务,用于在三级导出中按业务日期展开套餐组成商品。 */
private final RemoteDailyMenuService remoteDailyMenuService;
/** 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。 */
private final RemoteDictService remoteDictService;
@@ -640,138 +640,164 @@
order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') desc, o.hour_type
</select>
<!-- 菜品预约导出按单个用餐日期和餐段返回日期餐段汇总。 -->
<select id="listApptExportSummary" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
select DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') as createTime,
o.hour_type as hourType,
SUM(a.product_count) as payCount,
SUM(a.total_price) as totalPrice,
count(distinct o.id) as timeCount,
count(distinct o.user_id) as payUserCount
from <include refid="apptEffectiveCarSource"/>
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
and o.hour_type is not null and o.hour_type not in ('-1')
<where>
<include refid="whereA"/>
and o.order_type = #{dto.orderType}
and (o.appt_id is not null or o.appt_rule_id is not null)
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.hourType != null and dto.hourType != ''">and o.hour_type = #{dto.hourType}</if>
<if test="dto.merchantId != null">and o.merchant_id = #{dto.merchantId}</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == false">and o.user_dept_id not in</when>
<otherwise>and o.user_dept_id in</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.merchantDeptIds != null and dto.merchantDeptIds.size() != 0">
and o.merchant_dept_id in
<foreach collection="dto.merchantDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and o.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
</where>
group by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type
order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type
<!--
综合菜品销售导出为每个日期餐段同时生成跨来源合计和来源拆分。
三层 SQL 使用相同的来源、业务时间、食堂和部门筛选,保证页面与导出以及上下层之间口径一致。
-->
<select id="listSalesExportSummary"
resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select export_rows.statisticsDate,
export_rows.hourType,
export_rows.saleSource,
export_rows.payUserCount,
export_rows.timeCount,
export_rows.payCount,
export_rows.totalPrice
from (
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
null as saleSource,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type
union all
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source
) export_rows
order by export_rows.statisticsDate,
export_rows.hourType,
case when export_rows.saleSource is null then 0
when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end
</select>
<!-- 菜品预约导出按商户和用户部门返回第二层统计。 -->
<select id="listApptExportMerchant" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
select DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') as apptDate,
o.hour_type as hourType,
o.merchant_id as merchantId,
o.user_dept_id as userDeptId,
count(distinct o.user_id) as payUserCount,
count(distinct o.id) as timeCount,
SUM(a.product_count) as payCount,
SUM(a.total_price) as totalPrice
from <include refid="apptEffectiveCarSource"/>
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
and o.hour_type is not null and o.hour_type not in ('-1')
<where>
<include refid="whereA"/>
and o.order_type = #{dto.orderType}
and (o.appt_id is not null or o.appt_rule_id is not null)
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.hourType != null and dto.hourType != ''">and o.hour_type = #{dto.hourType}</if>
<if test="dto.merchantId != null">and o.merchant_id = #{dto.merchantId}</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == false">and o.user_dept_id not in</when>
<otherwise>and o.user_dept_id in</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.merchantDeptIds != null and dto.merchantDeptIds.size() != 0">
and o.merchant_dept_id in
<foreach collection="dto.merchantDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and o.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
</where>
group by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id
order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id
<select id="listSalesExportMerchant"
resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select export_rows.statisticsDate,
export_rows.hourType,
export_rows.saleSource,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.payUserCount,
export_rows.timeCount,
export_rows.payCount,
export_rows.totalPrice
from (
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
null as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.mch_id, sale_stats.user_dept_id
union all
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source,
sale_stats.mch_id, sale_stats.user_dept_id
) export_rows
order by export_rows.statisticsDate,
export_rows.hourType,
case when export_rows.saleSource is null then 0
when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end,
export_rows.merchantId,
export_rows.userDeptId
</select>
<!-- 菜品预约导出按商户、部门和商品返回第三层商品明细。 -->
<select id="listApptExportProduct" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
select DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') as apptDate,
o.hour_type as hourType,
o.merchant_id as merchantId,
o.user_dept_id as userDeptId,
a.product_id as productId,
a.product_name as productName,
SUM(a.product_count) as payCount,
SUM(a.total_price) as totalPrice
from <include refid="apptEffectiveCarSource"/>
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
and o.hour_type is not null and o.hour_type not in ('-1')
<where>
<include refid="whereA"/>
and o.order_type = #{dto.orderType}
and (o.appt_id is not null or o.appt_rule_id is not null)
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.hourType != null and dto.hourType != ''">and o.hour_type = #{dto.hourType}</if>
<if test="dto.merchantId != null">and o.merchant_id = #{dto.merchantId}</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == false">and o.user_dept_id not in</when>
<otherwise>and o.user_dept_id in</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.merchantDeptIds != null and dto.merchantDeptIds.size() != 0">
and o.merchant_dept_id in
<foreach collection="dto.merchantDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and o.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
</where>
group by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id,
a.product_id, a.product_name
order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id,
a.product_name
<select id="listSalesExportProduct"
resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select export_rows.statisticsDate,
export_rows.hourType,
export_rows.saleSource,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.productId,
export_rows.productName,
export_rows.payCount,
export_rows.totalPrice
from (
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
null as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
sale_stats.product_id as productId,
sale_stats.product_name as productName,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.mch_id, sale_stats.user_dept_id,
sale_stats.product_id, sale_stats.product_name
union all
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
sale_stats.product_id as productId,
sale_stats.product_name as productName,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source,
sale_stats.mch_id, sale_stats.user_dept_id, sale_stats.product_id, sale_stats.product_name
) export_rows
order by export_rows.statisticsDate,
export_rows.hourType,
case when export_rows.saleSource is null then 0
when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.productName
</select>
<select id="proudctQuery" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCarVo">
select a.product_id,
a.product_name,
@@ -0,0 +1,24 @@
package com.bsd.pjcloud.thirdparty.constant.fz;
import lombok.Getter;
/**
* 商户分账关系业务场景,编码用于区分同一渠道账号未来可能增加的其他分账用途。
*/
@Getter
public enum ThirdMchFzRelationSceneEnum {
/** 商户支付成功后向平台收取服务费的默认分佣关系。 */
DEFAULT_PLATFORM_COMMISSION("DEFAULT_PLATFORM_COMMISSION");
/** 关系表持久化的稳定业务场景编码。 */
private final String code;
/**
* 创建关系场景。
*
* @param code 持久化场景编码
*/
ThirdMchFzRelationSceneEnum(String code) {
this.code = code;
}
}
@@ -0,0 +1,32 @@
package com.bsd.pjcloud.thirdparty.constant.fz;
import lombok.Getter;
/**
* 商户渠道分账关系状态,编码持久化到 {@code third_mch_fz_relation.status},不可随意修改。
*/
@Getter
public enum ThirdMchFzRelationStatusEnum {
/** 已建立本地关系事实,等待 Quartz 下发绑定任务。 */
PENDING_BIND("PENDING_BIND"),
/** Worker 已开始调用渠道,结果不确定时允许使用相同参数重试。 */
BINDING("BINDING"),
/** 渠道已明确确认分账关系存在,可以创建需要平台抽佣的支付订单。 */
BOUND("BOUND"),
/** 渠道返回确定性失败,等待修正配置或人工重试。 */
BIND_FAILED("BIND_FAILED"),
/** 支付配置已变化,该关系不再用于新订单,但为历史订单保留渠道关系和快照。 */
SUPERSEDED("SUPERSEDED");
/** 数据库和内部接口使用的稳定状态编码。 */
private final String code;
/**
* 创建关系状态。
*
* @param code 持久化状态编码
*/
ThirdMchFzRelationStatusEnum(String code) {
this.code = code;
}
}
@@ -33,7 +33,7 @@ public enum ThirdPaymentTaskTypeEnum {
AGGREGATE_SESSION("AGGREGATE_SESSION"),
/** 历史订单码查单并在渠道确认后关闭。 */
PAY_CLOSE("PAY_CLOSE"),
/** 历史聚合支付会话本地关闭。 */
/** 历史聚合会话收口;无渠道单时本地关闭,有渠道单时物化唯一会话补偿任务。 */
AGGREGATE_CLOSE("AGGREGATE_CLOSE"),
/** 对账任务预留类型。 */
RECONCILE("RECONCILE");
@@ -21,6 +21,6 @@ public class ThirdPaymentTaskDispatchDto {
private Integer batchSize = 100;
/** 触发来源,固定任务由 Quartz 传入。 */
private String triggerSource = "QUARTZ";
/** Quartz 触发时间。 */
/** Quartz 原始触发时间,仅用于调用链诊断;thirdparty 必须使用自身接收时间计算数据库租约。 */
private LocalDateTime triggerTime;
}
@@ -0,0 +1,87 @@
package com.bsd.pjcloud.thirdparty.entity.pay;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 商户第三方支付分账关系事实,一条记录描述一个 {@code mch_manage} 在一个渠道下的平台分佣接收方。
*/
@Data
@TenantTable
@TableName("third_mch_fz_relation")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "商户第三方支付分账关系")
public class ThirdMchFzRelationEntity extends Model<ThirdMchFzRelationEntity> {
/** 关系主键,由应用侧雪花算法生成。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 关系所属租户ID,来源于 mch_manage,作为数据隔离和任务幂等边界。 */
private Long tenantId;
/** 对应的 mch_manage 商户主键,不跨租户解释。 */
private Long mchId;
/** 产生该关系的 mch_pay_setting 主键,用于配置变更时识别历史关系。 */
private Long paySettingId;
/** 支付平台持久化编码,10表示微信、20表示支付宝。 */
private String platformType;
/** 本地关系场景编码,本期固定为 DEFAULT_PLATFORM_COMMISSION。 */
private String relationScene;
/** 微信服务商 AppID 或支付宝平台 AppID 快照,用于区分不同平台账号。 */
private String channelAppId;
/** 微信 sub_mchid 或支付宝 smid/ip_role_id,用于绑定关系与商户支付配置对应。 */
private String channelSubMchId;
/** 支付宝命名平台账号键;默认支付宝账号及微信关系为空。 */
private String accountKey;
/** 渠道接收方类型,微信为 MERCHANT_ID,支付宝为 userId 或 loginName。 */
private String receiverType;
/** 平台分佣接收账号,发起渠道请求时使用;管理端展示必须脱敏。 */
private String receiverAccount;
/** 平台分佣接收方名称快照,渠道不要求时允许为空。 */
private String receiverName;
/** 渠道关系类型,微信默认 SERVICE_PROVIDER,支付宝关系允许为空。 */
private String channelRelationType;
/** 渠道绑定稳定幂等号;支付宝重试必须保持不变,微信用于本地审计。 */
private String outRequestNo;
/** 当前关系状态,由关系 Worker 使用原状态条件推进。 */
private String status;
/** 是否为支付配置当前默认关系,0为历史关系、1为新订单使用的默认关系。 */
private String isDefault;
/** 脱敏后的绑定请求快照,不得包含 app_auth_token、私钥或公钥。 */
private String bindReq;
/** 最近一次渠道绑定或关系查询响应快照。 */
private String bindRes;
/** 最近一次绑定错误摘要,不得包含渠道凭据。 */
private String bindError;
/** 最近一次渠道结果码或外部状态,用于监控聚合。 */
private String lastExternalStatus;
/** 最近一次调用渠道绑定或查询接口的时间。 */
private LocalDateTime lastQueryTime;
/** 渠道明确确认关系已绑定的时间。 */
private LocalDateTime boundTime;
/** 审计创建人,由 MyBatis 填充器写入。 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 本地关系事实创建时间。 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 最近修改人,由 MyBatis 填充器维护。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/** 最近一次关系状态、默认标记或渠道快照更新时间。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/** 逻辑删除标记,0为正常、1为删除;历史关系默认保留而不物理删除。 */
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
}
@@ -18,33 +18,33 @@ import java.time.LocalDateTime;
@TableName("third_payment_task_attempt")
@EqualsAndHashCode(callSuper = true)
public class ThirdPaymentTaskAttemptEntity extends Model<ThirdPaymentTaskAttemptEntity> {
/** 尝试主键。 */
/** 单次执行尝试主键,由应用侧雪花算法生成。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 任务主键。 */
/** 所属支付任务主键,来源于 {@code third_payment_task.id}。 */
private Long taskId;
/** 租户ID。 */
/** 尝试记录所属租户ID,复制自任务事实并参与租户数据隔离。 */
private Long tenantId;
/** 任务内序号。 */
/** 任务内尝试序号,来源于任务成功领取后的累计次数,从一开始递增。 */
private Integer attemptNo;
/** 触发来源。 */
/** 本次执行触发来源,例如 QUARTZ、CALLBACK、SYNC_RESPONSE 或 MANUAL。 */
private String triggerSource;
/** Worker实例标识。 */
/** 实际执行本次尝试的 thirdparty Worker 实例标识,用于多实例故障定位。 */
private String workerId;
/** 执行租约令牌。 */
/** 本次执行对应的租约令牌,用于关联任务 CAS 所有权;任务重领后旧令牌不再有效。 */
private String leaseToken;
/** 脱敏请求快照。 */
/** 本次执行的脱敏请求 JSON;允许为空,禁止保存私钥、公钥、完整授权令牌或付款码。 */
private String requestSnapshot;
/** 脱敏响应快照。 */
/** 本次执行的脱敏响应 JSON;允许为空,支付凭据及个人敏感字段必须遮蔽。 */
private String responseSnapshot;
/** 外部状态。 */
/** 本次执行观察到的渠道或本地编排状态;调用前失败时允许为空。 */
private String externalStatus;
/** 错误码。 */
/** 本次执行的渠道或本地错误码;成功执行时允许为空。 */
private String errorCode;
/** 错误摘要。 */
/** 本次执行的脱敏错误摘要;成功执行时允许为空,禁止记录敏感配置原文。 */
private String errorMessage;
/** 开始时间。 */
/** Worker 开始处理本次尝试的时间,用于计算执行耗时和定位超时租约。 */
private LocalDateTime startedTime;
/** 结束时间。 */
/** 本次尝试写入成功、失败或重试结果的时间;Worker 中断且尚未回写时允许为空。 */
private LocalDateTime finishedTime;
}
@@ -18,64 +18,64 @@ import java.time.LocalDateTime;
@EqualsAndHashCode(callSuper = true)
@Schema(description = "第三方支付任务")
public class ThirdPaymentTaskEntity extends Model<ThirdPaymentTaskEntity> {
/** 任务主键。 */
/** 任务事实主键,由应用侧雪花算法生成,不依赖数据库自增。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** 租户ID。 */
/** 任务所属租户ID,来源于业务单据或当前租户上下文,也是任务领取和唯一键隔离边界。 */
private Long tenantId;
/** 任务类型持久化编码。 */
/** 任务类型持久化编码,取值由 {@code ThirdPaymentTaskTypeEnum} 维护,创建后不得改变。 */
private String taskType;
/** 租户内稳定幂等键。 */
/** 租户内稳定幂等键,由任务类型和原业务号组成,外部调用重试时不得重新生成。 */
private String taskKey;
/** 支付订单主键。 */
/** 关联支付订单主键;非支付订单类任务允许为空。 */
private Long payOrderId;
/** 分账订单主键。 */
/** 关联分账订单主键;非分账及退分账任务允许为空。 */
private Long fzOrderId;
/** 聚合会话主键。 */
/** 关联聚合支付会话主键;非聚合支付任务允许为空。 */
private Long aggregateSessionId;
/** 稳定支付单号。 */
/** 平台内稳定支付单号,来源于支付订单;未关联支付订单的任务允许为空。 */
private String payNo;
/** 稳定退款单号。 */
/** 平台内稳定退款单号,来源于退款业务;非退款任务允许为空。 */
private String refundNo;
/** 稳定分账单号。 */
/** 平台内稳定分账单号,来源于分账订单;非分账任务允许为空。 */
private String fzNo;
/** 稳定聚合会话号。 */
/** 聚合支付会话稳定业务号,来源于聚合会话;非聚合支付任务允许为空。 */
private String aggregateNo;
/** 当前任务状态。 */
/** 当前任务状态持久化编码,由任务状态机通过 CAS 更新,禁止外部直接跳转终态。 */
private String status;
/** 下一次可执行时间。 */
/** 下一次允许领取的数据库时间;READY/RETRY 到期后才可由 Quartz 分发触发领取。 */
private LocalDateTime nextRunTime;
/** 优先级,越大越优先。 */
/** 同批到期任务的领取优先级,数值越大越先领取。 */
private Integer priority;
/** Worker实例标识。 */
/** 当前租约持有的 thirdparty Worker 实例标识;未领取或任务终态时为空。 */
private String leaseOwner;
/** 当前租约令牌。 */
/** 当前执行租约的随机令牌,完成更新必须匹配该值,未领取或任务终态时为空。 */
private String leaseToken;
/** 租约到期时间。 */
/** 当前执行租约到期时间;只约束 Worker 所有权,不代表外部渠道业务超时。 */
private LocalDateTime leaseExpireTime;
/** 累计尝试次数。 */
/** 任务累计领取尝试次数,每次 CAS 领取成功后递增,初始值为零。 */
private Integer attemptCount;
/** 最近外部渠道状态。 */
/** 最近一次执行观察到的支付宝、微信或本地编排状态;尚未执行时允许为空。 */
private String lastExternalStatus;
/** 最近错误码。 */
/** 最近一次失败的渠道或本地错误码;最近执行成功或尚无错误时允许为空。 */
private String lastErrorCode;
/** 最近错误摘要。 */
/** 最近一次失败的脱敏错误摘要,不得包含密钥、完整授权令牌或付款码。 */
private String lastErrorMessage;
/** 任务终态时间。 */
/** 进入 SUCCESS、FAILED、ALERT 或 CANCELLED 等终态的时间;处理中任务为空。 */
private LocalDateTime finishedTime;
/** 创建人。 */
/** 审计创建人,由 MyBatis 填充器写入;系统任务允许为空或使用系统身份。 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 创建时间。 */
/** 任务事实创建时间,由应用显式写入或 MyBatis 填充器补齐。 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 修改人。 */
/** 最近修改人,由 MyBatis 填充器维护;系统任务允许为空或使用系统身份。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/** 修改时间。 */
/** 最近一次任务状态、租约或诊断信息变更时间。 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/** 逻辑删除标记。 */
/** 逻辑删除标记,沿用项目通用 {@code 0/1} 约定;任务状态机不得物理删除任务事实。 */
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
@@ -13,7 +13,7 @@ public class ThirdPaymentTaskDispatchVo {
private int acceptedCount;
/** 成功取得租约的任务数。 */
private int claimedCount;
/** 当前 Worker 正在执行数量。 */
/** 当前 thirdparty 实例正在执行及已进入有界队列的任务数量。 */
private int processingCount;
/** 队列是否达到容量上限。 */
private boolean busy;
@@ -2,15 +2,11 @@ package com.bsd.pjcloud.thirdparty.job;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -48,21 +44,15 @@ public class ThirdAggregateOrderPayJob {
private final ThirdAggregateOrderPayService aggregateOrderPayService;
/**
* 第三方支付子订单服务,用于在一小时硬截止后仅做本地关闭标记。
*/
private final ThirdPayOrderService thirdPayOrderService;
/**
* 服务端轮询协调器,用于在无活动会话时退避数据库扫描,并在新会话创建后恢复短间隔。
* 旧兼容入口的进程内门控器;不创建线程,也不是支付周期调度来源。
*/
private final ThirdPayPollingCoordinator pollingCoordinator;
/**
* 分批处理等待扫码、平台创建中和支付中的聚合会话。
*
* <p>调度线程初始没有租户上下文,单条处理前使用记录自身 tenantId 恢复上下文;
* Redis 键仍由公共基础设施自动附加租户前缀。方法每五秒轻量触发一次,但连续空结果时协调器会把
* 真正的数据库扫描逐级退避到最长六十秒;创建超过一小时的记录不再进入高频处理。</p>
* <p>该方法没有 {@code @Scheduled},仅保留为显式诊断兼容入口。单条处理前使用记录自身 tenantId
* 恢复租户上下文;生产环境由 Quartz 分发任务表记录,不调用本方法形成 thirdparty 兜底调度。</p>
*/
public void handlePendingSessions() {
if (!pollingCoordinator.tryStartAggregatePendingPolling()) {
@@ -102,11 +92,11 @@ public class ThirdAggregateOrderPayJob {
}
/**
* 补偿处理已到期但子订单尚未确认关闭的聚合会话,并关闭超过一小时的历史记录。
* 补偿处理已到期但子订单尚未确认关闭的聚合会话,并继续核对超过一小时的历史记录。
*
* <p>补偿窗口从会话原始失效时间开始计算,最多持续十分钟;因此客户端退出、断网或不再查询状态时,
* 服务端仍会独立完成有限次查单和关单。连续空结果时数据库扫描从三十秒逐级退避到最长五分钟,
* 超过一小时后只把会话及未支付子订单标记为关闭,不再请求支付宝或微信。</p>
* 服务端仍会独立完成查单和关单。该兼容方法没有 {@code @Scheduled},周期分发完全由 Quartz 和
* {@code third_payment_task} 承担;存在渠道子订单的历史记录也不能本地盲关。</p>
*/
public void handleExpiredSessionCompensation() {
if (!pollingCoordinator.tryStartAggregateCompensationPolling()) {
@@ -157,10 +147,10 @@ public class ThirdAggregateOrderPayJob {
}
/**
* 将超过一小时的过期聚合会话及其未支付子订单标记为交易关闭。
* 处理超过一小时仍未收敛的聚合会话。
*
* <p>该分支不调用任何支付平台接口,也不再根据子订单恢复会话。会话先进入关闭终态,随后仅把仍为
* 未支付的子订单标记为关闭;已经落库的真实支付结果保留给人工对账,但不会重新激活聚合会话。</p>
* <p>方法只保留为旧诊断入口,不承担周期调度。每条记录仍交给聚合支付编排服务,后者对有渠道子单的
* 会话执行查单或关单,对未知结果抛出异常并保留待处理状态。</p>
*
* @param sessions 超过一小时且仍处于活动或过期状态的聚合会话
*/
@@ -175,16 +165,7 @@ public class ThirdAggregateOrderPayJob {
}
try {
TenantBroker.runAs(session.getTenantId(), tenantId -> {
aggregatePaySessionService.markTerminal(
session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
ThirdPayOrderEntity payOrder = session.getPayNo() == null
? null : thirdPayOrderService.getByPayNo(session.getPayNo());
if (payOrder != null) {
thirdPayOrderService.update(new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, payOrder.getId())
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())
.set(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_CLOSED.getType()));
}
aggregateOrderPayService.processPendingSession(session.getId());
});
} catch (Exception exception) {
log.warn("关闭历史聚合支付会话失败,sessionId:{},tenantId:{},exceptionType:{}",
@@ -53,16 +53,6 @@ public class ThirdAppPlusPayJob {
*/
private static final long ORDER_CODE_CLOSE_COMPENSATION_MINUTES = 10L;
/**
* 超过历史恢复时间的原生订单码本地关闭标记周期,单位毫秒。
*/
private static final long ORDER_CODE_HISTORY_CLOSE_INTERVAL_MILLIS = 300000L;
/**
* 服务启动后首次标记历史订单码的等待时间,单位毫秒。
*/
private static final long ORDER_CODE_HISTORY_CLOSE_INITIAL_DELAY_MILLIS = 60000L;
/**
* 支付订单服务,用于分批读取仍未支付的订单码记录。
*/
@@ -89,7 +79,7 @@ public class ThirdAppPlusPayJob {
private final ThirdAppPlusAliPayService thirdAppPlusAliPayService;
/**
* 服务端轮询协调器,用于在无待处理订单时退避数据库扫描,并在创建订单后恢复短间隔。
* 旧兼容入口的进程内门控器;不创建线程,也不承担生产支付调度。
*/
private final ThirdPayPollingCoordinator pollingCoordinator;
@@ -118,9 +108,8 @@ public class ThirdAppPlusPayJob {
/**
* 分批处理支付宝和微信云消费机订单码,最长处理到订单过期后十分钟。
*
* <p>初始查询在线程无租户上下文时跨租户读取,处理单条订单前通过 TenantBroker
* 恢复订单所属租户,确保后续读取和原子更新都受正确租户条件约束。方法每五秒轻量触发一次,
* 但连续空结果时协调器会把真正的数据库扫描逐级退避到最长六十秒。</p>
* <p>该方法没有 {@code @Scheduled},仅保留为显式诊断兼容入口。处理单条订单前通过 TenantBroker
* 恢复订单所属租户;生产周期调度只由 Quartz 分发 {@code third_payment_task}。</p>
*/
public void handleOrderCodeOrders() {
if (!pollingCoordinator.tryStartOrderCodePolling()) {
@@ -454,10 +443,10 @@ public class ThirdAppPlusPayJob {
}
/**
* 将超过一小时仍未支付的原生支付宝、微信订单码标记为交易关闭。
* 核对超过一小时仍未支付的原生支付宝、微信订单码。
*
* <p>该任务只执行带未支付状态条件的数据库更新,不再向支付宝或微信发起查单、撤销或关单请求;
* 平台若在硬截止后出现延迟支付,由人工渠道核对处理。</p>
* <p>该方法没有 {@code @Scheduled},只保留为显式诊断入口。历史年龄不能替代渠道终态,
* 因此仍复用支付编排服务按订单配置快照查单或关单,未知结果保持未支付。</p>
*/
public void closeHistoricalOrderCodeOrders() {
LocalDateTime cutoff = LocalDateTime.now()
@@ -482,15 +471,11 @@ public class ThirdAppPlusPayJob {
}
try {
TenantBroker.runAs(order.getTenantId(), tenantId -> {
ThirdPayOrderEntity update = new ThirdPayOrderEntity();
update.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType());
thirdPayOrderService.update(update, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, order.getId())
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())
.and(timeWrapper -> timeWrapper
.isNull(ThirdPayOrderEntity::getCreateTime)
.or()
.lt(ThirdPayOrderEntity::getCreateTime, cutoff)));
ThirdPayOrderEntity latest = thirdPayOrderService.getById(order.getId());
if (latest != null && ObjectUtil.equals(
latest.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
thirdAppPlusService.queryPayResult(latest);
}
});
} catch (Exception exception) {
log.warn("关闭历史订单码失败,orderId:{},tenantId:{},platform:{},exceptionType:{}",
@@ -0,0 +1,12 @@
package com.bsd.pjcloud.thirdparty.mapper.pay;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 商户分账关系数据访问,关系领取与结果推进使用 Service 层的状态条件更新。
*/
@Mapper
public interface ThirdMchFzRelationMapper extends PjcloudBaseMapper<ThirdMchFzRelationEntity> {
}
@@ -68,6 +68,20 @@ public interface ThirdPaymentTaskMapper extends PjcloudBaseMapper<ThirdPaymentTa
int claim(@Param("id") Long id, @Param("now") LocalDateTime now, @Param("owner") String owner,
@Param("token") String token, @Param("expireTime") LocalDateTime expireTime);
/**
* 任务真正离开本地有界队列时验证租约所有权并延长执行窗口,过期后已被其他实例恢复的旧队列项不会命中。
*
* @param id 任务主键
* @param token 当前队列项持有的租约令牌
* @param owner 当前 thirdparty Worker 实例标识
* @param expireTime 从实际开始执行时重新计算的租约到期时间
* @param now 实际开始执行时间
* @return 当前租约仍有效且续租成功时为1,否则为0
*/
@InterceptorIgnore(tenantLine = "true")
int renewLease(@Param("id") Long id, @Param("token") String token, @Param("owner") String owner,
@Param("expireTime") LocalDateTime expireTime, @Param("now") LocalDateTime now);
/**
* 提交队列失败时按租约释放任务。
* @param id 任务主键
@@ -0,0 +1,34 @@
package com.bsd.pjcloud.thirdparty.service;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
/**
* 商户支付分账关系服务,负责把 {@code mch_manage} 的支付配置映射为渠道关系事实。
*/
public interface ThirdMchFzRelationService {
/**
* 根据商户当前启用支付配置物化默认平台分佣关系;已有相同身份关系只做幂等复用。
*
* @param merchant 商户及其启用支付配置,来源于 wallet 服务的内部快照
*/
void syncDefaultRelations(MchManageVo merchant);
/**
* 获取支付创建时必须使用的已绑定关系;有抽佣配置但关系未绑定时直接拒绝支付。
*
* @param setting 商户支付配置
* @return 已绑定关系;未启用平台抽佣时返回 {@code null}
*/
ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting);
/**
* 执行一条关系绑定任务;重复执行必须使用关系事实中保存的稳定请求号和参数。
*
* @param relationId 关系主键
* @return 渠道明确确认绑定时返回 {@code true},未知或失败返回 {@code false}
*/
boolean bind(Long relationId);
}
@@ -5,10 +5,10 @@ import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 第三方订单码服务端轮询协调器。
* 旧批处理入口的进程内门控器。
*
* <p>支付平台回调仍是结果确认主路径;本协调器只控制数据库补偿扫描频率。有待处理记录时维持短间隔,
* 连续空扫描时指数退避,订单创建成功后由当前服务实例主动唤醒,从而避免全天执行无效 SQL。</p>
* <p>本类不创建线程、不注册定时器,也不会主动扫描数据库。它只供保留的兼容诊断方法控制同进程重入;
* 生产支付任务的唯一周期来源是 Quartz,真实恢复状态由 {@code third_payment_task} 保存。</p>
*/
@Component
public class ThirdPayPollingCoordinator {
@@ -24,9 +24,9 @@ public class ThirdPayPollingCoordinator {
public static final long COMPENSATION_TRIGGER_INTERVAL_MILLIS = 30000L;
/**
* 订单码允许服务端自动查单、关单或恢复会话状态的最长时间,单位分钟。
* 旧批处理区分活动记录和历史记录的时间窗口,单位分钟。
*
* <p>超过该硬截止后只允许本地标记关闭,客户端查询、定时任务和创建重试均不得再次访问支付平台。</p>
* <p>该窗口只影响扫描分组,不能作为本地关闭渠道订单的依据;存在渠道单号时仍须查单或关单确认。</p>
*/
public static final long MAX_AUTO_PROCESSING_MINUTES = 60L;
@@ -53,32 +53,74 @@ public class ThirdPaymentTaskEnqueuer {
payOrderId, payNo, null, null, null);
}
/** 为已创建的分账记录排入微信分账查单任务。 */
/**
* 为已创建的微信分账记录排入稳定查单任务。
*
* @param tenantId 分账订单所属租户ID;为空时读取当前租户上下文
* @param payOrderId 原支付订单主键
* @param fzOrderId 分账订单主键
* @param payNo 原支付订单稳定支付单号
* @param fzNo 微信分账稳定业务单号
*/
public void enqueueFzQuery(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo) {
enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_QUERY, "FZ_QUERY:" + fzNo,
payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 100);
}
/** 为支付宝直付通排入确认结算任务,默认至少等待支付成功三十秒。 */
/**
* 为支付宝直付通排入确认结算任务,调用方必须传入不早于支付成功约三十秒的执行时间。
*
* @param tenantId 支付订单所属租户ID;为空时读取当前租户上下文
* @param payOrderId 原支付订单主键
* @param fzOrderId 直付通分账阶段记录主键
* @param payNo 原支付订单稳定支付单号
* @param fzNo 直付通稳定分账业务号
* @param nextRunTime 最早确认结算时间;为空时立即可领取,仅供显式人工恢复使用
*/
public void enqueueFzSettleConfirm(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo,
LocalDateTime nextRunTime) {
enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_SETTLE_CONFIRM, "FZ_SETTLE_CONFIRM:" + payNo,
payOrderId, payNo, null, fzOrderId, fzNo, nextRunTime, 100);
}
/** 为确认结算成功的支付宝订单排入平台抽佣提交任务。 */
/**
* 为确认结算成功的支付宝订单排入平台抽佣提交任务。
*
* @param tenantId 支付订单所属租户ID;为空时读取当前租户上下文
* @param payOrderId 原支付订单主键
* @param fzOrderId 直付通分账阶段记录主键
* @param payNo 原支付订单稳定支付单号
* @param fzNo 直付通稳定分账业务号
*/
public void enqueueFzRoyalty(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo) {
enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_ROYALTY, "FZ_ROYALTY:" + payNo,
payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 100);
}
/** 为支付宝平台抽佣受理结果排入稳定查询任务。 */
/**
* 为支付宝平台抽佣受理结果排入稳定查询任务。
*
* @param tenantId 支付订单所属租户ID;为空时读取当前租户上下文
* @param payOrderId 原支付订单主键
* @param fzOrderId 直付通分账阶段记录主键
* @param payNo 原支付订单稳定支付单号
* @param fzNo 直付通稳定分账业务号
*/
public void enqueueFzRoyaltyQuery(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo) {
enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_ROYALTY_QUERY, "FZ_ROYALTY_QUERY:" + payNo,
payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 100);
}
/** 为已发起的分账退款排入稳定查单任务。 */
/**
* 为已发起的微信或支付宝分账退款排入稳定查单任务。
*
* @param tenantId 分账退款所属租户ID;为空时读取当前租户上下文
* @param payOrderId 原支付订单主键
* @param fzOrderId 原分账订单主键
* @param payNo 原支付订单稳定支付单号
* @param fzNo 原分账稳定业务单号
* @param fzRefundNo 分账退款稳定业务号,用于区分同一分账单的退款尝试
*/
public void enqueueFzRefundQuery(Long tenantId, Long payOrderId, Long fzOrderId, String payNo, String fzNo,
String fzRefundNo) {
enqueueAt(tenantId, ThirdPaymentTaskTypeEnum.FZ_REFUND_QUERY,
@@ -86,6 +128,39 @@ public class ThirdPaymentTaskEnqueuer {
payOrderId, payNo, null, fzOrderId, fzNo, LocalDateTime.now(), 110);
}
/**
* 为历史聚合会话补建唯一的状态收敛任务,避免 {@code AGGREGATE_CLOSE} 与活动会话任务并发查同一渠道单。
*
* @param tenantId 会话所属租户ID,不允许为空
* @param aggregateSessionId 聚合会话主键
* @param aggregateNo 聚合会话稳定业务号
* @param payNo 已绑定的渠道支付单号
* @param nextRunTime 最早执行时间;为空时立即可执行
*/
public void enqueueAggregateSession(Long tenantId, Long aggregateSessionId, String aggregateNo, String payNo,
LocalDateTime nextRunTime) {
if (tenantId == null || aggregateSessionId == null || aggregateNo == null || aggregateNo.isBlank()) {
throw new IllegalArgumentException("聚合支付任务租户、会话主键和会话号不能为空");
}
LocalDateTime now = LocalDateTime.now();
ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity();
task.setId(IdWorker.getId());
task.setTenantId(tenantId);
task.setTaskType(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION.getCode());
task.setTaskKey("AGGREGATE_SESSION:" + aggregateNo);
task.setAggregateSessionId(aggregateSessionId);
task.setAggregateNo(aggregateNo);
task.setPayNo(payNo);
task.setStatus("READY");
task.setNextRunTime(nextRunTime == null ? now : nextRunTime);
task.setPriority(105);
task.setAttemptCount(0);
task.setCreateTime(now);
task.setUpdateTime(now);
task.setDelFlag("0");
taskMapper.insertIgnore(task);
}
/**
* 写入一个稳定的任务事实。
* @param tenantId 租户ID
@@ -103,7 +178,20 @@ public class ThirdPaymentTaskEnqueuer {
LocalDateTime.now(), 100);
}
/** 写入指定执行时间和优先级的任务,外部业务号和任务键保持不变。 */
/**
* 写入指定执行时间和优先级的任务,外部业务号和任务键保持不变。
*
* @param tenantId 租户ID;为空时读取当前租户上下文
* @param type 任务类型,持久化编码由枚举提供
* @param taskKey 租户内稳定幂等键
* @param payOrderId 支付订单主键,非支付订单任务允许为空
* @param payNo 稳定支付单号,未关联支付订单时允许为空
* @param refundNo 稳定退款单号,非退款任务允许为空
* @param fzOrderId 分账订单主键,非分账任务允许为空
* @param fzNo 稳定分账单号,非分账任务允许为空
* @param nextRunTime 最早执行时间;为空时立即可执行
* @param priority 调度优先级,数值越大越先领取
*/
public void enqueueAt(Long tenantId, ThirdPaymentTaskTypeEnum type, String taskKey, Long payOrderId,
String payNo, String refundNo, Long fzOrderId, String fzNo,
LocalDateTime nextRunTime, int priority) {
@@ -156,7 +156,10 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig();
request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl());
request.setBizModel(model);
request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken());
// app_auth_token 仅在商户配置了代调用授权时发送;为空时必须让支付宝按平台默认授权处理。
if (StrUtil.isNotBlank(aliPaySetting.getAppAuthToken())) {
request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken());
}
payOrder.setPayRep(buildRequestSnapshot(context).toString());
AlipayTradeCreateResponse response;
@@ -296,19 +299,16 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
}
/**
* 校验商户支付宝授权配置并解析 app_auth_token。
* 校验商户支付宝配置并解析可选 app_auth_token。
*
* @param paySetting 当前商户启用的支付宝支付配置
* @return 包含非空商户授权令牌的配置
* @return 支付宝配置;授权令牌为空时仍允许直付通请求继续,由支付宝返回明确权限结果
*/
private MchPaySettingEntity.AliPaySetting parseAliPaySetting(MchPaySettingVo paySetting) {
if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) {
throw new RuntimeException("商户支付宝支付配置不完整");
}
MchPaySettingEntity.AliPaySetting setting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (setting == null || StrUtil.isBlank(setting.getAppAuthToken())) {
throw new RuntimeException("商户支付宝授权Token未配置");
}
return setting;
}
@@ -449,10 +449,11 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
if (ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
return;
}
if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) {
processAlipayOrder(session, payOrder);
} else if (ObjectUtil.equals(session.getSelectedPlatform(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
processWechatOrder(session, payOrder);
String platformType = StrUtil.blankToDefault(session.getSelectedPlatform(), payOrder.getPayPlatformType());
if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.ALI_PAY.getType())) {
processAlipayOrder(session, payOrder);
} else if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.WECHAT.getType())) {
processWechatOrder(session, payOrder);
}
}
@@ -971,21 +972,6 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
return session == null || session.getCreateTime() == null || !session.getCreateTime().isAfter(cutoff);
}
/**
* 关闭没有渠道子订单的历史聚合会话。
*
* <p>方法故意拒绝处理带 {@code payNo} 的会话。渠道单号一旦存在,外部支付事实就可能已经产生,
* 必须由查单、关单或已验签回调决定终态,不能通过本地年龄绕过渠道。</p>
*
* @param session 未绑定渠道子订单的历史会话
*/
private void closeHistoricalSessionLocally(ThirdAggregatePaySessionEntity session) {
if (session == null || StrUtil.isNotBlank(session.getPayNo())) {
return;
}
aggregatePaySessionService.markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
}
/**
* 阻止同业务存在未归属于聚合会话的旧原生订单码或既有成功订单。
*
@@ -777,10 +777,11 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
if (thirdFzOrderService == null || payOrder == null
|| ObjectUtil.notEqual(payOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())
|| ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())
|| ObjectUtil.notEqual(payOrder.getEnableFz(), com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode())
|| payOrder.getPayTime() == null) {
|| ObjectUtil.notEqual(payOrder.getEnableFz(), com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode())) {
return null;
}
// 历史成功订单可能因旧实现没有写 pay_time;这里只物化本地分账事实,不访问支付宝。
// 最早确认结算时间由任务 Worker 以“发现时间 + 30 秒”兜底,不能在这里永久阻断恢复。
ThirdFzOrderEntity existing = thirdFzOrderService.getByPayNo(payOrder.getPayNo());
if (existing != null) {
return existing;
@@ -1042,6 +1043,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
*/
private boolean saveDirectPayStage(ThirdFzOrderEntity fzOrder, JSONObject snapshot, JSONObject stage,
ThirdFzOrderStatusEnum terminalStatus) {
String expectedCreateRes = fzOrder.getCreateRes();
ThirdFzOrderEntity update = new ThirdFzOrderEntity();
update.setCreateRes(snapshot.toString());
JSONObject request = stage == null ? null : stage.getJSONObject("request");
@@ -1054,9 +1056,16 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
if (terminalStatus != null) {
update.setStatus(terminalStatus.getType());
}
boolean changed = thirdFzOrderService.update(update, new LambdaUpdateWrapper<ThirdFzOrderEntity>()
LambdaUpdateWrapper<ThirdFzOrderEntity> condition = new LambdaUpdateWrapper<ThirdFzOrderEntity>()
.eq(ThirdFzOrderEntity::getId, fzOrder.getId())
.eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType()));
.eq(ThirdFzOrderEntity::getStatus, ThirdFzOrderStatusEnum.FZ_UNPAID.getType());
// 阶段快照本身也是并发版本号:旧 Worker 不能用缺少后续阶段的 JSON 覆盖新 Worker 的结果。
if (expectedCreateRes == null) {
condition.isNull(ThirdFzOrderEntity::getCreateRes);
} else {
condition.eq(ThirdFzOrderEntity::getCreateRes, expectedCreateRes);
}
boolean changed = thirdFzOrderService.update(update, condition);
if (!changed) {
return false;
}
@@ -155,8 +155,8 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
/**
* 将会话推进为成功、关闭或过期状态。
*
* <p>过期只允许从活动状态写入;支付成功或平台关闭还允许覆盖过期状态,用于接收关单补偿期间
* 查询到的真实平台终态,但不会覆盖已经确认的成功或关闭结果。</p>
* <p>过期只允许从活动状态写入;平台关闭允许覆盖过期状态。支付成功还允许覆盖本地已关闭状态,
* 仅用于恢复已经验签并完成关键字段校验的延迟成功回调,确保真实扣款事实优先于本地超时判断。</p>
*
* @param sessionId 聚合会话主键
* @param status 目标终态
@@ -173,16 +173,23 @@ public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggreg
ThirdAggregatePaySessionEntity update = new ThirdAggregatePaySessionEntity();
update.setStatus(status.getType());
update.setResolvedTime(LocalDateTime.now());
List<String> sourceStatuses = ObjectUtil.equals(status, ThirdAggregatePaySessionStatusEnum.EXPIRED)
? List.of(
ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
ThirdAggregatePaySessionStatusEnum.PAYING.getType())
: List.of(
ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
ThirdAggregatePaySessionStatusEnum.PAYING.getType(),
ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
List<String> sourceStatuses;
if (ObjectUtil.equals(status, ThirdAggregatePaySessionStatusEnum.EXPIRED)) {
sourceStatuses = List.of(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
ThirdAggregatePaySessionStatusEnum.PAYING.getType());
} else if (ObjectUtil.equals(status, ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS)) {
sourceStatuses = List.of(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
ThirdAggregatePaySessionStatusEnum.PAYING.getType(),
ThirdAggregatePaySessionStatusEnum.EXPIRED.getType(),
ThirdAggregatePaySessionStatusEnum.PAY_CLOSED.getType());
} else {
sourceStatuses = List.of(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
ThirdAggregatePaySessionStatusEnum.PAYING.getType(),
ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
}
return update(update, new LambdaUpdateWrapper<ThirdAggregatePaySessionEntity>()
.eq(ThirdAggregatePaySessionEntity::getId, sessionId)
.in(ThirdAggregatePaySessionEntity::getStatus, sourceStatuses));
@@ -0,0 +1,456 @@
package com.bsd.pjcloud.thirdparty.service.impl.pay;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.alipay.api.AlipayClient;
import com.alipay.api.AlipayConfig;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.domain.AlipayTradeRoyaltyRelationBatchqueryModel;
import com.alipay.api.domain.AlipayTradeRoyaltyRelationBindModel;
import com.alipay.api.domain.RoyaltyEntity;
import com.alipay.api.request.AlipayTradeRoyaltyRelationBatchqueryRequest;
import com.alipay.api.request.AlipayTradeRoyaltyRelationBindRequest;
import com.alipay.api.response.AlipayTradeRoyaltyRelationBatchqueryResponse;
import com.alipay.api.response.AlipayTradeRoyaltyRelationBindResponse;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationSceneEnum;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdMchFzRelationMapper;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.github.yulichang.toolkit.MPJReflectionKit;
import com.wechat.pay.java.service.profitsharing.ProfitsharingService;
import com.wechat.pay.java.service.profitsharing.model.AddReceiverRequest;
import com.wechat.pay.java.service.profitsharing.model.AddReceiverResponse;
import com.wechat.pay.java.service.profitsharing.model.ReceiverRelationType;
import com.wechat.pay.java.service.profitsharing.model.ReceiverType;
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.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* 商户微信/支付宝默认平台分佣关系实现。
*
* <p>关系绑定是支付前置条件,但外部渠道调用由 Quartz 的
* {@code FZ_RELATION_BIND} 任务执行;本服务只负责关系事实物化、渠道请求和结果快照。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService {
/** 微信支付平台持久化编码。 */
private static final String WECHAT_TYPE = AppPlusPlatformPayType.WECHAT.getType();
/** 支付宝支付平台持久化编码。 */
private static final String ALIPAY_TYPE = AppPlusPlatformPayType.ALI_PAY.getType();
/** 关系绑定的固定业务场景。 */
private static final String DEFAULT_SCENE = ThirdMchFzRelationSceneEnum.DEFAULT_PLATFORM_COMMISSION.getCode();
/** 本地关系绑定任务的优先级,需早于支付创建后的分账任务。 */
private static final int RELATION_TASK_PRIORITY = 150;
/** 关系事实 Mapper。 */
private final ThirdMchFzRelationMapper relationMapper;
/** 关系绑定任务写入器。 */
private final ThirdPaymentTaskEnqueuer taskEnqueuer;
/** 微信服务商分账 SDK。 */
private final ProfitsharingService profitsharingService;
/** 应用级支付宝、微信平台配置。 */
private final AppPlusPayConfig appPlusPayConfig;
/**
* 物化当前商户启用且配置抽佣比例的默认关系,并把旧配置标记为历史关系。
*
* @param merchant 商户内部快照
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void syncDefaultRelations(MchManageVo merchant) {
if (merchant == null || merchant.getId() == null) {
return;
}
Long tenantId = merchant.getTenantId() == null ? TenantContextHolder.getTenantId() : merchant.getTenantId();
if (tenantId == null) {
throw new IllegalStateException("商户分账关系租户不能为空");
}
List<MchPaySettingVo> settings = merchant.getPaySettingList() == null
? List.of() : merchant.getPaySettingList();
List<Long> currentSettingIds = new ArrayList<>();
for (MchPaySettingVo setting : settings) {
if (!isEligible(setting)) {
continue;
}
currentSettingIds.add(setting.getId());
ThirdMchFzRelationEntity relation = buildRelation(tenantId, merchant, setting);
ThirdMchFzRelationEntity existing = relationMapper.selectOne(new LambdaQueryWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
.eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId())
.eq(ThirdMchFzRelationEntity::getPlatformType, relation.getPlatformType())
.eq(ThirdMchFzRelationEntity::getChannelAppId, relation.getChannelAppId())
.eq(ThirdMchFzRelationEntity::getChannelSubMchId, relation.getChannelSubMchId())
.eq(ThirdMchFzRelationEntity::getReceiverType, relation.getReceiverType())
.eq(ThirdMchFzRelationEntity::getReceiverAccount, relation.getReceiverAccount())
.eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE)
.eq(ThirdMchFzRelationEntity::getDelFlag, "0"));
if (existing == null) {
relationMapper.insert(relation);
existing = relation;
}
relationMapper.update(null, new LambdaUpdateWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
.eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId())
.eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode())
.ne(ThirdMchFzRelationEntity::getId, existing.getId())
.eq(ThirdMchFzRelationEntity::getDelFlag, "0")
.set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode())
.set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()));
if (!ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(existing.getStatus())
&& !ThirdMchFzRelationStatusEnum.BINDING.getCode().equals(existing.getStatus())) {
taskEnqueuer.enqueueAt(tenantId, com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum.FZ_RELATION_BIND,
"FZ_RELATION_BIND:" + existing.getId(), null, null, null, null, null,
LocalDateTime.now(), RELATION_TASK_PRIORITY);
}
}
// 配置被禁用、删除或改成其他平台账号时,旧关系只停止接新单,不删除渠道事实。
LambdaUpdateWrapper<ThirdMchFzRelationEntity> stale = new LambdaUpdateWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
.eq(ThirdMchFzRelationEntity::getMchId, merchant.getId())
.eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode())
.eq(ThirdMchFzRelationEntity::getDelFlag, "0");
if (!currentSettingIds.isEmpty()) {
stale.notIn(ThirdMchFzRelationEntity::getPaySettingId, currentSettingIds);
}
stale.set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode())
.set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode());
relationMapper.update(null, stale);
}
/**
* 校验支付配置当前默认关系已明确绑定。
*
* @param setting 商户支付配置
* @return 已绑定关系;无抽佣比例时为空
*/
@Override
public ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting) {
if (!isEligible(setting)) {
return null;
}
Long tenantId = setting.getTenantId() == null ? TenantContextHolder.getTenantId() : setting.getTenantId();
ThirdMchFzRelationEntity expected = buildRelation(tenantId, setting.getMchId(), setting);
ThirdMchFzRelationEntity relation = relationMapper.selectOne(new LambdaQueryWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
.eq(ThirdMchFzRelationEntity::getMchId, setting.getMchId())
.eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId())
.eq(ThirdMchFzRelationEntity::getPlatformType, expected.getPlatformType())
.eq(ThirdMchFzRelationEntity::getChannelAppId, expected.getChannelAppId())
.eq(ThirdMchFzRelationEntity::getChannelSubMchId, expected.getChannelSubMchId())
.eq(ThirdMchFzRelationEntity::getReceiverType, expected.getReceiverType())
.eq(ThirdMchFzRelationEntity::getReceiverAccount, expected.getReceiverAccount())
.eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE)
.eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode())
.eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BOUND.getCode())
.eq(ThirdMchFzRelationEntity::getDelFlag, "0"));
if (relation == null) {
throw new IllegalStateException("支付平台分佣关系尚未绑定,请稍后重试");
}
return relation;
}
/**
* 执行关系绑定并保存脱敏快照;异常或渠道未明确成功时返回失败,交由任务状态机重试。
*
* @param relationId 关系主键
* @return 关系已绑定
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean bind(Long relationId) {
ThirdMchFzRelationEntity relation = relationMapper.selectById(relationId);
if (relation == null || !"0".equals(relation.getDelFlag())) {
return false;
}
if (ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) {
return true;
}
relationMapper.update(null, new LambdaUpdateWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getId, relationId)
.in(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode(),
ThirdMchFzRelationStatusEnum.BIND_FAILED.getCode())
.set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING)
.set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now()));
relation = relationMapper.selectById(relationId);
if (relation == null || !ThirdMchFzRelationStatusEnum.BINDING.getCode().equals(relation.getStatus())) {
return ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation == null ? null : relation.getStatus());
}
try {
if (WECHAT_TYPE.equals(relation.getPlatformType())) {
return bindWechat(relation);
}
if (ALIPAY_TYPE.equals(relation.getPlatformType())) {
return bindAlipay(relation);
}
return markFailed(relation, "UNSUPPORTED_PLATFORM", "不支持的支付平台");
} catch (Exception exception) {
log.warn("绑定商户平台分佣关系异常,relationId:{},type:{},exceptionType:{}", relationId,
relation.getPlatformType(), exception.getClass().getSimpleName());
return markFailed(relation, "NETWORK_OR_SYSTEM", exception.getMessage());
}
}
/** 构建关系事实。 */
private ThirdMchFzRelationEntity buildRelation(Long tenantId, MchManageVo merchant, MchPaySettingVo setting) {
ThirdMchFzRelationEntity result = buildRelation(tenantId, setting.getMchId(), setting);
result.setReceiverName("浙江资安信信息科技有限公司");
return result;
}
/** 按支付配置解析渠道关系身份和平台默认接收方。 */
private ThirdMchFzRelationEntity buildRelation(Long tenantId, Long mchId, MchPaySettingVo setting) {
if (tenantId == null || mchId == null || setting == null || setting.getId() == null) {
throw new IllegalArgumentException("商户支付配置关系参数不完整");
}
ThirdMchFzRelationEntity result = new ThirdMchFzRelationEntity();
result.setId(IdWorker.getId());
result.setTenantId(tenantId);
result.setMchId(mchId);
result.setPaySettingId(setting.getId());
result.setPlatformType(setting.getPlatformType());
result.setRelationScene(DEFAULT_SCENE);
result.setIsDefault(YesNoEnum.YES.getCode());
result.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode());
result.setDelFlag("0");
result.setCreateTime(LocalDateTime.now());
result.setUpdateTime(result.getCreateTime());
if (WECHAT_TYPE.equals(setting.getPlatformType())) {
AppPlusPayConfig.WechatConfig config = requireWechatConfig();
MchPaySettingEntity.WechatPaySetting paySetting = parseWechat(setting);
result.setChannelAppId(config.getAppPlatformAppId());
result.setChannelSubMchId(paySetting.getSubMchid());
result.setReceiverType(ReceiverType.MERCHANT_ID.name());
result.setReceiverAccount(config.getMerchantId());
result.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name());
result.setOutRequestNo("wxrel_" + setting.getId() + "_" + paySetting.getSubMchid());
result.setBindReq(JSONUtil.createObj().set("platform", "WECHAT").set("appid", config.getAppPlatformAppId())
.set("subMchid", paySetting.getSubMchid()).set("receiverType", ReceiverType.MERCHANT_ID.name())
.set("receiverAccount", mask(paySetting(config.getMerchantId()))).toString());
} else if (ALIPAY_TYPE.equals(setting.getPlatformType())) {
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(setting);
MchPaySettingEntity.AliPaySetting paySetting = parseAliPay(setting);
String receiverType = StrUtil.blankToDefault(config.getRoyaltyTransInType(), "userId");
String receiverAccount = StrUtil.blankToDefault(config.getRoyaltyTransIn(), "");
if (StrUtil.isBlank(receiverAccount)) {
throw new IllegalStateException("平台支付宝分佣接收账号未配置");
}
if (!List.of("userId", "loginName").contains(receiverType)) {
throw new IllegalStateException("支付宝分佣接收方类型只支持userId或loginName");
}
result.setChannelAppId(config.getAppId());
result.setChannelSubMchId(paySetting.getSubMchid());
result.setAccountKey(paySetting.getAccountKey());
result.setReceiverType(receiverType);
result.setReceiverAccount(receiverAccount);
result.setReceiverName(config.getRoyaltyTransInName());
result.setOutRequestNo("alirel_" + setting.getId() + "_" + paySetting.getSubMchid());
result.setBindReq(JSONUtil.createObj().set("platform", "ALIPAY").set("appId", config.getAppId())
.set("smid", paySetting.getSubMchid()).set("receiverType", receiverType)
.set("receiverAccount", mask(receiverAccount)).set("appAuthTokenConfigured", StrUtil.isNotBlank(paySetting.getAppAuthToken())).toString());
} else {
throw new IllegalArgumentException("不支持的支付平台关系");
}
return result;
}
/** 判断支付配置是否需要平台分佣关系。 */
private boolean isEligible(MchPaySettingVo setting) {
return setting != null && setting.getId() != null && YesNoEnum.YES.getCode().equals(setting.getEnable())
&& List.of(WECHAT_TYPE, ALIPAY_TYPE).contains(setting.getPlatformType())
&& setting.getFzRatio() != null && setting.getFzRatio() > 0 && setting.getFzRatio() <= 100;
}
/** 微信关系绑定。 */
private boolean bindWechat(ThirdMchFzRelationEntity relation) {
AddReceiverRequest request = new AddReceiverRequest();
request.setAppid(relation.getChannelAppId());
request.setSubMchid(relation.getChannelSubMchId());
request.setType(ReceiverType.MERCHANT_ID);
request.setAccount(relation.getReceiverAccount());
request.setName(StrUtil.blankToDefault(relation.getReceiverName(), "浙江资安信信息科技有限公司"));
request.setRelationType(ReceiverRelationType.SERVICE_PROVIDER);
AddReceiverResponse response = profitsharingService.addReceiver(request);
if (response == null) {
return markFailed(relation, "EMPTY_RESPONSE", "微信未返回关系绑定结果");
}
return markBound(relation, JSONUtil.toJsonStr(response), response.getAccount());
}
/** 支付宝关系绑定。 */
private boolean bindAlipay(ThirdMchFzRelationEntity relation) throws Exception {
MchPaySettingEntity.AliPaySetting paySetting = new MchPaySettingEntity.AliPaySetting();
paySetting.setAccountKey(relation.getAccountKey());
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(paySetting);
AlipayTradeRoyaltyRelationBindModel model = new AlipayTradeRoyaltyRelationBindModel();
model.setOutRequestNo(relation.getOutRequestNo());
RoyaltyEntity receiver = new RoyaltyEntity();
receiver.setType(relation.getReceiverType());
if ("loginName".equals(relation.getReceiverType())) {
receiver.setLoginName(relation.getReceiverAccount());
} else {
receiver.setAccount(relation.getReceiverAccount());
}
receiver.setName(relation.getReceiverName());
receiver.setMemo("平台默认分佣关系");
model.setReceiverList(List.of(receiver));
AlipayTradeRoyaltyRelationBindRequest request = new AlipayTradeRoyaltyRelationBindRequest();
request.setBizModel(model);
if (StrUtil.isNotBlank(paySetting.getAppAuthToken())) {
request.putOtherTextParam("app_auth_token", paySetting.getAppAuthToken());
}
AlipayTradeRoyaltyRelationBindResponse response = client(config).execute(request);
if (response != null && response.isSuccess()) {
return markBound(relation, JSONUtil.toJsonStr(response), response.getResultCode());
}
// 绑定响应丢失时用相同 out_request_no 查询,避免重复建立关系或误判失败。
AlipayTradeRoyaltyRelationBatchqueryResponse queried = queryAlipay(config, relation.getOutRequestNo(), paySetting.getAppAuthToken());
if (queried != null && queried.isSuccess() && queried.getReceiverList() != null && !queried.getReceiverList().isEmpty()) {
return markBound(relation, JSONUtil.toJsonStr(queried), "QUERY_BOUND");
}
return markFailed(relation, response == null ? "EMPTY_RESPONSE" : response.getSubCode(),
response == null ? "支付宝未返回关系绑定结果" : response.getSubMsg());
}
/** 查询支付宝关系绑定结果。 */
private AlipayTradeRoyaltyRelationBatchqueryResponse queryAlipay(AppPlusPayConfig.AliPayConfig config,
String outRequestNo, String appAuthToken) throws Exception {
AlipayTradeRoyaltyRelationBatchqueryModel model = new AlipayTradeRoyaltyRelationBatchqueryModel();
model.setOutRequestNo(outRequestNo);
model.setPageNum(1L);
model.setPageSize(20L);
AlipayTradeRoyaltyRelationBatchqueryRequest request = new AlipayTradeRoyaltyRelationBatchqueryRequest();
request.setBizModel(model);
if (StrUtil.isNotBlank(appAuthToken)) {
request.putOtherTextParam("app_auth_token", appAuthToken);
}
return client(config).execute(request);
}
/** 动态创建支付宝客户端,命名账号不存在时直接失败。 */
private AlipayClient client(AppPlusPayConfig.AliPayConfig config) {
if (config == null || StrUtil.hasBlank(config.getAppId(), config.getAppPrivateKey(), config.getAlipayPublicKey())) {
throw new IllegalStateException("支付宝平台账号配置不完整");
}
AlipayConfig sdk = new AlipayConfig();
sdk.setServerUrl("https://openapi.alipay.com/gateway.do");
sdk.setAppId(config.getAppId());
sdk.setPrivateKey(config.getAppPrivateKey());
sdk.setFormat("json");
sdk.setAlipayPublicKey(config.getAlipayPublicKey());
sdk.setCharset("UTF-8");
sdk.setSignType("RSA2");
return new DefaultAlipayClient(sdk);
}
/** 解析微信支付配置。 */
private MchPaySettingEntity.WechatPaySetting parseWechat(MchPaySettingVo setting) {
MchPaySettingEntity.WechatPaySetting result = StrUtil.isBlank(setting.getSettingJson()) ? null
: JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
if (result == null || StrUtil.isBlank(result.getSubMchid())) {
throw new IllegalStateException("微信子商户号未配置");
}
return result;
}
/** 解析支付宝直付通配置。 */
private MchPaySettingEntity.AliPaySetting parseAliPay(MchPaySettingVo setting) {
MchPaySettingEntity.AliPaySetting result = StrUtil.isBlank(setting.getSettingJson()) ? null
: JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (result == null || StrUtil.isBlank(result.getSubMchid())) {
throw new IllegalStateException("支付宝直付通二级商户标识未配置");
}
return result;
}
/** 读取微信平台配置。 */
private AppPlusPayConfig.WechatConfig requireWechatConfig() {
AppPlusPayConfig.WechatConfig config = appPlusPayConfig.getWechatConfig();
if (config == null || StrUtil.hasBlank(config.getAppPlatformAppId(), config.getMerchantId())) {
throw new IllegalStateException("微信平台分佣配置不完整");
}
return config;
}
/** 按 accountKey 读取支付宝平台配置。 */
private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingVo setting) {
MchPaySettingEntity.AliPaySetting paySetting = parseAliPay(setting);
return resolveAliPayConfig(paySetting);
}
/** 按 accountKey 读取支付宝平台配置。 */
private AppPlusPayConfig.AliPayConfig resolveAliPayConfig(MchPaySettingEntity.AliPaySetting setting) {
AppPlusPayConfig.AliPayConfig base = appPlusPayConfig.getAliPayConfig();
if (base == null) {
throw new IllegalStateException("支付宝平台配置不存在");
}
if (StrUtil.isBlank(setting == null ? null : setting.getAccountKey())) {
return base;
}
AppPlusPayConfig.AliPayConfig named = base.getAccounts() == null ? null : base.getAccounts().get(setting.getAccountKey());
if (named == null) {
throw new IllegalStateException("支付宝平台账号不存在");
}
return named;
}
/** 保存绑定成功快照。 */
private boolean markBound(ThirdMchFzRelationEntity relation, String response, String externalStatus) {
return relationMapper.update(null, new LambdaUpdateWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getId, relation.getId())
.eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING.getCode())
.set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BOUND.getCode())
.set(ThirdMchFzRelationEntity::getBindRes, response)
.set(ThirdMchFzRelationEntity::getBindError, null)
.set(ThirdMchFzRelationEntity::getLastExternalStatus, externalStatus)
.set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now())
.set(ThirdMchFzRelationEntity::getBoundTime, LocalDateTime.now())) > 0;
}
/** 保存确定性失败或可重试失败快照。 */
private boolean markFailed(ThirdMchFzRelationEntity relation, String code, String message) {
relationMapper.update(null, new LambdaUpdateWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getId, relation.getId())
.eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING.getCode())
.set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BIND_FAILED.getCode())
.set(ThirdMchFzRelationEntity::getLastExternalStatus, code)
.set(ThirdMchFzRelationEntity::getBindError, StrUtil.subPre(StrUtil.blankToDefault(message, code), 1000))
.set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now()));
return false;
}
/** 脱敏账号。 */
private String mask(String value) {
if (StrUtil.isBlank(value) || value.length() <= 4) {
return "****";
}
return value.substring(0, 2) + "****" + value.substring(value.length() - 2);
}
/** 避免静态检查把敏感值直接写入快照。 */
private String paySetting(String value) {
return value;
}
}
@@ -2,7 +2,6 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
@@ -99,17 +98,22 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
/**
* Quartz 固定触发动作:恢复过期租约、物化业务任务、CAS领取后提交有界队列。
*
* @param dto 任务类型、批量上限和触发来源;触发时间不作为 thirdparty 租约时钟
* @return 本轮物化、成功提交、处理中、繁忙状态和到期积压统计
*/
@Override
public ThirdPaymentTaskDispatchVo dispatch(ThirdPaymentTaskDispatchDto dto) {
ThirdPaymentTaskTypeEnum type = ThirdPaymentTaskTypeEnum.fromCode(dto.getTaskType());
int batchSize = Math.max(1, Math.min(dto.getBatchSize() == null ? 100 : dto.getBatchSize(), 500));
LocalDateTime now = dto.getTriggerTime() == null ? LocalDateTime.now() : dto.getTriggerTime();
// Quartz 的 triggerTime 可能因为 misfire 或 Feign 排队而落后于当前时间,不能拿它作为租约起点;
// 租约和 CAS 必须使用 thirdparty 实例实际收到分发请求的时间,避免刚领取就被其他实例恢复。
LocalDateTime now = LocalDateTime.now();
baseMapper.recoverExpiredLeases(type.getCode(), now);
int accepted = materialize(type, now, batchSize);
List<ThirdPaymentTaskEntity> due = baseMapper.selectDueTasks(type.getCode(), now, batchSize);
int claimed = 0;
int processing = executor == null ? 0 : executor.getActiveCount();
int processing = executor == null ? 0 : executor.getActiveCount() + executor.getQueue().size();
boolean busy = executor == null || executor.getQueue().remainingCapacity() == 0;
for (ThirdPaymentTaskEntity candidate : due) {
String token = UUID.randomUUID().toString().replace("-", "");
@@ -117,7 +121,6 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
if (baseMapper.claim(candidate.getId(), now, workerId, token, expire) != 1) {
continue;
}
claimed++;
ThirdPaymentTaskEntity leased = candidate;
leased.setLeaseToken(token);
leased.setLeaseOwner(workerId);
@@ -130,6 +133,7 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
continue;
}
executor.execute(() -> executeLeasedTask(leased, dto.getTriggerSource()));
claimed++;
} catch (RejectedExecutionException exception) {
baseMapper.release(leased.getId(), token, now.plusSeconds(5), "Worker拒绝任务");
busy = true;
@@ -142,6 +146,11 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
/**
* 根据业务表当前事实幂等物化任务;插入冲突不视为错误。
*
* @param type 本轮只处理的任务类型
* @param now thirdparty 接收分发请求的时间,也是新任务默认可执行时间
* @param limit 本轮最多读取的业务候选数量
* @return 成功插入的新任务事实数量,已存在的稳定任务键不计入
*/
private int materialize(ThirdPaymentTaskTypeEnum type, LocalDateTime now, int limit) {
List<ThirdPaymentTaskEntity> candidates = baseMapper.selectMaterializationCandidates(type.getCode(), now, limit);
@@ -161,9 +170,20 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
}
/**
* 执行已取得租约的任务,所有租户数据访问和 Feign 请求均在任务所属租户上下文中完成。
* 执行已取得租约的任务;离开本地队列时先续租,随后在任务所属租户上下文中完成数据访问和渠道调用。
*
* @param task 已通过 CAS 领取并携带租约令牌的任务快照
* @param triggerSource Quartz、人工或实时路径等触发来源,空值按 QUARTZ 记录
*/
private void executeLeasedTask(ThirdPaymentTaskEntity task, String triggerSource) {
LocalDateTime executionStart = LocalDateTime.now();
int renewed = baseMapper.renewLease(task.getId(), task.getLeaseToken(), workerId,
executionStart.plusSeconds(leaseSeconds), executionStart);
if (renewed != 1) {
// 队列等待期间租约可能已过期并被其他实例恢复;旧队列项不得再访问任何支付渠道。
log.info("支付任务队列项租约已失效,跳过执行,taskId:{},workerId:{}", task.getId(), workerId);
return;
}
TenantBroker.runAs(task.getTenantId(), ignored -> {
ThirdPaymentTaskAttemptEntity attempt = new ThirdPaymentTaskAttemptEntity();
attempt.setTaskId(task.getId());
@@ -207,20 +227,37 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
/**
* 执行单个任务并将业务当前状态映射为成功或可重试结果。
*
* @param task 已验证执行租约且恢复租户上下文的任务事实
* @return 不含渠道敏感报文的任务状态机结果
*/
private TaskResult runTask(ThirdPaymentTaskEntity task) {
ThirdPaymentTaskTypeEnum type = ThirdPaymentTaskTypeEnum.fromCode(task.getTaskType());
ThirdPayOrderEntity payOrder;
switch (type) {
case PAY_CREATE:
payOrder = payOrderService.getById(task.getPayOrderId());
if (payOrder == null || !ThirdPayOrderStatusEnum.PAY_UNPAID.getType().equals(payOrder.getStatus())) {
return TaskResult.success(payOrder == null ? "MISSING" : payOrder.getStatus());
}
if (StrUtil.isNotBlank(payOrder.getPayRes())) {
// 同步响应可能在 PAY_CREATE 已被领取后才提交,此时创建阶段已完成,只保留唯一 PAY_QUERY。
paymentTaskEnqueuer.enqueuePayQuery(payOrder.getTenantId(), payOrder.getId(), payOrder.getPayNo());
return TaskResult.success("CREATE_ACCEPTED");
}
// 创建响应丢失时先查渠道事实;App 支付参数无法由后台安全交付给原客户端,禁止盲目新建业务单号。
appPlusService.queryPayResult(payOrder);
payOrder = payOrderService.getById(task.getPayOrderId());
return payOrder == null || !ThirdPayOrderStatusEnum.PAY_UNPAID.getType().equals(payOrder.getStatus())
? TaskResult.success(payOrder == null ? "MISSING" : payOrder.getStatus())
: TaskResult.retry("CREATE_RESULT_UNKNOWN", "PAY_CREATE_RESULT_UNKNOWN", "支付创建结果尚未确认");
case PAY_QUERY:
payOrder = payOrderService.getById(task.getPayOrderId());
if (payOrder == null || !ThirdPayOrderStatusEnum.PAY_UNPAID.getType().equals(payOrder.getStatus())) {
return TaskResult.success(payOrder == null ? "MISSING" : payOrder.getStatus());
}
appPlusService.queryPayResult(payOrder);
payOrder = payOrderService.getById(task.getPayOrderId());
return settleByPayStatus(payOrder);
return settleByPayStatus(payOrderService.getById(task.getPayOrderId()));
case REFUND_CREATE:
case REFUND_QUERY:
payOrder = payOrderService.getById(task.getPayOrderId());
@@ -254,8 +291,11 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
if (createdAli == null) {
return TaskResult.retry("PROCESSING", "FZ_NOT_CREATED", "支付宝分账记录尚未生成");
}
// 历史成功订单可能没有 payTime;此时以当前处理时间作为安全下限,仍保证确认结算不会早于本次发现。
LocalDateTime settleReadyTime = payOrder.getPayTime() == null
? LocalDateTime.now().plusSeconds(30) : payOrder.getPayTime().plusSeconds(30);
paymentTaskEnqueuer.enqueueFzSettleConfirm(payOrder.getTenantId(), payOrder.getId(), createdAli.getId(),
createdAli.getPayNo(), createdAli.getFzNo(), payOrder.getPayTime().plusSeconds(30));
createdAli.getPayNo(), createdAli.getFzNo(), settleReadyTime);
}
return fzCreated(payOrder.getPayNo());
case FZ_SETTLE_CONFIRM:
@@ -325,11 +365,7 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
case AGGREGATE_SESSION:
aggregateOrderPayService.processPendingSession(task.getAggregateSessionId());
ThirdAggregatePaySessionEntity session = aggregateSessionService.getById(task.getAggregateSessionId());
if (session != null && List.of(
ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
ThirdAggregatePaySessionStatusEnum.PAYING.getType(),
ThirdAggregatePaySessionStatusEnum.EXPIRED.getType()).contains(session.getStatus())) {
if (isAggregateSessionProcessing(session)) {
return TaskResult.retry("PROCESSING", "SESSION_PROCESSING", "聚合支付会话尚未收敛");
}
return TaskResult.success(session == null ? "MISSING" : session.getStatus());
@@ -343,19 +379,18 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
return settleByPayStatus(payOrderService.getById(task.getPayOrderId()));
case AGGREGATE_CLOSE:
ThirdAggregatePaySessionEntity aggregate = aggregateSessionService.getById(task.getAggregateSessionId());
if (aggregate != null) {
aggregateSessionService.markTerminal(aggregate.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
ThirdPayOrderEntity child = StrUtil.isBlank(aggregate.getPayNo())
? null : payOrderService.getByPayNo(aggregate.getPayNo());
if (child != null) {
ThirdPayOrderEntity closeChild = new ThirdPayOrderEntity();
closeChild.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType());
payOrderService.update(closeChild, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, child.getId())
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
}
if (aggregate == null) {
return TaskResult.success("MISSING");
}
return TaskResult.success("CLOSED");
if (StrUtil.isBlank(aggregate.getPayNo())) {
// 从未绑定渠道子订单的历史会话没有外部支付事实,可以安全本地关闭。
aggregateSessionService.markTerminal(aggregate.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
return TaskResult.success("NO_CHANNEL_ORDER");
}
// 渠道单始终交给唯一 AGGREGATE_SESSION 任务收敛,避免两个任务键并发重复查单或关单。
paymentTaskEnqueuer.enqueueAggregateSession(aggregate.getTenantId(), aggregate.getId(),
aggregate.getAggregateNo(), aggregate.getPayNo(), LocalDateTime.now());
return TaskResult.success("DELEGATED_TO_AGGREGATE_SESSION");
case RECONCILE:
return reconcilePayOrder(task);
default:
@@ -387,6 +422,25 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
return TaskResult.success("RECONCILED");
}
/**
* 判断聚合会话是否仍需由相同任务键继续补偿。
*
* @param session 业务处理后的最新聚合会话;记录缺失时允许为空
* @return 活动状态,或已过期但仍绑定渠道子订单时返回 {@code true}
*/
private boolean isAggregateSessionProcessing(ThirdAggregatePaySessionEntity session) {
if (session == null) {
return false;
}
if (List.of(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
ThirdAggregatePaySessionStatusEnum.PAYING.getType()).contains(session.getStatus())) {
return true;
}
return ThirdAggregatePaySessionStatusEnum.EXPIRED.getType().equals(session.getStatus())
&& StrUtil.isNotBlank(session.getPayNo());
}
/**
* 按支付状态判定查单是否收敛。
*
@@ -488,6 +542,9 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
/**
* 实时路径创建任务。调用方必须先填充租户、类型和稳定任务键;本方法不会重写业务号。
*
* @param task 已包含租户、类型、稳定任务键和关联业务号的任务事实
* @return 新任务写入或相同稳定任务键已经存在时均返回 {@code true}
*/
@Override
public boolean enqueue(ThirdPaymentTaskEntity task) {
@@ -509,7 +566,12 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
return true;
}
/** 人工重试保留原任务键,禁止生成新的支付业务号。 */
/**
* 将允许人工恢复的终态任务放回重试队列,保留原任务键和全部外部业务号。
*
* @param taskId 当前租户内待恢复的任务主键
* @return 原状态允许人工重试且更新成功时返回 {@code true}
*/
@Override
public boolean manualRetry(Long taskId) {
return baseMapper.manualRetry(taskId, LocalDateTime.now()) > 0;
@@ -74,7 +74,7 @@
DATE_ADD(COALESCE(p.pay_time, p.update_time, #{now}), INTERVAL 30 SECOND) AS next_run_time
FROM third_pay_order p
WHERE p.del_flag = '0' AND p.status = '20' AND p.enable_fz = '1'
AND p.pay_no IS NOT NULL AND p.pay_time IS NOT NULL
AND p.pay_no IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM third_fz_order f
WHERE f.tenant_id = p.tenant_id AND f.pay_no = p.pay_no AND f.del_flag = '0')
@@ -106,7 +106,7 @@
JOIN third_pay_order p ON p.tenant_id = f.tenant_id AND p.pay_no = f.pay_no AND p.del_flag = '0'
WHERE f.del_flag = '0' AND f.status = '10' AND f.fz_no IS NOT NULL
AND p.pay_platform_type = '20' AND p.status = '20'
AND p.pay_time IS NOT NULL AND p.pay_time &lt;= DATE_SUB(#{now}, INTERVAL 30 SECOND)
AND COALESCE(p.pay_time, p.update_time, #{now}) &lt;= DATE_SUB(#{now}, INTERVAL 30 SECOND)
AND (JSON_UNQUOTE(JSON_EXTRACT(COALESCE(f.create_res, '{}'), '$.settleConfirm.status')) IS NULL
OR JSON_UNQUOTE(JSON_EXTRACT(COALESCE(f.create_res, '{}'), '$.settleConfirm.status')) &lt;&gt; 'SUCCESS')
AND NOT EXISTS (SELECT 1 FROM third_payment_task t WHERE t.tenant_id = f.tenant_id
@@ -268,6 +268,13 @@
AND next_run_time &lt;= #{now} AND del_flag = '0'
</update>
<update id="renewLease">
UPDATE third_payment_task
SET lease_expire_time = #{expireTime}, update_time = #{now}
WHERE id = #{id} AND status = 'PROCESSING' AND lease_token = #{token}
AND lease_owner = #{owner} AND del_flag = '0'
</update>
<update id="release">
UPDATE third_payment_task
SET status = 'RETRY', next_run_time = #{nextRunTime}, lease_owner = NULL, lease_token = NULL,
@@ -3,12 +3,9 @@ package com.bsd.pjcloud.thirdparty.job;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -22,8 +19,6 @@ import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
/**
@@ -39,17 +34,11 @@ class ThirdAggregateOrderPayJobTest {
private ThirdAggregatePaySessionService aggregatePaySessionService;
/**
* 聚合支付编排服务 Mock,用于确认历史关闭路径不再调用支付平台处理流程。
* 聚合支付编排服务 Mock,用于确认历史记录继续交由可信查单路径处理。
*/
@Mock
private ThirdAggregateOrderPayService aggregateOrderPayService;
/**
* 第三方子订单服务 Mock;无子订单会话关闭时不应访问该服务。
*/
@Mock
private ThirdPayOrderService thirdPayOrderService;
/**
* 自适应轮询协调器 Mock,用于允许本轮补偿扫描。
*/
@@ -71,10 +60,10 @@ class ThirdAggregateOrderPayJobTest {
}
/**
* 验证超过一小时且没有子订单的会话直接标记关闭,不再进入第三方平台查单流程。
* 验证超过一小时且没有渠道单的会话仍委托业务编排服务,由业务层决定本地终态。
*/
@Test
void compensationShouldCloseHistoricalSessionWithoutCallingPaymentPlatform() {
void compensationShouldDelegateHistoricalSessionWithoutChannelOrder() {
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setId(201L);
session.setTenantId(66L);
@@ -85,36 +74,27 @@ class ThirdAggregateOrderPayJobTest {
job.handleExpiredSessionCompensation();
verify(aggregatePaySessionService).markTerminal(
session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
verify(aggregateOrderPayService).processPendingSession(session.getId());
verify(pollingCoordinator).finishAggregateCompensationPolling(true);
verifyNoInteractions(aggregateOrderPayService, thirdPayOrderService);
}
/**
* 验证历史子订单即使已保存支付成功事实,也只关闭聚合会话而不执行历史状态恢复。
* 验证历史会话已绑定渠道单时不会在 Job 中盲关,而是委托编排服务核对渠道事实。
*/
@Test
void compensationShouldNotRecoverHistoricalSessionFromSuccessfulChildOrder() {
void compensationShouldDelegateHistoricalSessionWithChannelOrder() {
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setId(202L);
session.setTenantId(66L);
session.setPayNo("historical-child-pay-no");
session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
session.setCreateTime(LocalDateTime.now().minusHours(2));
ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
payOrder.setId(302L);
payOrder.setPayNo(session.getPayNo());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
when(pollingCoordinator.tryStartAggregateCompensationPolling()).thenReturn(true);
when(aggregatePaySessionService.list(any(Wrapper.class))).thenReturn(List.of(session), List.of());
when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder);
job.handleExpiredSessionCompensation();
verify(aggregatePaySessionService).markTerminal(
session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
verify(aggregatePaySessionService, never()).syncFromPayOrder(any());
verifyNoInteractions(aggregateOrderPayService);
verify(aggregateOrderPayService).processPendingSession(session.getId());
verify(pollingCoordinator).finishAggregateCompensationPolling(true);
}
}
@@ -7,6 +7,8 @@ import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 支付任务 Mapper 原子条件契约测试,防止维护 SQL 时移除跨实例安全所需的状态或租约约束。
@@ -16,9 +18,15 @@ class ThirdPaymentTaskMapperSqlContractTest {
/** MyBatis 支付任务映射资源路径。 */
private static final String MAPPER_RESOURCE = "mapper/ThirdPaymentTaskMapper.xml";
/** 支付任务表与高频业务扫描索引迁移相对项目根目录的路径。 */
private static final String PAYMENT_TASK_MIGRATION = "db/20260824_add_third_payment_task.sql";
/** 测试启动时读取的 Mapper XML。 */
private static String mapperXml;
/** 测试启动时读取的支付任务数据库迁移。 */
private static String migrationSql;
/**
* 从测试类路径读取本轮实际参与编译的 Mapper XML。
*
@@ -31,6 +39,7 @@ class ThirdPaymentTaskMapperSqlContractTest {
Assertions.assertNotNull(input, "支付任务 Mapper XML 必须进入运行时类路径");
mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
migrationSql = readProjectFile(PAYMENT_TASK_MIGRATION);
}
/**
@@ -68,6 +77,30 @@ class ThirdPaymentTaskMapperSqlContractTest {
() -> Assertions.assertTrue(sql.contains("status = 'RETRY'")));
}
/**
* 验证任务离开本地队列时必须按原租约令牌和 Worker 所有者续租,失效队列项不能访问支付渠道。
*/
@Test
void leaseRenewalShouldRequireCurrentTokenAndOwner() {
String sql = statement("update", "renewLease");
Assertions.assertAll(
() -> Assertions.assertTrue(sql.contains("status = 'PROCESSING'")),
() -> Assertions.assertTrue(sql.contains("lease_token = #{token}")),
() -> Assertions.assertTrue(sql.contains("lease_owner = #{owner}")));
}
/**
* 验证高频物化扫描具备与实际谓词和排序方向匹配的业务源表索引,避免百万级数据量下周期全表扫描。
*/
@Test
void migrationShouldCoverHighFrequencyBusinessScans() {
Assertions.assertAll(
() -> Assertions.assertTrue(migrationSql.contains("`idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)")),
() -> Assertions.assertTrue(migrationSql.contains("`idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)")),
() -> Assertions.assertTrue(migrationSql.contains("`idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)")),
() -> Assertions.assertTrue(migrationSql.contains("`idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)")));
}
/**
* 提取指定 MyBatis 语句片段。
*
@@ -82,4 +115,23 @@ class ThirdPaymentTaskMapperSqlContractTest {
Assertions.assertTrue(start >= 0 && end > start, "缺少 MyBatis 语句:" + id);
return mapperXml.substring(start, end);
}
/**
* 从当前 Maven 或 IDE 工作目录向父目录查找项目文件,兼容聚合构建和模块内单测两种启动方式。
*
* @param relativePath 相对后端项目根目录的文件路径
* @return UTF-8 文件内容
* @throws IOException 文件读取失败或未找到后端项目根目录时抛出
*/
private static String readProjectFile(String relativePath) throws IOException {
Path current = Path.of("").toAbsolutePath();
while (current != null) {
Path candidate = current.resolve(relativePath);
if (Files.isRegularFile(candidate)) {
return Files.readString(candidate, StandardCharsets.UTF_8);
}
current = current.getParent();
}
throw new IOException("未找到项目文件:" + relativePath);
}
}
@@ -72,4 +72,24 @@ class ThirdPaymentTaskEnqueuerTest {
() -> Assertions.assertEquals(201L, task.getFzOrderId()),
() -> Assertions.assertEquals(110, task.getPriority()));
}
/**
* 验证历史聚合关闭任务物化的会话任务沿用唯一聚合号,不产生第二个渠道处理任务键。
*/
@Test
void enqueueAggregateSessionShouldUseStableAggregateKey() {
enqueuer.enqueueAggregateSession(9L, 301L, "aggregate-1001", "pay-1001", null);
ArgumentCaptor<ThirdPaymentTaskEntity> captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class);
verify(taskMapper).insertIgnore(captor.capture());
ThirdPaymentTaskEntity task = captor.getValue();
Assertions.assertAll(
() -> Assertions.assertEquals("AGGREGATE_SESSION:aggregate-1001", task.getTaskKey()),
() -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION.getCode(), task.getTaskType()),
() -> Assertions.assertEquals(301L, task.getAggregateSessionId()),
() -> Assertions.assertEquals("aggregate-1001", task.getAggregateNo()),
() -> Assertions.assertEquals("pay-1001", task.getPayNo()),
() -> Assertions.assertEquals(105, task.getPriority()),
() -> Assertions.assertNotNull(task.getNextRunTime()));
}
}
@@ -159,6 +159,27 @@ class ThirdAggregateAliPayServiceImplTest {
Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("alipay-open-id"));
}
/**
* 验证商户没有配置授权 Token 时请求中完全不发送 app_auth_token,且不降级为另一套支付流程。
*
* @throws Exception Mock 支付宝客户端声明的受检异常
*/
@Test
void createPayShouldOmitEmptyAppAuthToken() throws Exception {
paySetting.setSettingJson(JSONUtil.toJsonStr(new MchPaySettingEntity.AliPaySetting()));
AlipayTradeCreateResponse response = new AlipayTradeCreateResponse();
response.setCode("10000");
response.setTradeNo("2026000000000003");
when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response);
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true);
service.createPay(context, paySetting, new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null));
ArgumentCaptor<AlipayTradeCreateRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class);
verify(alipayClient).execute(requestCaptor.capture());
Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token"));
}
/**
* 验证平台下单成功但本地子订单保存失败时立即发起撤销,避免形成孤立可支付订单。
*
@@ -25,6 +25,8 @@ import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import org.jasypt.encryption.StringEncryptor;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
@@ -148,7 +150,7 @@ class ThirdAggregateOrderPayServiceImplTest {
lenient().when(remoteTenantService.list()).thenReturn(R.ok(List.of(tenant)));
request = new ThirdAggregateOrderCodePayDto();
request.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY);
request.setBizType(ThirdPayOrderBizTypeEnum.XFJ);
request.setBizId("order-1001");
request.setMchId(88L);
request.setAmount(1234);
@@ -234,10 +236,10 @@ class ThirdAggregateOrderPayServiceImplTest {
}
/**
* 验证第三方查单异常不能让已到期会话永久停留在五秒高频轮询队列。
* 验证第三方查单异常必须保留原会话状态,由任务表使用同一任务键退避重试。
*/
@Test
void expiredSessionShouldBecomeExpiredWhenAlipayQueryFails() {
void expiredSessionShouldRemainPendingWhenAlipayQueryFails() {
ThirdAggregatePaySessionEntity session = reusableSession();
session.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
@@ -256,14 +258,16 @@ class ThirdAggregateOrderPayServiceImplTest {
Assertions.assertThrows(RuntimeException.class, () -> service.processPendingSession(session.getId()));
verify(aggregatePaySessionService).markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.EXPIRED);
verify(aggregatePaySessionService, never()).markTerminal(any(), any());
verify(aggregateAliPayService).query(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
verify(aggregateAliPayService, never()).cancel(anyString(), anyString());
}
/**
* 验证超过一小时的会话从任何处理入口进入时都只做本地关闭,不访问支付宝或微信。
* 验证超过一小时但已绑定渠道单的会话仍先查单,并仅在渠道确认撤销后更新本地订单。
*/
@Test
void historicalSessionShouldCloseLocallyWithoutQueryingPaymentPlatform() {
void historicalSessionWithChannelOrderShouldQueryAndCancelPaymentPlatform() {
ThirdAggregatePaySessionEntity session = reusableSession();
session.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
@@ -274,22 +278,34 @@ class ThirdAggregateOrderPayServiceImplTest {
payOrder.setId(102L);
payOrder.setPayNo(session.getPayNo());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
payOrder.setMchPaySettingJson("{}");
AlipayTradeQueryResponse queryResponse = new AlipayTradeQueryResponse();
queryResponse.setCode("10000");
queryResponse.setTradeStatus("WAIT_BUYER_PAY");
AlipayTradeCancelResponse cancelResponse = new AlipayTradeCancelResponse();
cancelResponse.setCode("10000");
cancelResponse.setAction("close");
cancelResponse.setRetryFlag("N");
when(aggregatePaySessionService.getById(session.getId())).thenReturn(session);
when(thirdPayOrderService.getByPayNo(session.getPayNo())).thenReturn(payOrder);
when(aggregateAliPayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson()))
.thenReturn(queryResponse);
when(aggregateAliPayService.cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson()))
.thenReturn(cancelResponse);
service.processPendingSession(session.getId());
verify(aggregatePaySessionService).markTerminal(
session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
verify(thirdPayOrderService).update(any(Wrapper.class));
verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService);
verify(aggregateAliPayService).query(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
verify(aggregateAliPayService).cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
verify(thirdPayOrderService).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
verifyNoInteractions(aggregateWechatPayService);
}
/**
* 验证一小时后到达的成功回调只保留子订单事实,不把历史聚合会话恢复为支付成功。
* 验证一小时后到达且已经验签的成功回调仍同步真实扣款事实,不受本地超时状态覆盖。
*/
@Test
void lateSuccessfulCallbackShouldNotRecoverHistoricalSession() {
void lateSuccessfulCallbackShouldRecoverHistoricalSession() {
ThirdAggregatePaySessionEntity session = reusableSession();
session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
session.setPayNo("late-success-pay-no");
@@ -300,13 +316,11 @@ class ThirdAggregateOrderPayServiceImplTest {
payOrder.setPayNo(session.getPayNo());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
when(aggregatePaySessionService.getByPayNo(payOrder.getPayNo())).thenReturn(session);
when(thirdPayOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder);
Assertions.assertTrue(service.syncAggregateChildOrder(payOrder));
verify(aggregatePaySessionService).markTerminal(
session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
verify(aggregatePaySessionService, never()).syncFromPayOrder(any());
verify(aggregatePaySessionService).syncFromPayOrder(payOrder);
verify(aggregatePaySessionService, never()).markTerminal(any(), any());
verifyNoInteractions(aggregateAliPayService, aggregateWechatPayService);
}
@@ -297,6 +297,22 @@ class ThirdAppPlusAliPayServiceImplTest {
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
/**
* 验证旧版本已经支付成功但没有支付时间的订单仍能物化分账事实,后续等待时间由任务 Worker 安全兜底。
*/
@Test
void ensureDirectPayFzOrderShouldRecoverHistoricalOrderWithoutPayTime() {
ThirdPayOrderEntity payOrder = paidDirectPayOrder();
payOrder.setPayTime(null);
ThirdFzOrderEntity result = service.ensureDirectPayFzOrder(payOrder);
Assertions.assertNotNull(result);
Assertions.assertEquals("fz_" + payOrder.getPayNo(), result.getFzNo());
Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_UNPAID.getType(), result.getStatus());
verify(thirdFzOrderService).save(result);
}
/**
* 验证已完成平台抽佣的支付宝订单退款携带按退款金额折算的 refund_royalty_parameters。
*/
@@ -0,0 +1,274 @@
package com.bsd.pjcloud.thirdparty.service.impl.pay;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskAttemptMapper;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskMapper;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService;
import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
import org.junit.jupiter.api.Assertions;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.Collections;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* 支付任务 Worker 聚合会话分支测试,重点验证历史任务不会并发重复访问支付渠道。
*/
@ExtendWith(MockitoExtension.class)
class ThirdPaymentTaskServiceImplTest {
/** 支付任务尝试日志 Mapper Mock,本组测试不执行异步租约入口。 */
@Mock
private ThirdPaymentTaskAttemptMapper attemptMapper;
/** 支付任务 Mapper Mock,用于验证分发租约使用 thirdparty 当前时间。 */
@Mock
private ThirdPaymentTaskMapper taskMapper;
/** 支付订单服务 Mock,用于确认关闭分支不直接读写渠道子订单。 */
@Mock
private ThirdPayOrderService payOrderService;
/** 分账订单服务 Mock,本组聚合测试不使用。 */
@Mock
private ThirdFzOrderService fzOrderService;
/** App 支付编排服务 Mock,本组聚合测试不使用。 */
@Mock
private ThirdAppPlusService appPlusService;
/** 微信分账服务 Mock,本组聚合测试不使用。 */
@Mock
private ThirdAppPlusWechatFzService wechatFzService;
/** 支付宝直付通服务 Mock,本组聚合测试不使用。 */
@Mock
private ThirdAppPlusAliPayService aliPayService;
/** 聚合会话服务 Mock,用于返回最新会话状态和验证本地终态更新。 */
@Mock
private ThirdAggregatePaySessionService aggregateSessionService;
/** 聚合支付编排服务 Mock,用于验证唯一会话任务负责渠道查单。 */
@Mock
private ThirdAggregateOrderPayService aggregateOrderPayService;
/** 稳定任务写入器 Mock,用于验证历史关闭任务只物化唯一会话任务。 */
@Mock
private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
/** 被测支付任务 Worker。 */
@InjectMocks
private ThirdPaymentTaskServiceImpl service;
/**
* 将继承自 MyBatis-Plus ServiceImpl 的 Mapper 字段替换为 Mock,避免分发测试访问数据库。
*/
@BeforeEach
void setUpBaseMapper() {
ReflectionTestUtils.setField(service, "baseMapper", taskMapper);
}
/**
* 验证已绑定渠道单的历史会话只补建唯一会话任务,不在关闭任务中再次访问渠道。
*
* @throws Exception 反射调用私有任务分派方法失败时抛出
*/
@Test
void aggregateCloseWithChannelOrderShouldDelegateToStableSessionTask() throws Exception {
ThirdAggregatePaySessionEntity session = historicalSession("pay-1001");
when(aggregateSessionService.getById(session.getId())).thenReturn(session);
Object result = runTask(task(ThirdPaymentTaskTypeEnum.AGGREGATE_CLOSE, session));
Assertions.assertTrue(resultSuccess(result));
Assertions.assertEquals("DELEGATED_TO_AGGREGATE_SESSION", resultExternalStatus(result));
verify(paymentTaskEnqueuer).enqueueAggregateSession(eq(session.getTenantId()), eq(session.getId()),
eq(session.getAggregateNo()), eq(session.getPayNo()), any(LocalDateTime.class));
verifyNoInteractions(aggregateOrderPayService, payOrderService);
verify(aggregateSessionService, never()).markTerminal(any(), any());
}
/**
* 验证从未绑定渠道单的历史会话可以本地关闭,且不会创建无意义的渠道查询任务。
*
* @throws Exception 反射调用私有任务分派方法失败时抛出
*/
@Test
void aggregateCloseWithoutChannelOrderShouldCloseLocally() throws Exception {
ThirdAggregatePaySessionEntity session = historicalSession(null);
when(aggregateSessionService.getById(session.getId())).thenReturn(session);
Object result = runTask(task(ThirdPaymentTaskTypeEnum.AGGREGATE_CLOSE, session));
Assertions.assertTrue(resultSuccess(result));
Assertions.assertEquals("NO_CHANNEL_ORDER", resultExternalStatus(result));
verify(aggregateSessionService).markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
verifyNoInteractions(aggregateOrderPayService, payOrderService, paymentTaskEnqueuer);
}
/**
* 验证聚合渠道状态尚未收敛时保持同一会话任务重试,不能把 EXPIRED 误当最终成功。
*
* @throws Exception 反射调用私有任务分派方法失败时抛出
*/
@Test
void aggregateSessionWithUnknownChannelStateShouldRetry() throws Exception {
ThirdAggregatePaySessionEntity session = historicalSession("pay-1001");
when(aggregateSessionService.getById(session.getId())).thenReturn(session);
Object result = runTask(task(ThirdPaymentTaskTypeEnum.AGGREGATE_SESSION, session));
Assertions.assertFalse(resultSuccess(result));
Assertions.assertEquals("PROCESSING", resultExternalStatus(result));
verify(aggregateOrderPayService).processPendingSession(session.getId());
}
/**
* 验证同步创建响应晚于任务领取时,PAY_CREATE 立即完成并只补建稳定 PAY_QUERY,避免两类任务重复查单。
*
* @throws Exception 反射调用私有任务分派方法失败时抛出
*/
@Test
void acceptedPayCreateShouldDelegateToStablePayQuery() throws Exception {
ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
payOrder.setId(301L);
payOrder.setTenantId(9L);
payOrder.setPayNo("pay-create-1001");
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
payOrder.setPayRes("{\"accepted\":true}");
when(payOrderService.getById(payOrder.getId())).thenReturn(payOrder);
ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity();
task.setTaskType(ThirdPaymentTaskTypeEnum.PAY_CREATE.getCode());
task.setPayOrderId(payOrder.getId());
Object result = runTask(task);
Assertions.assertTrue(resultSuccess(result));
Assertions.assertEquals("CREATE_ACCEPTED", resultExternalStatus(result));
verify(paymentTaskEnqueuer).enqueuePayQuery(payOrder.getTenantId(), payOrder.getId(), payOrder.getPayNo());
verifyNoInteractions(appPlusService);
}
/**
* 验证 Quartz misfire 产生的陈旧触发时间不会让 thirdparty 创建已经过期的执行租约。
*/
@Test
void dispatchShouldUseReceiverClockInsteadOfStaleQuartzTriggerTime() {
ThirdPaymentTaskDispatchDto dto = new ThirdPaymentTaskDispatchDto();
dto.setTaskType(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode());
dto.setBatchSize(100);
dto.setTriggerTime(LocalDateTime.now().minusHours(1));
when(taskMapper.selectMaterializationCandidates(eq(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode()),
any(LocalDateTime.class), eq(100))).thenReturn(Collections.emptyList());
when(taskMapper.selectDueTasks(eq(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode()),
any(LocalDateTime.class), eq(100))).thenReturn(Collections.emptyList());
LocalDateTime beforeDispatch = LocalDateTime.now();
service.dispatch(dto);
ArgumentCaptor<LocalDateTime> nowCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(taskMapper).recoverExpiredLeases(eq(ThirdPaymentTaskTypeEnum.PAY_QUERY.getCode()), nowCaptor.capture());
Assertions.assertFalse(nowCaptor.getValue().isBefore(beforeDispatch));
Assertions.assertTrue(nowCaptor.getValue().isAfter(dto.getTriggerTime()));
}
/**
* 构造历史聚合会话,状态保持 EXPIRED 以模拟服务重启后的恢复记录。
*
* @param payNo 渠道支付单号;为空表示从未创建渠道子订单
* @return 带稳定租户和聚合会话号的历史记录
*/
private ThirdAggregatePaySessionEntity historicalSession(String payNo) {
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setId(201L);
session.setTenantId(9L);
session.setAggregateNo("aggregate-1001");
session.setPayNo(payNo);
session.setStatus(ThirdAggregatePaySessionStatusEnum.EXPIRED.getType());
return session;
}
/**
* 构造关联指定聚合会话的任务事实。
*
* @param type 待验证的任务类型
* @param session 任务关联聚合会话
* @return 可直接传入 Worker 分派方法的任务
*/
private ThirdPaymentTaskEntity task(ThirdPaymentTaskTypeEnum type, ThirdAggregatePaySessionEntity session) {
ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity();
task.setTaskType(type.getCode());
task.setTenantId(session.getTenantId());
task.setAggregateSessionId(session.getId());
task.setAggregateNo(session.getAggregateNo());
task.setPayNo(session.getPayNo());
return task;
}
/**
* 反射调用 Worker 私有分派方法,使测试只覆盖状态映射而不启动线程池和数据库租约。
*
* @param task 待执行任务
* @return Worker 内部任务结果记录
* @throws Exception 方法查找、访问或执行失败时抛出
*/
private Object runTask(ThirdPaymentTaskEntity task) throws Exception {
Method method = ThirdPaymentTaskServiceImpl.class.getDeclaredMethod("runTask", ThirdPaymentTaskEntity.class);
method.setAccessible(true);
return method.invoke(service, task);
}
/**
* 读取内部任务结果的成功标识。
*
* @param result Worker 内部任务结果记录
* @return 当前任务是否完成
* @throws Exception 结果访问器不可用时抛出
*/
private boolean resultSuccess(Object result) throws Exception {
Method method = result.getClass().getDeclaredMethod("success");
method.setAccessible(true);
return (boolean) method.invoke(result);
}
/**
* 读取内部任务结果保存的外部状态。
*
* @param result Worker 内部任务结果记录
* @return 最近渠道或本地状态
* @throws Exception 结果访问器不可用时抛出
*/
private String resultExternalStatus(Object result) throws Exception {
Method method = result.getClass().getDeclaredMethod("externalStatus");
method.setAccessible(true);
return (String) method.invoke(result);
}
}
@@ -17,15 +17,16 @@ import java.util.List;
@Configuration
@RequiredArgsConstructor
public class ThirdPaymentQuartzInitializer implements InitializingBean {
/** 支付固定任务所属 Quartz 分组。 */
/** 支付固定任务所属 Quartz 分组,作为代码托管任务与后台可编辑任务的隔离边界。 */
private static final String GROUP = "THIRD_PAYMENT_SYSTEM";
/** 每轮默认最大领取数量。 */
/** 单个任务类型每轮最大领取数量,限制一次 Feign 分发对 thirdparty Worker 队列的压力。 */
private static final int DEFAULT_BATCH_SIZE = 100;
/** Quartz调度器。 */
/** 共享数据库持久化的 Quartz 调度器,由集群负责单次触发与故障接管。 */
private final Scheduler scheduler;
/**
* 支付任务与固定触发间隔;同一 JobKey 在 Quartz 集群中同一时刻只会由一个节点执行。
* 间隔单位为秒,misfire 恢复时只推进后续触发,不突发补跑所有错过次数。
*/
private static final List<PaymentSchedule> SCHEDULES = List.of(
new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_CREATE, 5),
@@ -17,9 +17,9 @@ import java.time.LocalDateTime;
@Slf4j
@DisallowConcurrentExecution
public class ThirdPaymentDispatchJob implements Job {
/** JobDataMap中的任务类型键。 */
/** JobDataMap 中的任务类型键,值为任务类型持久化编码。 */
public static final String TASK_TYPE_KEY = "taskType";
/** JobDataMap中的批量大小键。 */
/** JobDataMap 中的批量大小键,限制 thirdparty 单次可领取任务数量。 */
public static final String BATCH_SIZE_KEY = "batchSize";
/** thirdparty内部任务分发 Feign 客户端,由 Quartz JobFactory 注入。 */
@Autowired
@@ -0,0 +1,28 @@
package com.bsd.pjcloud.wallet.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 钱包流水备注修改参数,只允许后台修改备注,不承载金额、余额和业务关联等不可变流水信息。
*/
@Data
@Schema(description = "钱包流水备注修改参数")
public class WalletTransactionRemarkDto {
/**
* 需要修改备注的钱包流水编号,由钱包流水列表返回。
*/
@NotNull(message = "钱包流水编号不能为空")
@Schema(description = "钱包流水编号", requiredMode = Schema.RequiredMode.REQUIRED)
private Long id;
/**
* 流水业务备注;允许传空以清除原备注,非空内容最多 256 个字符。
*/
@Size(max = 256, message = "流水备注不能超过256个字符")
@Schema(description = "流水备注,最多256个字符")
private String remark;
}
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -84,6 +85,13 @@ public class WalletTransactionEntity extends Model<WalletTransactionEntity> {
@Schema(description = "财务统计标签,多个字典值使用英文逗号分隔")
private String tags;
/**
* 流水备注,由后台操作人员补充业务说明;历史流水和未填写场景允许为空,数据库最多保存 256 个字符。
*/
@Size(max = 256, message = "流水备注不能超过256个字符")
@Schema(description = "流水备注,最多256个字符")
private String remark;
/**
* 创建人
*/
@@ -14,6 +14,7 @@ import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionSumAndCountDto;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo;
@@ -160,10 +161,24 @@ public class WalletTransactionController {
@PutMapping("/update-balance")
@Operation(summary = "更新会员用户余额")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_update_balance')")
public R<Boolean> updateWalletBalance(@RequestBody WalletTransactionDto dto) {
public R<Boolean> updateWalletBalance(@Valid @RequestBody WalletTransactionDto dto) {
return R.ok(walletTransactionService.updateWalletBalance(dto));
}
/**
* 修改单笔钱包流水的业务备注,不开放金额、余额、业务类型等流水核心字段的变更。
*
* @param dto 流水编号和新备注,备注为空时清除原值
* @return 更新到当前租户有效流水时返回 {@code true}
*/
@PutMapping("/update-remark")
@Operation(summary = "修改钱包流水备注")
@SysLog("修改钱包流水备注")
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_edit')")
public R<Boolean> updateRemark(@Valid @RequestBody WalletTransactionRemarkDto dto) {
return R.ok(walletTransactionService.updateRemark(dto));
}
@Operation(summary = "后台充值分页", description = "后台充值分页")
@PostMapping("/page-cz")
public R pageCz(@ParameterObject Page page, @RequestBody WalletTransactionDto dto) {
@@ -295,7 +310,7 @@ public class WalletTransactionController {
@SysLog("新增会员钱包流水表")
@PostMapping
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_add')")
public R save(@RequestBody WalletTransactionEntity walletTransaction) {
public R save(@Valid @RequestBody WalletTransactionEntity walletTransaction) {
return R.ok(walletTransactionService.save(walletTransaction));
}
@@ -303,7 +318,7 @@ public class WalletTransactionController {
@SysLog("修改会员钱包流水表")
@PutMapping
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_edit')")
public R updateById(@RequestBody WalletTransactionEntity walletTransaction) {
public R updateById(@Valid @RequestBody WalletTransactionEntity walletTransaction) {
return R.ok(walletTransactionService.updateById(walletTransaction));
}
@@ -6,6 +6,7 @@ import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.vo.WalletCzTotalVo;
import com.bsd.pjcloud.wallet.api.vo.WalletTypeTotalVo;
@@ -121,6 +122,27 @@ public interface WalletTransactionService extends IService<WalletTransactionEnti
*/
WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags);
/**
* 增加钱包余额并记录财务统计标签和流水备注。
*
* @param walletId 钱包 id
* @param price 增加金额,单位分
* @param bizId 业务关联 id
* @param bizType 业务关联分类
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @return 已保存的钱包流水
*/
WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark);
/**
* 只修改单笔钱包流水的业务备注,空白内容按清除备注处理。
*
* @param dto 流水编号和新备注
* @return 更新到当前租户有效流水时返回 {@code true}
*/
Boolean updateRemark(WalletTransactionRemarkDto dto);
/**
* 更新会员用户余额
*
@@ -12,6 +12,7 @@ import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.alibaba.fastjson.JSON;
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;
import com.bsd.pjcloud.admin.api.entity.SysDept;
@@ -35,6 +36,7 @@ import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletManageDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTransactionRemarkDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTypeDto;
import com.bsd.pjcloud.wallet.api.dto.mch.WalletMchDeptViewDto;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
@@ -717,6 +719,23 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
@Override
@Transactional(rollbackFor = Exception.class)
public WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags) {
return getSelf().addWalletBalance(walletId, price, bizId, bizType, tags, null);
}
/**
* 增加钱包余额,并在同一笔最终流水中保存财务标签和人工备注。
*
* @param walletId 钱包 id
* @param price 增加金额,单位分
* @param bizId 业务关联 id
* @param bizType 业务关联分类
* @param tags 财务标签字典值,多个值使用英文逗号分隔,允许为空
* @param remark 流水业务备注,允许为空,最多 256 个字符
* @return 已保存的钱包流水
*/
@Override
@Transactional(rollbackFor = Exception.class)
public WalletTransactionEntity addWalletBalance(Long walletId, Integer price, String bizId, WalletBizEnum bizType, String tags, String remark) {
WalletManageEntity wallet = walletManageService.getById(walletId);
if (ObjectUtil.isEmpty(wallet)) {
log.error("|{}|,用户钱包id({})不存在.", TAG, walletId);
@@ -747,10 +766,37 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
insetObj.setBizType(bizType.getCode());
insetObj.setTitle(bizType.getDesc());
insetObj.setTags(tags);
String normalizedRemark = remark == null ? null : remark.strip();
insetObj.setRemark(ObjectUtil.isEmpty(normalizedRemark) ? null : normalizedRemark);
save(insetObj);
return insetObj;
}
/**
* 使用独立更新条件写入备注,确保传入空值时也能清除数据库中的旧备注,同时不触碰流水其他字段。
*
* @param dto 流水编号和新备注
* @return 更新到当前租户有效流水时返回 {@code true}
*/
@Override
public Boolean updateRemark(WalletTransactionRemarkDto dto) {
String remark = dto.getRemark();
if (remark != null) {
remark = remark.strip();
if (remark.isEmpty()) remark = null;
}
// 传入空实体会跳过 MyBatis-Plus 的修改人和修改时间填充,因此保留更新实体承载审计字段。
WalletTransactionEntity updateEntity = new WalletTransactionEntity();
int updated = baseMapper.update(updateEntity, Wrappers.<WalletTransactionEntity>lambdaUpdate()
.eq(WalletTransactionEntity::getId, dto.getId())
.set(WalletTransactionEntity::getRemark, remark));
// 部分 MySQL affected-row 配置会把“值未变化”返回为 0,仅在记录也查不到时才判定失败。
if (updated == 0 && baseMapper.selectById(dto.getId()) == null) {
throw new RuntimeException("钱包流水不存在或已删除");
}
return true;
}
@Override
// @CacheEvict(value = CacheConstants.WALLET_DETAIL, key = "#dto.getWalletId()")
public Boolean updateWalletBalance(WalletTransactionDto dto) {
@@ -763,7 +809,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl<WalletTransactionM
throw new RuntimeException("用户钱包不存在");
}
// 更新钱包余额
WalletTransactionEntity result = getSelf().addWalletBalance(wallet.getId(), dto.getBalance(), String.valueOf(dto.getUserId()), WalletBizEnum.UPDATE, dto.getTags());
WalletTransactionEntity result = getSelf().addWalletBalance(wallet.getId(), dto.getBalance(), String.valueOf(dto.getUserId()), WalletBizEnum.UPDATE, dto.getTags(), dto.getRemark());
// 查询钱包类型
WalletTypeEntity walletType = walletTypeMapper.selectById(wallet.getWalletTypeId());
if (ObjectUtil.isNotEmpty(walletType) && ObjectUtil.equals(walletType.getUniqueCode(), WalletTypeEnum.EAT.getCode())) {
@@ -11,6 +11,7 @@
<result property="price" column="price"/>
<result property="balance" column="balance"/>
<result property="tags" column="tags"/>
<result property="remark" column="remark"/>
</resultMap>
<sql id="selectA">
@@ -24,6 +25,7 @@
a.price,
a.balance,
a.tags,
a.remark,
a.create_by,
a.create_time,
a.update_by,